diff --git a/docs/source/orm.rst b/docs/source/orm.rst index d0e2a8f6..fb2e95e7 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -360,7 +360,7 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/docs/source/tables.rst b/docs/source/tables.rst index a76308ef..552d7a47 100644 --- a/docs/source/tables.rst +++ b/docs/source/tables.rst @@ -278,7 +278,7 @@ and :meth:`~pyairtable.Table.add_comment` methods will return instances of Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 358b66d7..e850cc3d 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -210,7 +210,7 @@ def webhooks(self) -> List[Webhook]: last_successful_notification_time=None, notification_url="https://example.com", last_notification_result=None, - expiration_time="2023-07-01T00:00:00.000Z", + expiration_time=datetime.datetime(...), specification: WebhookSpecification(...) ) ] @@ -264,7 +264,7 @@ def add_webhook( CreateWebhookResponse( id='ach00000000000001', mac_secret_base64='c3VwZXIgZHVwZXIgc2VjcmV0', - expiration_time='2023-07-01T00:00:00.000Z' + expiration_time=datetime.datetime(...) ) Raises: diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 0cc4cdc5..3050bbe2 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -562,7 +562,7 @@ def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]: Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 479d201f..64b1f105 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import partial from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union @@ -5,7 +6,11 @@ from typing_extensions import Self as SelfType from pyairtable._compat import pydantic -from pyairtable.utils import _append_docstring_text +from pyairtable.utils import ( + _append_docstring_text, + datetime_from_iso_str, + datetime_to_iso_str, +) class AirtableModel(pydantic.BaseModel): @@ -30,8 +35,16 @@ class Config: _raw: Any = pydantic.PrivateAttr() def __init__(self, **data: Any) -> None: + self._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)): + continue + if isinstance(value, str) and field_model.type_ is datetime: + data[name] = datetime_from_iso_str(value) super().__init__(**data) - self._raw = data @classmethod def from_api( @@ -244,6 +257,10 @@ def save(self) -> None: exclude=exclude, exclude_none=(not self.__save_none), ) + # This undoes the finagling we do in __init__, converting datetime back to str. + for key in data: + if isinstance(value := data.get(key), datetime): + data[key] = datetime_to_iso_str(value) response = self._api.request(self.__save_http_method, self._url, json=data) if self.__reload_after_save: self._reload(response) diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index 00a63f25..69e34992 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional from typing_extensions import TypeAlias @@ -30,7 +31,7 @@ class AuditLogEvent(AirtableModel): """ id: str - timestamp: str + timestamp: datetime action: str actor: "AuditLogActor" model_id: str diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 4d27a917..3769e2cf 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict, Optional from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs @@ -19,7 +20,7 @@ class Comment( Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( @@ -48,7 +49,7 @@ class Comment( text: str #: The ISO 8601 timestamp of when the comment was created. - created_time: str + created_time: datetime #: The ISO 8601 timestamp of when the comment was last edited. last_updated_time: Optional[str] diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0de7056b..1b4dd9f8 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,4 +1,5 @@ import importlib +from datetime import datetime from functools import partial from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast @@ -178,7 +179,7 @@ class InterfaceCollaborators( _Collaborators, url="meta/bases/{base.id}/interfaces/{key}", ): - created_time: str + created_time: datetime group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() invite_links: List["InterfaceInviteLink"] = _FL() @@ -214,7 +215,7 @@ class Info( ): state: str created_by_user_id: str - created_time: str + created_time: datetime share_id: str type: str is_password_protected: bool @@ -348,7 +349,7 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): class GroupCollaborator(AirtableModel): - created_time: str + created_time: datetime granted_by_user_id: str group_id: str name: str @@ -356,7 +357,7 @@ class GroupCollaborator(AirtableModel): class IndividualCollaborator(AirtableModel): - created_time: str + created_time: datetime granted_by_user_id: str user_id: str email: str @@ -380,7 +381,7 @@ class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"): id: str type: str - created_time: str + created_time: datetime invited_email: Optional[str] referred_by_user_id: str permission_level: str @@ -427,7 +428,7 @@ class EnterpriseInfo(AirtableModel): """ id: str - created_time: str + created_time: datetime group_ids: List[str] user_ids: List[str] workspace_ids: List[str] @@ -447,7 +448,7 @@ class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): id: str name: str - created_time: str + created_time: datetime base_ids: List[str] restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip group_collaborators: "WorkspaceCollaborators.GroupCollaborators" = _F("WorkspaceCollaborators.GroupCollaborators") # fmt: skip @@ -527,7 +528,7 @@ def workspaces(self) -> Dict[str, "Collaborations.WorkspaceCollaboration"]: class BaseCollaboration(AirtableModel): base_id: str - created_time: str + created_time: datetime granted_by_user_id: str permission_level: str @@ -536,7 +537,7 @@ class InterfaceCollaboration(BaseCollaboration): class WorkspaceCollaboration(AirtableModel): workspace_id: str - created_time: str + created_time: datetime granted_by_user_id: str permission_level: str @@ -559,8 +560,8 @@ class UserInfo( state: str is_sso_required: bool is_two_factor_auth_enabled: bool - last_activity_time: Optional[str] - created_time: Optional[str] + last_activity_time: Optional[datetime] + created_time: Optional[datetime] enterprise_user_type: Optional[str] invited_to_airtable_by_user_id: Optional[str] is_managed: bool = False @@ -581,8 +582,8 @@ class UserGroup(AirtableModel): id: str name: str enterprise_account_id: str - created_time: str - updated_time: str + created_time: datetime + updated_time: datetime members: List["UserGroup.Member"] collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) @@ -592,7 +593,7 @@ class Member(AirtableModel): first_name: str last_name: str role: str - created_time: str + created_time: datetime # The data model is a bit confusing here, but it's designed for maximum reuse. diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index e8d7cdbf..3ca54552 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -1,4 +1,5 @@ import base64 +from datetime import datetime from functools import partial from hmac import HMAC from typing import Any, Callable, Dict, Iterator, List, Optional, Union @@ -30,7 +31,7 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"): CreateWebhookResponse( id='ach00000000000001', mac_secret_base64='c3VwZXIgZHVwZXIgc2VjcmV0', - expiration_time='2023-07-01T00:00:00.000Z' + expiration_time=datetime.datetime(...) ) >>> webhooks = base.webhooks() >>> webhooks[0] @@ -42,7 +43,7 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"): last_successful_notification_time=None, notification_url="https://example.com", last_notification_result=None, - expiration_time="2023-07-01T00:00:00.000Z", + expiration_time=datetime.datetime(...), specification: WebhookSpecification(...) ) >>> webhooks[0].disable_notifications() @@ -110,7 +111,7 @@ def payloads( >>> iter_payloads = webhook.payloads() >>> next(iter_payloads) WebhookPayload( - timestamp="2022-02-01T21:25:05.663Z", + timestamp=datetime.datetime(...), base_transaction_number=4, payload_format="v0", action_metadata=ActionMetadata( @@ -204,7 +205,7 @@ def airtable_webhook(): base: _NestedId webhook: _NestedId - timestamp: str + timestamp: datetime @classmethod def from_request( @@ -241,7 +242,7 @@ def from_request( class WebhookNotificationResult(AirtableModel): success: bool - completion_timestamp: str + completion_timestamp: datetime duration_ms: float retry_number: int will_be_retried: Optional[bool] = None @@ -300,7 +301,7 @@ class CreateWebhookResponse(AirtableModel): mac_secret_base64: str #: The timestamp when the webhook will expire and be deleted. - expiration_time: Optional[str] + expiration_time: Optional[datetime] class WebhookPayload(AirtableModel): @@ -309,7 +310,7 @@ class WebhookPayload(AirtableModel): `Webhooks payload `_. """ - timestamp: str + timestamp: datetime base_transaction_number: int payload_format: str action_metadata: Optional["WebhookPayload.ActionMetadata"] @@ -372,7 +373,7 @@ class CellValuesByFieldId(AirtableModel): cell_values_by_field_id: Dict[str, Any] class RecordCreated(AirtableModel): - created_time: str + created_time: datetime cell_values_by_field_id: Dict[str, Any] diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index acfcdaab..1d20e490 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,3 +1,4 @@ +import datetime from functools import lru_cache from typing import Any, Dict, Iterable, List, Optional @@ -16,6 +17,7 @@ from pyairtable.formulas import EQ, OR, RECORD_ID from pyairtable.models import Comment from pyairtable.orm.fields import AnyField, Field +from pyairtable.utils import datetime_from_iso_str, datetime_to_iso_str class Model: @@ -69,7 +71,7 @@ def api_key(): """ id: str = "" - created_time: str = "" + created_time: Optional[datetime.datetime] = None _deleted: bool = False _fields: Dict[FieldName, Any] @@ -219,7 +221,7 @@ def save(self) -> bool: did_create = False self.id = record["id"] - self.created_time = record["createdTime"] + self.created_time = datetime_from_iso_str(record["createdTime"]) return did_create def delete(self) -> bool: @@ -275,7 +277,8 @@ def to_record(self, only_writable: bool = False) -> RecordDict: for field, value in self._fields.items() if not (map_[field].readonly and only_writable) } - return {"id": self.id, "createdTime": self.created_time, "fields": fields} + ct = datetime_to_iso_str(self.created_time) if self.created_time else "" + return {"id": self.id, "createdTime": ct, "fields": fields} @classmethod def from_record(cls, record: RecordDict) -> SelfType: @@ -299,7 +302,7 @@ def from_record(cls, record: RecordDict) -> SelfType: # any readonly fields, instead we directly set instance._fields. instance = cls(id=record["id"]) instance._fields = field_values - instance.created_time = record["createdTime"] + instance.created_time = datetime_from_iso_str(record["createdTime"]) return instance @classmethod @@ -388,9 +391,9 @@ def batch_save(cls, models: List[SelfType]) -> None: table = cls.get_table() table.batch_update(update_records, typecast=cls._typecast()) created_records = table.batch_create(create_records, typecast=cls._typecast()) - for model, created_record in zip(create_models, created_records): - model.id = created_record["id"] - model.created_time = created_record["createdTime"] + for model, record in zip(create_models, created_records): + model.id = record["id"] + model.created_time = datetime_from_iso_str(record["createdTime"]) @classmethod def batch_delete(cls, models: List[SelfType]) -> None: diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 49455927..099e6935 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone import pytest @@ -116,8 +116,8 @@ def test_integration_orm(Contact, Address): email="email@email.com", is_registered=True, address=[address], - birthday=datetime.utcnow().date(), - last_access=datetime.utcnow(), + birthday=datetime.now(timezone.utc).date(), + last_access=datetime.now(timezone.utc), ) assert contact.first_name == "John" diff --git a/tests/test_models.py b/tests/test_models.py index 52e52560..fc91a520 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List import pytest @@ -278,3 +279,23 @@ class Dummy(RestfulModel, url="{base.id}/{dummy.one}/{dummy.two}"): r'"\'NoneType\' object has no attribute \'id\'"' r", \{'base': None, 'dummy': Dummy\(.*\)\}" ) + + +def test_datetime_conversion(api, requests_mock): + """ + Test that if an AirtableModel field is specified as a datetime, + and the input data is provided as a str, we'll convert to a datetime + and back to a str when saving. + """ + + class Dummy(CanUpdateModel, url="{self.id}", writable=["timestamp"]): + id: str + timestamp: datetime + + data = {"id": "rec000", "timestamp": "2024-01-08T12:34:56Z"} + obj = Dummy.from_api(data, api) + assert obj.timestamp == datetime(2024, 1, 8, 12, 34, 56, tzinfo=timezone.utc) + m = requests_mock.patch(obj._url, json=data) + obj.save() + assert m.call_count == 1 + assert m.request_history[0].json() == {"timestamp": "2024-01-08T12:34:56.000Z"} diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 9b5b9799..fc4f223d 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -80,7 +80,7 @@ def test_save(comment, requests_mock): """ new_text = "This was changed!" mentions = {} - modified = dict(comment.dict(by_alias=True), mentioned=mentions, text=new_text) + modified = dict(comment._raw, mentioned=mentions, text=new_text) m = requests_mock.patch(comment._url, json=modified) comment.text = "Whatever" diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py index 512ab295..5a5572a0 100644 --- a/tests/test_models_webhook.py +++ b/tests/test_models_webhook.py @@ -189,7 +189,9 @@ def test_notification_from_request(secret): notification = WebhookNotification.from_request(body, header, secret) assert notification.base.id == "app00000000000000" assert notification.webhook.id == "ach00000000000000" - assert notification.timestamp == "2022-02-01T21:25:05.663Z" + assert notification.timestamp == datetime.datetime( + 2022, 2, 1, 21, 25, 5, 663000, tzinfo=datetime.timezone.utc + ) with pytest.raises(ValueError): WebhookNotification.from_request("[1,2,3]", header, secret) diff --git a/tests/test_orm.py b/tests/test_orm.py index 4826d4f3..60178fc1 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -1,5 +1,5 @@ import re -from datetime import datetime +from datetime import datetime, timezone from operator import itemgetter from unittest import mock @@ -10,6 +10,9 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f from pyairtable.testing import fake_meta, fake_record +from pyairtable.utils import datetime_to_iso_str + +NOW = datetime.now().isoformat() + "Z" class Address(Model): @@ -44,11 +47,12 @@ def test_model_basics(): # save with mock.patch.object(Table, "create") as m_save: - m_save.return_value = {"id": "id", "createdTime": "time"} + m_save.return_value = {"id": "id", "createdTime": NOW} contact.save() assert m_save.called assert contact.id == "id" + assert contact.created_time.tzinfo is timezone.utc # delete with mock.patch.object(Table, "delete") as m_delete: @@ -63,7 +67,7 @@ def test_model_basics(): record = contact.to_record() assert record["id"] == contact.id - assert record["createdTime"] == contact.created_time + assert record["createdTime"] == datetime_to_iso_str(contact.created_time) assert record["fields"]["First Name"] == contact.first_name @@ -89,7 +93,7 @@ def test_first(): with mock.patch.object(Table, "first") as m_first: m_first.return_value = { "id": "recwnBLPIeQJoYVt4", - "createdTime": "", + "createdTime": NOW, "fields": { "First Name": "X", "Created At": "2014-09-05T12:34:56.000Z", @@ -113,7 +117,7 @@ def test_from_record(): with mock.patch.object(Table, "get") as m_get: m_get.return_value = { "id": "recwnBLPIeQJoYVt4", - "createdTime": "", + "createdTime": NOW, "fields": { "First Name": "X", "Birthday": None, @@ -142,7 +146,7 @@ def test_readonly_field_not_saved(): record = { "id": "recwnBLPIeQJoYVt4", - "createdTime": datetime.utcnow().isoformat(), + "createdTime": datetime.now(timezone.utc).isoformat(), "fields": { "Birthday": "1970-01-01", "Age": 57, @@ -162,7 +166,7 @@ def test_readonly_field_not_saved(): def test_linked_record(): - record = {"id": "recFake", "createdTime": "", "fields": {"Street": "A"}} + record = {"id": "recFake", "createdTime": NOW, "fields": {"Street": "A"}} address = Address.from_id("recFake", fetch=False) # Id Reference @@ -280,7 +284,7 @@ def test_batch_save(mock_update, mock_create): addr3 = Address.from_record( { "id": "recExistingRecord", - "createdTime": datetime.utcnow().isoformat(), + "createdTime": datetime.now(timezone.utc).isoformat(), "fields": {"Number": 789, "Street": "Fake St"}, } ) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index c93c2842..e0f354ca 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -14,6 +14,7 @@ fake_record, fake_user, ) +from pyairtable.utils import datetime_to_iso_str DATE_S = "2023-01-01" DATE_V = datetime.date(2023, 1, 1) @@ -651,7 +652,7 @@ class M(Model): def patch_callback(request, context): return { "id": obj.id, - "createdTime": obj.created_time, + "createdTime": datetime_to_iso_str(obj.created_time), "fields": request.json()["fields"], } diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index a95d5228..b4462326 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -182,12 +182,20 @@ def test_from_ids__no_fetch(mock_all): assert set(contact.id for contact in contacts) == set(fake_ids) -@pytest.mark.parametrize("methodname", ("all", "first")) -def test_passthrough(methodname): +@pytest.mark.parametrize( + "methodname,returns", + ( + ("all", [fake_record(), fake_record(), fake_record()]), + ("first", fake_record()), + ), +) +def test_passthrough(methodname, returns): """ Test that .all() and .first() pass through whatever they get. """ - with mock.patch(f"pyairtable.Table.{methodname}") as mock_endpoint: + with mock.patch( + f"pyairtable.Table.{methodname}", return_value=returns + ) as mock_endpoint: method = getattr(FakeModel, methodname) method(a=1, b=2, c=3)