diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index dbea73e3..e70024b0 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -43,6 +43,9 @@ Changelog * Changed the return type of :meth:`Model.save ` from ``bool`` to :class:`~pyairtable.orm.SaveResult`. - `PR #387 `_ +* Added support for `Upload attachment `_ + via :meth:`Table.upload_attachment ` + or :meth:`AttachmentsList.upload `. * Added :class:`pyairtable.testing.MockAirtable` for easier testing. 2.3.3 (2024-03-22) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index fa6e9d94..79444bd4 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 ` 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 ` 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 ` 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 ` 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) + + +* 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 40473ccc..63ac1b31 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 a9d42943..687958cd 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 @@ RecordDict, RecordId, UpdateRecordDict, + UploadAttachmentResultDict, UpsertResultDict, WritableFields, assert_typed_dict, @@ -657,13 +662,23 @@ def schema(self, *, force: bool = False) -> TableSchema: 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 `__. @@ -671,7 +686,7 @@ def create_field( options: Only available for some field types. For more information, read about the `Airtable 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 @@ def create_field( 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 `__. + + 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 ecea194d..81807057 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. @@ -353,6 +369,33 @@ class UserAndScopesDict(TypedDict, total=False): scopes: List[str] +class UploadAttachmentResultDict(TypedDict): + """ + A ``dict`` representing the payload returned by + `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]: """ @@ -400,6 +443,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 74360c7d..69afe1d4 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/models/comment.py b/pyairtable/models/comment.py index aa6adb4e..af5c53af 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -54,7 +54,7 @@ class Comment( created_time: datetime #: The ISO 8601 timestamp of when the comment was last edited. - last_updated_time: Optional[str] + last_updated_time: Optional[datetime] #: The account which created the comment. author: Collaborator diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0cd10bdf..159f21da 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -180,6 +180,7 @@ class InterfaceCollaborators( url="meta/bases/{base.id}/interfaces/{key}", ): created_time: datetime + first_publish_time: Optional[datetime] group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() invite_links: List["InterfaceInviteLink"] = _FL() diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 3ca54552..d2f3d35f 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -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[str] + last_successful_notification_time: Optional[datetime] notification_url: Optional[str] last_notification_result: Optional["WebhookNotificationResult"] - expiration_time: Optional[str] + expiration_time: Optional[datetime] specification: "WebhookSpecification" def enable_notifications(self) -> None: diff --git a/pyairtable/orm/changes.py b/pyairtable/orm/changes.py deleted file mode 100644 index 021b8d23..00000000 --- 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 3eeaae6f..27e00d9e 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -29,7 +29,6 @@ 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 @@ 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 @@ 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 Meta: ... 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 @@ def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: # 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 `_. """ - 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 `_ @@ -934,10 +977,8 @@ class ExternalSyncSourceField(TextField): readonly = True -class LastModifiedByField(CollaboratorField): +class LastModifiedByField(_DictField[CollaboratorDict]): """ - Equivalent to :class:`CollaboratorField(readonly=True) `. - See `Last modified by `__. """ @@ -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 `_. """ - 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 `__. """ - 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 `_ @@ -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) `. - See `Created by `__. 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 6f7642b1..a1e89cf9 100644 --- a/pyairtable/orm/generate.py +++ b/pyairtable/orm/generate.py @@ -144,7 +144,7 @@ def field_class(self) -> Type[fields.AnyField]: 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 @@ def __str__(self) -> str: 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 00000000..f0360632 --- /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 12ceaec3..5284a1c0 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -684,6 +684,7 @@ def __bool__(self) -> bool: "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 32684fe0..6b12744a 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 @@ import requests from typing_extensions import ParamSpec, Protocol -from pyairtable.api.types import AnyRecordDict, CreateAttachmentDict, FieldValue +from pyairtable.api.types import AnyRecordDict, CreateAttachmentByUrl, FieldValue 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} @@ -303,3 +309,37 @@ def _getter(record: AnyRecordDict) -> Any: return tuple(_get_field(record, field) for field in fields) return _getter + + +# [[[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", + "fieldgetter", + "is_airtable_id", + "is_base_id", + "is_field_id", + "is_record_id", + "is_table_id", + "is_user_id", +] +# [[[end]]] (checksum: 7cf950d19fee128ae3f395ddbc475c0f) diff --git a/tests/conftest.py b/tests/conftest.py index d229aea8..1567a2dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ +import importlib import json +import re from collections import OrderedDict from pathlib import Path from posixpath import join as urljoin @@ -163,11 +165,16 @@ def schema_obj(api, sample_json): """ def _get_schema_obj(name: str, *, context: Any = None) -> Any: - from pyairtable.models import schema + if name.startswith("pyairtable."): + # pyairtable.models.Webhook.created_time -> ('pyairtable.models', 'Webhook.created_time') + match = re.match(r"(pyairtable\.[a-z_.]+)\.([A-Z].+)$", name) + modpath, name = match.groups() + else: + modpath = "pyairtable.models.schema" obj_name, _, obj_path = name.partition(".") obj_data = sample_json(obj_name) - obj_cls = getattr(schema, obj_name) + obj_cls = getattr(importlib.import_module(modpath), obj_name) if context: obj = obj_cls.from_api(obj_data, api, context=context) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7541e332..b9b3e818 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,6 +21,7 @@ class Columns: 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 65265e17..66656f46 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 4deb2d44..f51a8ead 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 e859ef9e..0114b047 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/sample_data/AuditLogResponse.json b/tests/sample_data/AuditLogResponse.json new file mode 100644 index 00000000..4b9e3c5b --- /dev/null +++ b/tests/sample_data/AuditLogResponse.json @@ -0,0 +1,39 @@ +{ + "events": [ + { + "action": "createBase", + "actor": { + "type": "user", + "user": { + "email": "foo@bar.com", + "id": "usrL2PNC5o3H4lBEi", + "name": "Jane Doe" + } + }, + "context": { + "actionId": "actxr1mLqZz1T35FA", + "baseId": "appLkNDICXNqxSDhG", + "enterpriseAccountId": "entUBq2RGdihxl3vU", + "interfaceId": "pbdyGA3PsOziEHPDE", + "workspaceId": "wspmhESAta6clCCwF" + }, + "id": "01FYFFDE39BDDBC0HWK51R6GPF", + "modelId": "appLkNDICXNqxSDhG", + "modelType": "base", + "origin": { + "ipAddress": "1.2.3.4", + "sessionId": "sesE3ulSADiRNhqAv", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" + }, + "payload": { + "name": "My newly created base!" + }, + "payloadVersion": "1.0", + "timestamp": "2022-02-01T21:25:05.663Z" + } + ], + "pagination": { + "next": "MDFHUk5OMlM4MFhTNkY0R0M2QVlZTVZNNDQ=", + "previous": "MDFHUk5ITVhNMEE4UFozTlg1SlFaRlMyOFM=" + } +} diff --git a/tests/sample_data/Comment.json b/tests/sample_data/Comment.json new file mode 100644 index 00000000..b5bc4dcc --- /dev/null +++ b/tests/sample_data/Comment.json @@ -0,0 +1,18 @@ +{ + "author": { + "id": "usrLkNDICXNqxSDhG", + "email": "author@example.com" + }, + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "comLkNDICXNqxSDhG", + "lastUpdatedTime": "2019-01-03T12:33:12.421Z", + "text": "Hello, @[usr00000mentioned]!", + "mentioned": { + "usr00000mentioned": { + "displayName": "Alice Doe", + "id": "usr00000mentioned", + "email": "alice@example.com", + "type": "user" + } + } +} diff --git a/tests/sample_data/Webhook.json b/tests/sample_data/Webhook.json index 66028168..9185589c 100644 --- a/tests/sample_data/Webhook.json +++ b/tests/sample_data/Webhook.json @@ -14,7 +14,7 @@ "success": false, "willBeRetried": true }, - "lastSuccessfulNotificationTime": null, + "lastSuccessfulNotificationTime": "2022-02-01T21:25:05.663Z", "notificationUrl": "https://example.com/receive-ping", "specification": { "options": { diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 34beedc2..4a4f9d4f 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -9,7 +9,7 @@ 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 e124510d..ede5a0d4 100644 --- a/tests/test_api_types.py +++ b/tests/test_api_types.py @@ -14,7 +14,9 @@ (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_models.py b/tests/test_models.py index fc91a520..7f75bd52 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -299,3 +299,36 @@ class Dummy(CanUpdateModel, url="{self.id}", writable=["timestamp"]): obj.save() assert m.call_count == 1 assert m.request_history[0].json() == {"timestamp": "2024-01-08T12:34:56.000Z"} + + +@pytest.mark.parametrize( + "attrpath", + [ + "pyairtable.models.webhook.Webhook.last_successful_notification_time", + "pyairtable.models.webhook.Webhook.expiration_time", + "pyairtable.models.comment.Comment.created_time", + "pyairtable.models.comment.Comment.last_updated_time", + "pyairtable.models.webhook.WebhookNotification.timestamp", + "pyairtable.models.webhook.WebhookPayload.timestamp", + "pyairtable.models.audit.AuditLogResponse.events[0].timestamp", + "pyairtable.models.schema.BaseCollaborators.group_collaborators.via_base[0].created_time", + "pyairtable.models.schema.BaseCollaborators.individual_collaborators.via_base[0].created_time", + "pyairtable.models.schema.BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG'].created_time", + "pyairtable.models.schema.BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG'].first_publish_time", + "pyairtable.models.schema.BaseShares.shares[0].created_time", + "pyairtable.models.schema.WorkspaceCollaborators.invite_links.via_base[0].created_time", + "pyairtable.models.schema.EnterpriseInfo.created_time", + "pyairtable.models.schema.WorkspaceCollaborators.created_time", + "pyairtable.models.schema.WorkspaceCollaborators.invite_links.via_base[0].created_time", + "pyairtable.models.schema.UserGroup.created_time", + "pyairtable.models.schema.UserGroup.updated_time", + "pyairtable.models.schema.UserGroup.members[1].created_time", + "pyairtable.models.schema.UserInfo.created_time", + "pyairtable.models.schema.UserInfo.last_activity_time", + ], +) +def test_datetime_models(attrpath, schema_obj): + """ + Test that specific models' fields are correctly converted to datetimes. + """ + assert isinstance(schema_obj(attrpath), datetime) diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 79fc8b0b..54698104 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -10,24 +10,8 @@ @pytest.fixture -def comment_json(): - author = fake_user("author") - mentioned = fake_user("mentioned") - return { - "author": author, - "createdTime": NOW, - "id": fake_id("com"), - "lastUpdatedTime": None, - "text": f"Hello, @[{mentioned['id']}]!", - "mentioned": { - mentioned["id"]: { - "displayName": mentioned["name"], - "id": mentioned["id"], - "email": mentioned["email"], - "type": "user", - } - }, - } +def comment_json(sample_json): + return sample_json("Comment") @pytest.fixture @@ -42,7 +26,9 @@ def comments_url(base, table): def test_parse(comment_json): - Comment.parse_obj(comment_json) + c = Comment.parse_obj(comment_json) + assert isinstance(c.created_time, datetime.datetime) + assert isinstance(c.last_updated_time, datetime.datetime) def test_missing_attributes(comment_json): diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 63c42697..aed47ede 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -9,6 +9,7 @@ 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 @@ class T(Model): 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 3225540f..1446bce3 100644 --- a/tests/test_orm_generate.py +++ b/tests/test_orm_generate.py @@ -187,7 +187,7 @@ class Meta: 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 00000000..90d11b34 --- /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 32d5eee6..2e1559b5 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -10,6 +10,8 @@ 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 2078d5f3..412542e0 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -9,6 +9,7 @@ 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 @@ class Actor(orm.Model): 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 @@ class Movie(orm.Model): 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 @@ class EveryField(orm.Model): 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 @@ class EveryField(orm.Model): 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 @@ class EveryField(orm.Model): 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 @@ class EveryField(orm.Model): 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 e22e4c82..c8b9dd40 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -40,11 +40,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( diff --git a/tox.ini b/tox.ini index 65bb829b..f42c1575 100644 --- a/tox.ini +++ b/tox.ini @@ -48,7 +48,7 @@ commands = [testenv:coverage] passenv = COVERAGE_FORMAT commands = - python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} + python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} --cov-fail-under=100 [testenv:docs] basepython = python3.8