Repository: gtalarico/pyairtable
Pull request #397 (2024-10-23 00:57:16)

Update to Pydantic v2
Pydantic v2 seems pretty stable and has a well defined migration path from v1. I also noticed some new warnings in Python 3.13 that certain features which Pydantic v1 uses are going to stop working in Python 3.15, so I figured it was best to just take care of the migration as part of the 3.0 release.


Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 18cfe4e..7bc3caf 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -33,6 +33,7 @@ API: pyairtable.api.enterprise
 .. automodule:: pyairtable.api.enterprise
     :members:
     :exclude-members: Enterprise
+    :inherited-members: BaseModel, AirtableModel
 
 
 API: pyairtable.api.types
@@ -61,7 +62,7 @@ API: pyairtable.models
 
 .. automodule:: pyairtable.models
     :members:
-    :inherited-members: AirtableModel
+    :inherited-members: BaseModel, AirtableModel
 
 
 API: pyairtable.models.comment
@@ -70,7 +71,7 @@ API: pyairtable.models.comment
 .. automodule:: pyairtable.models.comment
     :members:
     :exclude-members: Comment
-    :inherited-members: AirtableModel
+    :inherited-members: BaseModel, AirtableModel
 
 
 API: pyairtable.models.schema
@@ -78,6 +79,7 @@ API: pyairtable.models.schema
 
 .. automodule:: pyairtable.models.schema
     :members:
+    :inherited-members: BaseModel, AirtableModel
 
 
 API: pyairtable.models.webhook
@@ -86,7 +88,7 @@ API: pyairtable.models.webhook
 .. automodule:: pyairtable.models.webhook
     :members:
     :exclude-members: Webhook, WebhookNotification, WebhookPayload
-    :inherited-members: AirtableModel
+    :inherited-members: BaseModel, AirtableModel
 
 
 API: pyairtable.orm
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 5749887..680e5c4 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -51,6 +51,8 @@ Changelog
   - `PR #389 <https://github.com/gtalarico/pyairtable/pull/389>`_
 * Dropped support for Python 3.8.
   - `PR #395 <https://github.com/gtalarico/pyairtable/pull/395>`_
+* Dropped support for Pydantic 1.x.
+  - `PR #397 <https://github.com/gtalarico/pyairtable/pull/397>`_
 
 2.3.4 (2024-10-21)
 ------------------------
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index 79444bd..f85d023 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -9,7 +9,17 @@ Migration Guide
 Migrating from 2.x to 3.0
 ============================
 
-In this release we've made a number of breaking changes, summarized below.
+The 3.0 release introduces a number of breaking changes, summarized below.
+
+Updated minimum dependencies
+---------------------------------------------
+
+* pyAirtable 3.0 is tested on Python 3.9 or higher. It may continue to work on Python 3.8
+  for some time, but bug reports related to Python 3.8 compatibility will not be accepted.
+* pyAirtable 3.0 requires Pydantic 2. If your project still uses Pydantic 1,
+  you will need to continue to use pyAirtable 2.x until you can upgrade Pydantic.
+  Read the `Pydantic v2 migration guide <https://docs.pydantic.dev/latest/migration/>`__
+  for more information.
 
 Deprecated metadata module removed
 ---------------------------------------------
@@ -118,6 +128,10 @@ Breaking name changes
         | has become :class:`pyairtable.exceptions.MissingValueError`
     * - | ``pyairtable.orm.fields.MultipleValues``
         | has become :class:`pyairtable.exceptions.MultipleValuesError`
+    * - | ``pyairtable.models.AuditLogEvent.model_id``
+        | has become :data:`pyairtable.models.AuditLogEvent.object_id`
+    * - | ``pyairtable.models.AuditLogEvent.model_type``
+        | has become :data:`pyairtable.models.AuditLogEvent.object_type`
 
 
 Migrating from 2.2 to 2.3
diff --git a/docs/source/webhooks.rst b/docs/source/webhooks.rst
index 9f6a132..a3ce77e 100644
--- a/docs/source/webhooks.rst
+++ b/docs/source/webhooks.rst
@@ -22,7 +22,3 @@ using a straightforward API within the :class:`~pyairtable.Base` class.
 
 .. automethod:: pyairtable.models.Webhook.payloads
     :noindex:
-
-.. autoclass:: pyairtable.models.WebhookNotification
-    :noindex:
-    :members: from_request
diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py
deleted file mode 100644
index b59654b..0000000
--- a/pyairtable/_compat.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:  # mypy really does not like this conditional import.
-    import pydantic as pydantic
-else:
-    # Pydantic v2 broke a bunch of stuff. Luckily they provide a built-in v1.
-    try:
-        import pydantic.v1 as pydantic
-    except ImportError:  # pragma: no cover
-        import pydantic
-
-__all__ = ["pydantic"]
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 67bee39..263428a 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -279,7 +279,7 @@ class Base:
             spec = WebhookSpecification.from_api(spec, self.api)
 
         create = CreateWebhook(notification_url=notify_url, specification=spec)
-        request = create.dict(by_alias=True, exclude_unset=True)
+        request = create.model_dump(by_alias=True, exclude_unset=True)
         response = self.api.post(self.webhooks_url, json=request)
         return CreateWebhookResponse.from_api(response, self.api)
 
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index 5dee672..b5b133a 100644
--- a/pyairtable/api/enterprise.py
+++ b/pyairtable/api/enterprise.py
@@ -1,8 +1,9 @@
 import datetime
 from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union
 
-from pyairtable._compat import pydantic
-from pyairtable.models._base import AirtableModel, update_forward_refs
+import pydantic
+
+from pyairtable.models._base import AirtableModel, rebuild_models
 from pyairtable.models.audit import AuditLogResponse
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
 from pyairtable.utils import (
@@ -53,7 +54,7 @@ class Enterprise:
         params = {"include": ["collaborations"] if collaborations else []}
         url = self.api.build_url(f"meta/groups/{group_id}")
         payload = self.api.get(url, params=params)
-        return UserGroup.parse_obj(payload)
+        return UserGroup.model_validate(payload)
 
     def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo:
         """
@@ -219,7 +220,7 @@ class Enterprise:
             offset_field=offset_field,
         )
         for count, response in enumerate(iter_requests, start=1):
-            parsed = AuditLogResponse.parse_obj(response)
+            parsed = AuditLogResponse.model_validate(response)
             yield parsed
             if not parsed.events:
                 return
@@ -405,7 +406,7 @@ class ManageUsersResponse(AirtableModel):
         message: str
 
 
-update_forward_refs(vars())
+rebuild_models(vars())
 
 
 # These are at the bottom of the module to avoid circular imports
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index 8180705..3f34b8c 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -6,10 +6,9 @@ and return values to various pyAirtable methods.
 from functools import lru_cache
 from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
 
+import pydantic
 from typing_extensions import Required, TypeAlias, TypedDict
 
-from pyairtable._compat import pydantic
-
 T = TypeVar("T")
 
 #: An alias for ``str`` used internally for disambiguation.
@@ -397,13 +396,12 @@ class UploadAttachmentResultDict(TypedDict):
 
 
 @lru_cache
-def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
+def _create_model_from_typeddict(cls: Type[T]) -> pydantic.TypeAdapter[Any]:
     """
     Create a pydantic model from a TypedDict to use as a validator.
     Memoizes the result so we don't have to call this more than once per class.
     """
-    # Mypy can't tell that we are using pydantic v1.
-    return pydantic.create_model_from_typeddict(cls)  # type: ignore[no-any-return, operator, unused-ignore]
+    return pydantic.TypeAdapter(cls)
 
 
 def assert_typed_dict(cls: Type[T], obj: Any) -> T:
@@ -456,8 +454,8 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T:
                     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)
+    model = _create_model_from_typeddict(cls)  # type: ignore[arg-type]
+    model.validate_python(obj)
     return cast(T, obj)
 
 
diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py
index 6428e21..526723a 100644
--- a/pyairtable/models/_base.py
+++ b/pyairtable/models/_base.py
@@ -3,9 +3,9 @@ from functools import partial
 from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union
 
 import inflection
+import pydantic
 from typing_extensions import Self as SelfType
 
-from pyairtable._compat import pydantic
 from pyairtable.utils import (
     _append_docstring_text,
     datetime_from_iso_str,
@@ -18,31 +18,30 @@ class AirtableModel(pydantic.BaseModel):
     Base model for any data structures that will be loaded from the Airtable API.
     """
 
-    class Config:
-        # Ignore field names we don't recognize, so applications don't crash
-        # if Airtable decides to add new attributes.
-        extra = "ignore"
-
-        # Convert e.g. "base_invite_links" to "baseInviteLinks" for (de)serialization
-        alias_generator = partial(inflection.camelize, uppercase_first_letter=False)
-
-        # Allow both base_invite_links= and baseInviteLinks= in constructor
-        allow_population_by_field_name = True
+    model_config = pydantic.ConfigDict(
+        extra="ignore",
+        alias_generator=partial(inflection.camelize, uppercase_first_letter=False),
+        populate_by_name=True,
+    )
 
     _raw: Any = pydantic.PrivateAttr()
 
     def __init__(self, **data: Any) -> None:
-        self._raw = data.copy()
+        raw = data.copy()
+
         # Convert JSON-serializable input data to the types expected by our model.
         # For now this only converts ISO 8601 strings to datetime objects.
-        for field_model in self.__fields__.values():
-            for name in {field_model.name, field_model.alias}:
-                if not (value := data.get(name)):
+        for field_name, field_model in self.model_fields.items():
+            for name in {field_name, field_model.alias}:
+                if not name or not (value := data.get(name)):
                     continue
-                if isinstance(value, str) and field_model.type_ is datetime:
+                if isinstance(value, str) and field_model.annotation is datetime:
                     data[name] = datetime_from_iso_str(value)
+
         super().__init__(**data)
 
+        self._raw = raw  # must happen *after* __init__
+
     @classmethod
     def from_api(
         cls,
@@ -117,7 +116,7 @@ def cascade_api(
         obj._set_api(api, context=context)
 
     # Find and apply API/context to nested models in every Pydantic field.
-    for field_name in type(obj).__fields__:
+    for field_name in type(obj).model_fields:
         if field_value := getattr(obj, field_name, None):
             cascade_api(field_value, api, context=context)
 
@@ -166,7 +165,7 @@ class RestfulModel(AirtableModel):
             obj = self._api.get(self._url)
         copyable = type(self).from_api(obj, self._api, context=self._url_context)
         self.__dict__.update(
-            {key: copyable.__dict__.get(key) for key in type(self).__fields__}
+            {key: copyable.__dict__.get(key) for key in type(self).model_fields}
         )
 
 
@@ -248,7 +247,7 @@ class CanUpdateModel(RestfulModel):
             raise RuntimeError("save() called with no URL specified")
         include = set(self.__writable) if self.__writable else None
         exclude = set(self.__readonly) if self.__readonly else None
-        data = self.dict(
+        data = self.model_dump(
             by_alias=True,
             include=include,
             exclude=exclude,
@@ -264,8 +263,7 @@ class CanUpdateModel(RestfulModel):
 
     def __setattr__(self, name: str, value: Any) -> None:
         # Prevents implementers from changing values on readonly or non-writable fields.
-        # Mypy can't tell that we are using pydantic v1.
-        if name in self.__class__.__fields__:  # type: ignore[operator, unused-ignore]
+        if name in self.__class__.model_fields:
             if self.__readonly and name in self.__readonly:
                 raise AttributeError(name)
             if self.__writable is not None and name not in self.__writable:
@@ -274,7 +272,7 @@ class CanUpdateModel(RestfulModel):
         super().__setattr__(name, value)
 
 
-def update_forward_refs(
+def rebuild_models(
     obj: Union[Type[AirtableModel], Mapping[str, Any]],
     memo: Optional[Set[int]] = None,
 ) -> None:
@@ -301,12 +299,12 @@ def update_forward_refs(
         if id(obj) in memo:
             return
         memo.add(id(obj))
-        obj.update_forward_refs()
-        return update_forward_refs(vars(obj), memo=memo)
+        obj.model_rebuild()
+        return rebuild_models(vars(obj), memo=memo)
     # If it's a mapping, update refs for any AirtableModel instances.
     for value in obj.values():
         if isinstance(value, type) and issubclass(value, AirtableModel):
-            update_forward_refs(value, memo=memo)
+            rebuild_models(value, memo=memo)
 
 
 import pyairtable.api.api  # noqa
diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py
index 69e3499..5f1a1c8 100644
--- a/pyairtable/models/audit.py
+++ b/pyairtable/models/audit.py
@@ -1,9 +1,10 @@
 from datetime import datetime
 from typing import Any, Dict, List, Optional
 
+import pydantic
 from typing_extensions import TypeAlias
 
-from pyairtable.models._base import AirtableModel, update_forward_refs
+from pyairtable.models._base import AirtableModel, rebuild_models
 
 
 class AuditLogResponse(AirtableModel):
@@ -18,8 +19,8 @@ class AuditLogResponse(AirtableModel):
     pagination: Optional["AuditLogResponse.Pagination"] = None
 
     class Pagination(AirtableModel):
-        next: Optional[str]
-        previous: Optional[str]
+        next: Optional[str] = None
+        previous: Optional[str] = None
 
 
 class AuditLogEvent(AirtableModel):
@@ -28,14 +29,18 @@ class AuditLogEvent(AirtableModel):
 
     See `Audit log events <https://airtable.com/developers/web/api/audit-log-events>`__
     for more information on how to interpret this data structure.
+
+    To avoid namespace conflicts with the Pydantic library, the
+    ``modelId`` and ``modelType`` fields from the Airtable API are
+    represented as fields named ``object_id`` and ``object_type``.
     """
 
     id: str
     timestamp: datetime
     action: str
     actor: "AuditLogActor"
-    model_id: str
-    model_type: str
+    object_id: str = pydantic.Field(alias="modelId")
+    object_type: str = pydantic.Field(alias="modelType")
     payload: "AuditLogPayload"
     payload_version: str
     context: "AuditLogEvent.Context"
@@ -73,4 +78,4 @@ class AuditLogActor(AirtableModel):
 AuditLogPayload: TypeAlias = Dict[str, Any]
 
 
-update_forward_refs(vars())
+rebuild_models(vars())
diff --git a/pyairtable/models/collaborator.py b/pyairtable/models/collaborator.py
index a55c033..ab0eb55 100644
--- a/pyairtable/models/collaborator.py
+++ b/pyairtable/models/collaborator.py
@@ -20,7 +20,7 @@ class Collaborator(AirtableModel):
     id: UserId
 
     #: The email address of the user.
-    email: Optional[str]
+    email: Optional[str] = None
 
     #: The display name of the user.
-    name: Optional[str]
+    name: Optional[str] = None
diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py
index af5c53a..e956632 100644
--- a/pyairtable/models/comment.py
+++ b/pyairtable/models/comment.py
@@ -1,9 +1,9 @@
 from datetime import datetime
 from typing import Dict, Optional
 
-from pyairtable._compat import pydantic
+import pydantic
 
-from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs
+from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, rebuild_models
 from .collaborator import Collaborator
 
 
@@ -54,7 +54,7 @@ class Comment(
     created_time: datetime
 
     #: The ISO 8601 timestamp of when the comment was last edited.
-    last_updated_time: Optional[datetime]
+    last_updated_time: Optional[datetime] = None
 
     #: The account which created the comment.
     author: Collaborator
@@ -88,4 +88,4 @@ class Mentioned(AirtableModel):
     email: Optional[str] = None
 
 
-update_forward_refs(vars())
+rebuild_models(vars())
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
index 159f21d..5bfb162 100644
--- a/pyairtable/models/schema.py
+++ b/pyairtable/models/schema.py
@@ -3,9 +3,9 @@ from datetime import datetime
 from functools import partial
 from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast
 
+import pydantic
 from typing_extensions import TypeAlias
 
-from pyairtable._compat import pydantic
 from pyairtable.api.types import AddCollaboratorDict
 
 from ._base import (
@@ -13,7 +13,7 @@ from ._base import (
     CanDeleteModel,
     CanUpdateModel,
     RestfulModel,
-    update_forward_refs,
+    rebuild_models,
 )
 
 _T = TypeVar("_T", bound=Any)
@@ -24,10 +24,10 @@ _FD = partial(pydantic.Field, default_factory=dict)
 def _F(classname: str, **kwargs: Any) -> Any:
     def _create_default_from_classname() -> Any:
         this_module = importlib.import_module(__name__)
-        obj = this_module
+        obj: Any = this_module
         for segment in classname.split("."):
             obj = getattr(obj, segment)
-        return obj
+        return obj()
 
     kwargs["default_factory"] = _create_default_from_classname
     return pydantic.Field(**kwargs)
@@ -180,7 +180,7 @@ class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"):
         url="meta/bases/{base.id}/interfaces/{key}",
     ):
         created_time: datetime
-        first_publish_time: Optional[datetime]
+        first_publish_time: Optional[datetime] = None
         group_collaborators: List["GroupCollaborator"] = _FL()
         individual_collaborators: List["IndividualCollaborator"] = _FL()
         invite_links: List["InterfaceInviteLink"] = _FL()
@@ -310,7 +310,7 @@ class TableSchema(
     id: str
     name: str
     primary_field_id: str
-    description: Optional[str]
+    description: Optional[str] = None
     fields: List["FieldSchema"]
     views: List["ViewSchema"]
 
@@ -345,8 +345,8 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"):
     id: str
     type: str
     name: str
-    personal_for_user_id: Optional[str]
-    visible_field_ids: Optional[List[str]]
+    personal_for_user_id: Optional[str] = None
+    visible_field_ids: Optional[List[str]] = None
 
 
 class GroupCollaborator(AirtableModel):
@@ -383,7 +383,7 @@ class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"):
     id: str
     type: str
     created_time: datetime
-    invited_email: Optional[str]
+    invited_email: Optional[str] = None
     referred_by_user_id: str
     permission_level: str
     restricted_to_email_domains: List[str] = _FL()
@@ -561,15 +561,15 @@ class UserInfo(
     state: str
     is_sso_required: bool
     is_two_factor_auth_enabled: bool
-    last_activity_time: Optional[datetime]
-    created_time: Optional[datetime]
-    enterprise_user_type: Optional[str]
-    invited_to_airtable_by_user_id: Optional[str]
+    last_activity_time: Optional[datetime] = None
+    created_time: Optional[datetime] = None
+    enterprise_user_type: Optional[str] = None
+    invited_to_airtable_by_user_id: Optional[str] = None
     is_managed: bool = False
     is_admin: bool = False
     is_super_admin: bool = False
     groups: List[NestedId] = _FL()
-    collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations)
+    collaborations: "Collaborations" = _F("Collaborations")
 
     def logout(self) -> None:
         self._api.post(self._url + "/logout")
@@ -588,7 +588,7 @@ class UserGroup(AirtableModel):
     created_time: datetime
     updated_time: datetime
     members: List["UserGroup.Member"]
-    collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations)
+    collaborations: "Collaborations" = _F("Collaborations")
 
     class Member(AirtableModel):
         user_id: str
@@ -617,8 +617,8 @@ class AITextFieldConfig(AirtableModel):
 
 
 class AITextFieldOptions(AirtableModel):
-    prompt: Optional[List[Union[str, "AITextFieldOptions.PromptField"]]]
-    referenced_field_ids: Optional[List[str]]
+    prompt: List[Union[str, "AITextFieldOptions.PromptField"]] = _FL()
+    referenced_field_ids: List[str] = _FL()
 
     class PromptField(AirtableModel):
         field: NestedFieldId
@@ -673,7 +673,7 @@ class CountFieldConfig(AirtableModel):
 
 class CountFieldOptions(AirtableModel):
     is_valid: bool
-    record_link_field_id: Optional[str]
+    record_link_field_id: Optional[str] = None
 
 
 class CreatedByFieldConfig(AirtableModel):
@@ -784,8 +784,8 @@ class FormulaFieldConfig(AirtableModel):
 class FormulaFieldOptions(AirtableModel):
     formula: str
     is_valid: bool
-    referenced_field_ids: Optional[List[str]]
-    result: Optional["FieldConfig"]
+    referenced_field_ids: Optional[List[str]] = None
+    result: Optional["FieldConfig"] = None
 
 
 class LastModifiedByFieldConfig(AirtableModel):
@@ -807,8 +807,8 @@ class LastModifiedTimeFieldConfig(AirtableModel):
 
 class LastModifiedTimeFieldOptions(AirtableModel):
     is_valid: bool
-    referenced_field_ids: Optional[List[str]]
-    result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]]
+    referenced_field_ids: Optional[List[str]] = None
+    result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] = None
 
 
 class ManualSortFieldConfig(AirtableModel):
@@ -862,10 +862,10 @@ class MultipleLookupValuesFieldConfig(AirtableModel):
 
 
 class MultipleLookupValuesFieldOptions(AirtableModel):
-    field_id_in_linked_table: Optional[str]
     is_valid: bool
-    record_link_field_id: Optional[str]
-    result: Optional["FieldConfig"]
+    field_id_in_linked_table: Optional[str] = None
+    record_link_field_id: Optional[str] = None
+    result: Optional["FieldConfig"] = None
 
 
 class MultipleRecordLinksFieldConfig(AirtableModel):
@@ -881,8 +881,8 @@ class MultipleRecordLinksFieldOptions(AirtableModel):
     is_reversed: bool
     linked_table_id: str
     prefers_single_record_link: bool
-    inverse_link_field_id: Optional[str]
-    view_id_for_record_selection: Optional[str]
+    inverse_link_field_id: Optional[str] = None
+    view_id_for_record_selection: Optional[str] = None
 
 
 class MultipleSelectsFieldConfig(AirtableModel):
@@ -957,11 +957,11 @@ class RollupFieldConfig(AirtableModel):
 
 
 class RollupFieldOptions(AirtableModel):
-    field_id_in_linked_table: Optional[str]
+    field_id_in_linked_table: Optional[str] = None
     is_valid: bool
-    record_link_field_id: Optional[str]
-    referenced_field_ids: Optional[List[str]]
-    result: Optional["FieldConfig"]
+    record_link_field_id: Optional[str] = None
+    referenced_field_ids: Optional[List[str]] = None
+    result: Optional["FieldConfig"] = None
 
 
 class SingleCollaboratorFieldConfig(AirtableModel):
@@ -995,7 +995,7 @@ class SingleSelectFieldOptions(AirtableModel):
     class Choice(AirtableModel):
         id: str
         name: str
-        color: Optional[str]
+        color: Optional[str] = None
 
 
 class UrlFieldConfig(AirtableModel):
@@ -1013,7 +1013,7 @@ class UnknownFieldConfig(AirtableModel):
     """
 
     type: str
-    options: Optional[Dict[str, Any]]
+    options: Optional[Dict[str, Any]] = None
 
 
 class _FieldSchemaBase(
@@ -1024,7 +1024,7 @@ class _FieldSchemaBase(
 ):
     id: str
     name: str
-    description: Optional[str]
+    description: Optional[str] = None
 
 
 # This section is auto-generated so that FieldSchema and FieldConfig are kept aligned.
@@ -1367,7 +1367,7 @@ def parse_field_schema(obj: Dict[str, Any]) -> FieldSchema:
     Given a ``dict`` representing a field schema,
     parse it into the appropriate FieldSchema subclass.
     """
-    return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema
+    return _HasFieldSchema.model_validate({"field_schema": obj}).field_schema
 
 
-update_forward_refs(vars())
+rebuild_models(vars())
diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py
index d2f3d35..9a93284 100644
--- a/pyairtable/models/webhook.py
+++ b/pyairtable/models/webhook.py
@@ -4,12 +4,12 @@ from functools import partial
 from hmac import HMAC
 from typing import Any, Callable, Dict, Iterator, List, Optional, Union
 
+import pydantic
 from typing_extensions import Self as SelfType
 
-from pyairtable._compat import pydantic
 from pyairtable.api.types import RecordId
 
-from ._base import AirtableModel, CanDeleteModel, update_forward_refs
+from ._base import AirtableModel, CanDeleteModel, rebuild_models
 
 # Shortcuts to avoid lots of line wrapping
 FD: Callable[[], Any] = partial(pydantic.Field, default_factory=dict)
@@ -56,10 +56,10 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"):
     are_notifications_enabled: bool
     cursor_for_next_payload: int
     is_hook_enabled: bool
-    last_successful_notification_time: Optional[datetime]
-    notification_url: Optional[str]
-    last_notification_result: Optional["WebhookNotificationResult"]
-    expiration_time: Optional[datetime]
+    last_successful_notification_time: Optional[datetime] = None
+    notification_url: Optional[str] = None
+    last_notification_result: Optional["WebhookNotificationResult"] = None
+    expiration_time: Optional[datetime] = None
     specification: "WebhookSpecification"
 
     def enable_notifications(self) -> None:
@@ -237,7 +237,7 @@ class WebhookNotification(AirtableModel):
         expected = "hmac-sha256=" + hmac.hexdigest()
         if header != expected:
             raise ValueError("X-Airtable-Content-MAC header failed validation")
-        return cls.parse_raw(body)
+        return cls.model_validate_json(body)
 
 
 class WebhookNotificationResult(AirtableModel):
@@ -258,19 +258,19 @@ class WebhookSpecification(AirtableModel):
 
     class Options(AirtableModel):
         filters: "WebhookSpecification.Filters"
-        includes: Optional["WebhookSpecification.Includes"]
+        includes: Optional["WebhookSpecification.Includes"] = None
 
     class Filters(AirtableModel):
         data_types: List[str]
-        record_change_scope: Optional[str]
+        record_change_scope: Optional[str] = None
         change_types: List[str] = FL()
         from_sources: List[str] = FL()
-        source_options: Optional["WebhookSpecification.SourceOptions"]
+        source_options: Optional["WebhookSpecification.SourceOptions"] = None
         watch_data_in_field_ids: List[str] = FL()
         watch_schemas_of_field_ids: List[str] = FL()
 
     class SourceOptions(AirtableModel):
-        form_submission: Optional["WebhookSpecification.FormSubmission"]
+        form_submission: Optional["WebhookSpecification.FormSubmission"] = None
 
     class FormSubmission(AirtableModel):
         view_id: str
@@ -282,7 +282,7 @@ class WebhookSpecification(AirtableModel):
 
 
 class CreateWebhook(AirtableModel):
-    notification_url: Optional[str]
+    notification_url: Optional[str] = None
     specification: WebhookSpecification
 
 
@@ -301,7 +301,7 @@ class CreateWebhookResponse(AirtableModel):
     mac_secret_base64: str
 
     #: The timestamp when the webhook will expire and be deleted.
-    expiration_time: Optional[datetime]
+    expiration_time: Optional[datetime] = None
 
 
 class WebhookPayload(AirtableModel):
@@ -313,16 +313,16 @@ class WebhookPayload(AirtableModel):
     timestamp: datetime
     base_transaction_number: int
     payload_format: str
-    action_metadata: Optional["WebhookPayload.ActionMetadata"]
+    action_metadata: Optional["WebhookPayload.ActionMetadata"] = None
     changed_tables_by_id: Dict[str, "WebhookPayload.TableChanged"] = FD()
     created_tables_by_id: Dict[str, "WebhookPayload.TableCreated"] = FD()
     destroyed_table_ids: List[str] = FL()
-    error: Optional[bool]
-    error_code: Optional[str] = pydantic.Field(alias="code")
+    error: Optional[bool] = None
+    error_code: Optional[str] = pydantic.Field(alias="code", default=None)
 
     #: This is not a part of Airtable's webhook payload specification.
     #: This indicates the cursor field in the response which provided this payload.
-    cursor: Optional[int]
+    cursor: Optional[int] = None
 
     class ActionMetadata(AirtableModel):
         source: str
@@ -333,12 +333,12 @@ class WebhookPayload(AirtableModel):
         description: Optional[str] = None
 
     class FieldInfo(AirtableModel):
-        name: Optional[str]
-        type: Optional[str]
+        name: Optional[str] = None
+        type: Optional[str] = None
 
     class FieldChanged(AirtableModel):
         current: "WebhookPayload.FieldInfo"
-        previous: Optional["WebhookPayload.FieldInfo"]
+        previous: Optional["WebhookPayload.FieldInfo"] = None
 
     class TableChanged(AirtableModel):
         changed_views_by_id: Dict[str, "WebhookPayload.ViewChanged"] = FD()
@@ -346,7 +346,7 @@ class WebhookPayload(AirtableModel):
         changed_records_by_id: Dict[RecordId, "WebhookPayload.RecordChanged"] = FD()
         created_fields_by_id: Dict[str, "WebhookPayload.FieldInfo"] = FD()
         created_records_by_id: Dict[RecordId, "WebhookPayload.RecordCreated"] = FD()
-        changed_metadata: Optional["WebhookPayload.TableChanged.ChangedMetadata"]
+        changed_metadata: Optional["WebhookPayload.TableChanged.ChangedMetadata"] = None
         destroyed_field_ids: List[str] = FL()
         destroyed_record_ids: List[RecordId] = FL()
 
@@ -360,14 +360,14 @@ class WebhookPayload(AirtableModel):
         destroyed_record_ids: List[RecordId] = FL()
 
     class TableCreated(AirtableModel):
-        metadata: Optional["WebhookPayload.TableInfo"]
+        metadata: Optional["WebhookPayload.TableInfo"] = None
         fields_by_id: Dict[str, "WebhookPayload.FieldInfo"] = FD()
         records_by_id: Dict[RecordId, "WebhookPayload.RecordCreated"] = FD()
 
     class RecordChanged(AirtableModel):
         current: "WebhookPayload.CellValuesByFieldId"
-        previous: Optional["WebhookPayload.CellValuesByFieldId"]
-        unchanged: Optional["WebhookPayload.CellValuesByFieldId"]
+        previous: Optional["WebhookPayload.CellValuesByFieldId"] = None
+        unchanged: Optional["WebhookPayload.CellValuesByFieldId"] = None
 
     class CellValuesByFieldId(AirtableModel):
         cell_values_by_field_id: Dict[str, Any]
@@ -383,4 +383,4 @@ class WebhookPayloads(AirtableModel):
     payloads: List[WebhookPayload]
 
 
-update_forward_refs(vars())
+rebuild_models(vars())
diff --git a/requirements-dev.txt b/requirements-dev.txt
index c833c46..67a5783 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,7 +5,7 @@ sphinx-autoapi
 sphinxext-opengraph
 revitron-sphinx-theme @ git+https://github.com/gtalarico/revitron-sphinx-theme.git@40f4b09fa5c199e3844153ef973a1155a56981dd
 sphinx-autodoc-typehints
-autodoc-pydantic<2
+autodoc-pydantic>=2
 sphinxcontrib-applehelp==1.0.4
 sphinxcontrib-devhelp==1.0.2
 sphinxcontrib-htmlhelp==2.0.1
diff --git a/setup.cfg b/setup.cfg
index 4ea5425..240f5d9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -31,7 +31,7 @@ classifiers =
 packages = find:
 install_requires =
     inflection
-    pydantic
+    pydantic >= 2, < 3
     requests >= 2.22.0
     typing_extensions
     urllib3 >= 1.26
diff --git a/tox.ini b/tox.ini
index b39a2f0..f25eb58 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,7 +2,7 @@
 envlist =
     pre-commit
     mypy-py3{9,10,11,12,13}
-    py3{9,10,11,12,13}{,-pydantic1,-requestsmin}
+    py3{9,10,11,12,13}{,-requestsmin}
     integration
     coverage
 
@@ -25,7 +25,6 @@ extras = cli
 deps =
     -r requirements-test.txt
     requestsmin: requests==2.22.0  # Keep in sync with setup.cfg
-    pydantic1: pydantic<2  # Lots of projects still use 1.x
 
 [testenv:pre-commit]
 deps = pre-commit


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 1567a2d..9fcc597 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -179,7 +179,7 @@ def schema_obj(api, sample_json):
         if context:
             obj = obj_cls.from_api(obj_data, api, context=context)
         else:
-            obj = obj_cls.parse_obj(obj_data)
+            obj = obj_cls.model_validate(obj_data)
 
         if obj_path:
             obj = eval(f"obj.{obj_path}", None, {"obj": obj})
diff --git a/tests/sample_data/BaseSchema.json b/tests/sample_data/BaseSchema.json
index 993cae6..5577ace 100644
--- a/tests/sample_data/BaseSchema.json
+++ b/tests/sample_data/BaseSchema.json
@@ -12,6 +12,9 @@
         {
           "id": "fldoaIqdn5szURHpw",
           "name": "Pictures",
+          "options": {
+            "isReversed": false
+          },
           "type": "multipleAttachments"
         },
         {
diff --git a/tests/sample_data/TableSchema.json b/tests/sample_data/TableSchema.json
index ddeeb3c..c91024e 100644
--- a/tests/sample_data/TableSchema.json
+++ b/tests/sample_data/TableSchema.json
@@ -10,6 +10,9 @@
     {
       "id": "fldoaIqdn5szURHpw",
       "name": "Pictures",
+      "options": {
+        "isReversed": false
+      },
       "type": "multipleAttachments"
     },
     {
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index 7f12673..21aea5c 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -62,8 +62,8 @@ def fake_audit_log_events(counter, page_size=N_AUDIT_PAGE_SIZE):
             "timestamp": datetime.datetime.now().isoformat(),
             "action": "viewBase",
             "actor": {"type": "anonymousUser"},
-            "model_id": (base_id := fake_id("app")),
-            "model_type": "base",
+            "modelId": (base_id := fake_id("app")),
+            "modelType": "base",
             "payload": {"name": "The Base Name"},
             "payloadVersion": "1.0",
             "context": {
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index 4a4f9d4..8e9277f 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -17,7 +17,7 @@ NOW = datetime.now(timezone.utc).isoformat()
 
 @pytest.fixture()
 def table_schema(sample_json, api, base) -> TableSchema:
-    return TableSchema.parse_obj(sample_json("TableSchema"))
+    return TableSchema.model_validate(sample_json("TableSchema"))
 
 
 @pytest.fixture
diff --git a/tests/test_api_types.py b/tests/test_api_types.py
index ede5a0d..2ed38ff 100644
--- a/tests/test_api_types.py
+++ b/tests/test_api_types.py
@@ -1,6 +1,6 @@
+import pydantic
 import pytest
 
-from pyairtable._compat import pydantic
 from pyairtable.api import types as T
 from pyairtable.testing import fake_attachment, fake_id, fake_record, fake_user
 
diff --git a/tests/test_models.py b/tests/test_models.py
index 7f75bd5..e830e61 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -8,7 +8,7 @@ from pyairtable.models._base import (
     CanDeleteModel,
     CanUpdateModel,
     RestfulModel,
-    update_forward_refs,
+    rebuild_models,
 )
 
 
@@ -132,7 +132,7 @@ def test_save__nested_reload(requests_mock, api):
             id: int
             name: str
 
-    update_forward_refs(Parent)
+    rebuild_models(Parent)
 
     parent_data = {
         "id": 1,
@@ -248,7 +248,7 @@ def test_update_forward_refs():
     Outer.Inner.Outer = Outer
 
     # This will cause RecursionError if we're not careful
-    update_forward_refs(Outer)
+    rebuild_models(Outer)
 
 
 def test_restfulmodel__set_url(api, base):
diff --git a/tests/test_models_collaborator.py b/tests/test_models_collaborator.py
index a163047..4f47ab2 100644
--- a/tests/test_models_collaborator.py
+++ b/tests/test_models_collaborator.py
@@ -10,7 +10,7 @@ fake_user_data = {
 
 
 def test_parse():
-    user = Collaborator.parse_obj(fake_user_data)
+    user = Collaborator.model_validate(fake_user_data)
     assert user.id == fake_user_data["id"]
     assert user.email == fake_user_data["email"]
     assert user.name == fake_user_data["name"]
diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py
index 5469810..2f59747 100644
--- a/tests/test_models_comment.py
+++ b/tests/test_models_comment.py
@@ -26,7 +26,7 @@ def comments_url(base, table):
 
 
 def test_parse(comment_json):
-    c = Comment.parse_obj(comment_json)
+    c = Comment.model_validate(comment_json)
     assert isinstance(c.created_time, datetime.datetime)
     assert isinstance(c.last_updated_time, datetime.datetime)
 
@@ -37,7 +37,7 @@ def test_missing_attributes(comment_json):
     """
     del comment_json["lastUpdatedTime"]
     del comment_json["mentioned"]
-    comment = Comment.parse_obj(comment_json)
+    comment = Comment.model_validate(comment_json)
     assert comment.mentioned == {}
     assert comment.last_updated_time is None
 
diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py
index 250f0cf..9c1aebc 100644
--- a/tests/test_models_schema.py
+++ b/tests/test_models_schema.py
@@ -21,12 +21,12 @@ from pyairtable.testing import fake_id
 )
 def test_parse(sample_json, clsname):
     cls = attrgetter(clsname)(schema)
-    cls.parse_obj(sample_json(clsname))
+    cls.model_validate(sample_json(clsname))
 
 
 @pytest.mark.parametrize("cls", schema.FieldSchema.__args__)
 def test_parse_field(sample_json, cls):
-    cls.parse_obj(sample_json("field_schema/" + cls.__name__))
+    cls.model_validate(sample_json("field_schema/" + cls.__name__))
 
 
 @pytest.mark.parametrize(
@@ -44,7 +44,7 @@ def test_parse_field(sample_json, cls):
 )
 def test_find_in_collection(clsname, method, id_or_name, sample_json):
     cls = attrgetter(clsname)(schema)
-    obj = cls.parse_obj(sample_json(clsname))
+    obj = cls.model_validate(sample_json(clsname))
     assert getattr(obj, method)(id_or_name)
 
 
@@ -97,7 +97,7 @@ def test_find():
     and skips any models that are marked as deleted.
     """
 
-    collection = Outer.parse_obj(
+    collection = Outer.model_validate(
         {
             "inners": [
                 {"id": "0001", "name": "One"},
diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py
index 5a5572a..6e9d2f2 100644
--- a/tests/test_models_webhook.py
+++ b/tests/test_models_webhook.py
@@ -31,7 +31,7 @@ def payload_json(sample_json):
 )
 def test_parse(sample_json, clsname):
     cls = attrgetter(clsname)(pyairtable.models.webhook)
-    cls.parse_obj(sample_json(clsname))
+    cls.model_validate(sample_json(clsname))
 
 
 @pytest.mark.parametrize(
@@ -67,7 +67,7 @@ def test_delete(webhook: Webhook, requests_mock):
 
 def test_error_payload(payload_json):
     payload_json.update({"error": True, "code": "INVALID_HOOK"})
-    payload = WebhookPayload.parse_obj(payload_json)
+    payload = WebhookPayload.model_validate(payload_json)
     assert payload.error is True
     assert payload.error_code == "INVALID_HOOK"
 
diff --git a/tests/test_orm_generate.py b/tests/test_orm_generate.py
index 1446bce..6692516 100644
--- a/tests/test_orm_generate.py
+++ b/tests/test_orm_generate.py
@@ -61,7 +61,7 @@ def test_lookup_field_type_annotation(result_schema, expected):
         "type": "multipleLookupValues",
         "options": {"isValid": True, "result": result_schema},
     }
-    obj = schema.MultipleLookupValuesFieldSchema.parse_obj(struct)
+    obj = schema.MultipleLookupValuesFieldSchema.model_validate(struct)
     assert generate.lookup_field_type_annotation(obj) == expected