Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/main' into testing
Browse files Browse the repository at this point in the history
  • Loading branch information
mesozoic committed Sep 17, 2024
2 parents 958f7da + 97f2f1b commit 8ae9d6a
Show file tree
Hide file tree
Showing 34 changed files with 982 additions and 176 deletions.
3 changes: 3 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Changelog
* Changed the return type of :meth:`Model.save <pyairtable.orm.Model.save>`
from ``bool`` to :class:`~pyairtable.orm.SaveResult`.
- `PR #387 <https://github.com/gtalarico/pyairtable/pull/387>`_
* Added support for `Upload attachment <https://airtable.com/developers/web/api/upload-attachment>`_
via :meth:`Table.upload_attachment <pyairtable.Table.upload_attachment>`
or :meth:`AttachmentsList.upload <pyairtable.orm.lists.AttachmentsList.upload>`.
* Added :class:`pyairtable.testing.MockAirtable` for easier testing.

2.3.3 (2024-03-22)
Expand Down
61 changes: 39 additions & 22 deletions docs/source/migrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,30 +65,47 @@ The full list of breaking changes is below:
Changes to the ORM in 3.0
---------------------------------------------

:data:`Model.created_time <pyairtable.orm.Model.created_time>` is now a ``datetime`` (or ``None``)
instead of ``str``. This change also applies to all timestamp fields used in :ref:`API: pyairtable.models`.

:meth:`Model.save <pyairtable.orm.Model.save>` now only saves changed fields to the API, which
means it will sometimes not perform any network traffic (though this behavior can be overridden).
It also now returns an instance of :class:`~pyairtable.orm.SaveResult` instead of ``bool``.

The 3.0 release has changed the API for retrieving ORM model configuration:
* :data:`Model.created_time <pyairtable.orm.Model.created_time>` is now a ``datetime`` (or ``None``)
instead of ``str``. This change also applies to all timestamp fields used in :ref:`API: pyairtable.models`.

* :meth:`Model.save <pyairtable.orm.Model.save>` now only saves changed fields to the API, which
means it will sometimes not perform any network traffic (though this behavior can be overridden).
It also now returns an instance of :class:`~pyairtable.orm.SaveResult` instead of ``bool``.

* Fields which contain lists of values now return instances of ``ChangeTrackingList``, which
is still a subclass of ``list``. This should not affect most uses, but it does mean that
some code which relies on exact type checking may need to be updated:

>>> isinstance(Foo().atts, list)
True
>>> type(Foo().atts) is list
False
>>> type(Foo().atts)
<class 'pyairtable.orm.lists.ChangeTrackingList'>

* The 3.0 release has changed the API for retrieving ORM model configuration:

.. list-table::
:header-rows: 1

* - Method in 2.x
- Method in 3.0
* - ``Model.get_api()``
- ``Model.meta.api``
* - ``Model.get_base()``
- ``Model.meta.base``
* - ``Model.get_table()``
- ``Model.meta.table``
* - ``Model._get_meta(name)``
- ``Model.meta.get(name)``

Breaking type changes
---------------------------------------------

.. list-table::
:header-rows: 1
* ``pyairtable.api.types.CreateAttachmentDict`` is now a ``Union`` instead of a ``TypedDict``,
which may change some type checking behavior in code that uses it.

* - Method in 2.x
- Method in 3.0
* - ``Model.get_api()``
- ``Model.meta.api``
* - ``Model.get_base()``
- ``Model.meta.base``
* - ``Model.get_table()``
- ``Model.meta.table``
* - ``Model._get_meta(name)``
- ``Model.meta.get(name)``

Miscellaneous name changes
Breaking name changes
---------------------------------------------

* - | ``pyairtable.api.enterprise.ClaimUsersResponse``
Expand Down
31 changes: 31 additions & 0 deletions docs/source/orm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------

Expand Down
86 changes: 84 additions & 2 deletions pyairtable/api/table.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +15,7 @@
RecordDict,
RecordId,
UpdateRecordDict,
UploadAttachmentResultDict,
UpsertResultDict,
WritableFields,
assert_typed_dict,
Expand Down Expand Up @@ -657,21 +662,31 @@ 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 <https://airtable.com/developers/web/api/model/field-type>`__.
description: A long form description of the table.
options: Only available for some field types. For more information, read about the
`Airtable field model <https://airtable.com/developers/web/api/field-model>`__.
"""
request: Dict[str, Any] = {"name": name, "type": type}
request: Dict[str, Any] = {"name": name, "type": field_type}
if description:
request["description"] = description
if options:
Expand All @@ -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 <https://airtable.com/developers/web/api/upload-attachment>`__.
Usage:
>>> table.upload_attachment("recAdw9EjV90xbZ", "Attachments", "/tmp/example.jpg")
{
'id': 'recAdw9EjV90xbZ',
'createdTime': '2023-05-22T21:24:15.333134Z',
'fields': {
'Attachments': [
{
'id': 'attW8eG2x0ew1Af',
'url': 'https://content.airtable.com/...',
'filename': 'example.jpg'
}
]
}
}
Args:
record_id: |arg_record_id|
field: The ID or name of the ``multipleAttachments`` type field.
filename: The path to the file to upload. If ``content`` is provided, this
argument is still used to tell Airtable what name to give the file.
content: The content of the file as a string or bytes object. If no value
is provided, pyAirtable will attempt to read the contents of ``filename``.
content_type: The MIME type of the file. If not provided, the library will attempt to
guess the content type based on ``filename``.
Returns:
A full list of attachments in the given field, including the new attachment.
"""
if content is None:
with open(filename, "rb") as fp:
content = fp.read()
return self.upload_attachment(
record_id, field, filename, content, content_type
)

filename = os.path.basename(filename)
if content_type is None:
if not (content_type := mimetypes.guess_type(filename)[0]):
warnings.warn(f"Could not guess content-type for {filename!r}")
content_type = "application/octet-stream"

# TODO: figure out how to handle the atypical subdomain in a more graceful fashion
url = f"https://content.airtable.com/v0/{self.base.id}/{record_id}/{field}/uploadAttachment"
content = content.encode() if isinstance(content, str) else content
payload = {
"contentType": content_type,
"filename": filename,
"file": base64.encodebytes(content).decode("utf8"), # API needs Unicode
}
response = self.api.post(url, json=payload)
return assert_typed_dict(UploadAttachmentResultDict, response)


# These are at the bottom of the module to avoid circular imports
import pyairtable.api.api # noqa
Expand Down
57 changes: 56 additions & 1 deletion pyairtable/api/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -353,6 +369,33 @@ class UserAndScopesDict(TypedDict, total=False):
scopes: List[str]


class UploadAttachmentResultDict(TypedDict):
"""
A ``dict`` representing the payload returned by
`Upload attachment <https://airtable.com/developers/web/api/upload-attachment>`__.
Usage:
>>> table.upload_attachment("recAdw9EjV90xbZ", "Attachments", "/tmp/example.jpg")
{
'id': 'recAdw9EjV90xbZ',
'createdTime': '2023-05-22T21:24:15.333134Z',
'fields': {
'Attachments': [
{
'id': 'attW8eG2x0ew1Af',
'url': 'https://content.airtable.com/...',
'filename': 'example.jpg'
}
]
}
}
"""

id: RecordId
createdTime: str
fields: Dict[str, List[AttachmentDict]]


@lru_cache
def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
"""
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions pyairtable/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
2 changes: 1 addition & 1 deletion pyairtable/models/comment.py
Original file line number Diff line number Diff line change
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[str]
last_updated_time: Optional[datetime]

#: The account which created the comment.
author: Collaborator
Expand Down
1 change: 1 addition & 0 deletions pyairtable/models/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions pyairtable/models/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 8ae9d6a

Please sign in to comment.