Skip to content

Commit

Permalink
Merge pull request gtalarico#389 from mesozoic/upload_attachment
Browse files Browse the repository at this point in the history
Support new "Upload attachment" endpoint
  • Loading branch information
mesozoic authored Sep 17, 2024
2 parents 61b1ed5 + 440491a commit 97f2f1b
Show file tree
Hide file tree
Showing 24 changed files with 871 additions and 150 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>`.

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 @@ -350,6 +366,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 @@ -397,6 +440,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.
"""
48 changes: 0 additions & 48 deletions pyairtable/orm/changes.py

This file was deleted.

Loading

0 comments on commit 97f2f1b

Please sign in to comment.