Skip to content

Commit

Permalink
Merge pull request #397 from mesozoic/pydantic_v2
Browse files Browse the repository at this point in the history
Update to Pydantic v2
  • Loading branch information
mesozoic authored Oct 24, 2024
2 parents 8264744 + 7258fbf commit 3dc0d35
Show file tree
Hide file tree
Showing 29 changed files with 162 additions and 153 deletions.
8 changes: 5 additions & 3 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ API: pyairtable.api.enterprise
.. automodule:: pyairtable.api.enterprise
:members:
:exclude-members: Enterprise
:inherited-members: BaseModel, AirtableModel


API: pyairtable.api.types
Expand Down Expand Up @@ -61,7 +62,7 @@ API: pyairtable.models

.. automodule:: pyairtable.models
:members:
:inherited-members: AirtableModel
:inherited-members: BaseModel, AirtableModel


API: pyairtable.models.comment
Expand All @@ -70,14 +71,15 @@ API: pyairtable.models.comment
.. automodule:: pyairtable.models.comment
:members:
:exclude-members: Comment
:inherited-members: AirtableModel
:inherited-members: BaseModel, AirtableModel


API: pyairtable.models.schema
-------------------------------

.. automodule:: pyairtable.models.schema
:members:
:inherited-members: BaseModel, AirtableModel


API: pyairtable.models.webhook
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------------
Expand Down
16 changes: 15 additions & 1 deletion docs/source/migrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions docs/source/webhooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 0 additions & 12 deletions pyairtable/_compat.py

This file was deleted.

2 changes: 1 addition & 1 deletion pyairtable/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def add_webhook(
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)

Expand Down
11 changes: 6 additions & 5 deletions pyairtable/api/enterprise.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -53,7 +54,7 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup:
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:
"""
Expand Down Expand Up @@ -219,7 +220,7 @@ def handle_event(event):
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
Expand Down Expand Up @@ -405,7 +406,7 @@ class Error(AirtableModel):
message: str


update_forward_refs(vars())
rebuild_models(vars())


# These are at the bottom of the module to avoid circular imports
Expand Down
12 changes: 5 additions & 7 deletions pyairtable/api/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)


Expand Down
48 changes: 23 additions & 25 deletions pyairtable/models/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -166,7 +165,7 @@ def _reload(self, obj: Optional[Dict[str, Any]] = None) -> None:
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}
)


Expand Down Expand Up @@ -248,7 +247,7 @@ def save(self) -> None:
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,
Expand All @@ -264,8 +263,7 @@ def save(self) -> None:

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:
Expand All @@ -274,7 +272,7 @@ def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)


def update_forward_refs(
def rebuild_models(
obj: Union[Type[AirtableModel], Mapping[str, Any]],
memo: Optional[Set[int]] = None,
) -> None:
Expand All @@ -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
17 changes: 11 additions & 6 deletions pyairtable/models/audit.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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):
Expand All @@ -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"
Expand Down Expand Up @@ -73,4 +78,4 @@ class UserInfo(AirtableModel):
AuditLogPayload: TypeAlias = Dict[str, Any]


update_forward_refs(vars())
rebuild_models(vars())
4 changes: 2 additions & 2 deletions pyairtable/models/collaborator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions pyairtable/models/comment.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -88,4 +88,4 @@ class Mentioned(AirtableModel):
email: Optional[str] = None


update_forward_refs(vars())
rebuild_models(vars())
Loading

0 comments on commit 3dc0d35

Please sign in to comment.