Repository: gtalarico/pyairtable
Pull request #310 (2023-09-29 03:53:29)

Support for "AI Text" field type
I haven't actually needed this or used it, I'm just going off what the documentation says. FYI @marks in case there's some nuance to these fields that I haven't captured here.


Patch:
diff --git a/Makefile b/Makefile
index b9bf409..df2cfaa 100644
--- a/Makefile
+++ b/Makefile
@@ -10,9 +10,19 @@ hooks:
 	.tox/pre-commit/bin/pre-commit install
 	.tox/pre-commit/bin/pre-commit install-hooks
 
-.PHONY: release
+.PHONY: release release-test bump
 release:
-	@bash -c "./scripts/release.sh"
+	make clean
+	python -m build --sdist --wheel --outdir ./dist
+	twine upload ./dist/*
+
+release-test:
+	make clean
+	python -m build --sdist --wheel --outdir ./dist
+	twine upload --repository testpypi ./dist/*
+
+bump:
+	@bash -c "./scripts/bump.sh"
 
 .PHONY: test test-e2e coverage lint format docs clean
 test:
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 9b2719d..d79af83 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -109,6 +109,8 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
 
    * - ORM field class
      - Airtable field type(s)
+   * - :class:`~pyairtable.orm.fields.AITextField` 🔒
+     - `AI Text <https://airtable.com/developers/web/api/field-model#aitext>`__
    * - :class:`~pyairtable.orm.fields.AttachmentsField`
      - `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__
    * - :class:`~pyairtable.orm.fields.AutoNumberField` 🔒
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index 8487882..097c720 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -25,6 +25,25 @@ Timestamp: TypeAlias = str
 FieldName: TypeAlias = str
 
 
+class AITextDict(TypedDict, total=False):
+    """
+    A ``dict`` representing text generated by AI.
+
+    >>> record = table.get('recW8eG2x0ew1Af')
+    >>> record['fields']['Generated Text']
+    {
+        'state': 'generated',
+        'isStale': False,
+        'value': '...'
+    }
+    """
+
+    state: Required[str]
+    isStale: Required[bool]
+    value: Required[Optional[str]]
+    errorType: str
+
+
 class AttachmentDict(TypedDict, total=False):
     """
     A ``dict`` representing an attachment stored in an Attachments field.
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index 4531bda..3e9e1f5 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -170,7 +170,7 @@ def FIND(what: str, where: str, start_position: int = 0) -> str:
     """
     Creates a FIND statement
 
-    >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol'))
+    >>> FIND(STR(2021), FIELD('DatetimeCol'))
     "FIND('2021', {DatetimeCol})"
 
     Args:
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 43c6b2f..a28721d 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -49,6 +49,7 @@ from typing_extensions import TypeAlias
 
 from pyairtable import utils
 from pyairtable.api.types import (
+    AITextDict,
     AttachmentDict,
     BarcodeDict,
     ButtonDict,
@@ -601,6 +602,16 @@ class LinkField(_ListField[RecordId, T_Linked]):
 # get some extra functionality for free in the future.
 
 
+class AITextField(_DictField[AITextDict]):
+    """
+    Read-only field that returns a `dict`. For more information, read the
+    `AI Text <https://airtable.com/developers/web/api/field-model#aitext>`_
+    documentation.
+    """
+
+    readonly = True
+
+
 class AttachmentsField(_ValidatingListField[AttachmentDict]):
     """
     Accepts a list of dicts in the format detailed in
@@ -833,6 +844,7 @@ READONLY_FIELDS = {cls for cls in ALL_FIELDS if cls.readonly}
 #:
 #: :meta hide-value:
 FIELD_TYPES_TO_CLASSES = {
+    "aiText": AITextField,
     "autoNumber": AutoNumberField,
     "barcode": BarcodeField,
     "button": ButtonField,
diff --git a/scripts/bump.sh b/scripts/bump.sh
new file mode 100755
index 0000000..62c6c72
--- /dev/null
+++ b/scripts/bump.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+
+source ./scripts/console.sh
+
+
+function bump {
+    previousVersion=$( grep '^__version__' pyairtable/__init__.py | sed 's/__version__ = \"\(.*\)\"/\1/' )
+    previousVersion=$(echo -n "${previousVersion}")
+    info "Enter Version [current is ${previousVersion}]:"
+    read version
+    if [ -z "$version" ]; then
+        info "Empty version string - using existing"
+        version="$previousVersion"
+        return
+    fi
+    sed -i "" "s/^__version__ = .*$/__version__ = \"$version\"/" pyairtable/__init__.py
+    echo "Bumped __version__ to $version"
+}
+
+function confirmEval {
+    info "CMD > $1"
+    info "ENTER to confirm"
+    read foo
+    eval $1
+}
+
+function push {
+    cmd="git commit -am \"Publish Version: $version\""
+    confirmEval "$cmd"
+
+    cmd="git tag -m \"Version $version\" $version"
+    confirmEval "$cmd"
+
+    cmd="git push --tags origin main"
+    confirmEval "$cmd"
+}
+
+bump
+push
diff --git a/scripts/clean.sh b/scripts/clean.sh
index 6d27e7d..163f5a9 100755
--- a/scripts/clean.sh
+++ b/scripts/clean.sh
@@ -1,14 +1,14 @@
 #!/bin/bash
 
-echo "Cleaning up bytecode, cache, and build files 🧹"
-set -x
+source ./scripts/console.sh
+
+info "Cleanning up files 🧹"
 
 python3 -c "import pathlib; [p.unlink() for p in pathlib.Path('.').rglob('*.py[co]')]"
 python3 -c "import pathlib; [p.rmdir() for p in pathlib.Path('.').rglob('pytest_cache')]"
-rm -rdf ./build
-rm -rdf ./dist
 rm -rdf ./docs/build
+rm -rdf ./dist
+rm -rdf ./build
 rm -rdf ./htmlcov
-rm -rdf .mypy_cache
-rm -rdf .pytest_cache
-rm -rdf pyairtable.egg-info
+rm -rdf  pyairtable.egg-info
+rm -rdf  .pytest_cache
diff --git a/scripts/console.sh b/scripts/console.sh
new file mode 100755
index 0000000..debad8f
--- /dev/null
+++ b/scripts/console.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+RED='\033[1;31m'
+L_GREEN='\033[1;32m'
+L_BLUE='\033[1;34m'
+L_GREY='\033[0;37m'
+WHITE='\033[1;37m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+console() {
+  local color=$1
+  local msg=$2
+  printf "${!color}${msg}${NC}\n"
+}
+
+error() {
+  local msg=$1
+  console 'RED' "==> $msg"
+}
+
+info() {
+  local msg=$1
+  console 'L_GREEN' "==> ${msg}"
+}
+warn() {
+  local msg=$1
+  console 'L_BLUE' "==> ${msg}"
+}
diff --git a/scripts/format.sh b/scripts/format.sh
new file mode 100755
index 0000000..4ad422c
--- /dev/null
+++ b/scripts/format.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+set -e
+
+source ./scripts/console.sh
+
+info 'Formatting'
+
+flake8 .
+black --diff .
diff --git a/scripts/githooks/pre-commit b/scripts/githooks/pre-commit
new file mode 100755
index 0000000..d0c4ea4
--- /dev/null
+++ b/scripts/githooks/pre-commit
@@ -0,0 +1,8 @@
+#!/bin/bash -e
+
+# This script is deprecated and just replaces itself with pre-commit.
+echo "$0: deprecated githooks script; replacing with pre-commit"
+cd $(dirname $0)/../..
+git config --local core.hooksPath && git config --local --unset core.hooksPath
+make setup
+.git/hooks/pre-commit "$@"
diff --git a/scripts/githooks/pre-push b/scripts/githooks/pre-push
new file mode 100755
index 0000000..5a4bc04
--- /dev/null
+++ b/scripts/githooks/pre-push
@@ -0,0 +1,7 @@
+#!/bin/bash -e
+
+# This script is deprecated and just replaces itself with pre-commit.
+echo "$0: deprecated githooks script; replacing with pre-commit"
+cd $(dirname $0)/../..
+git config --local core.hooksPath && git config --local --unset core.hooksPath
+make setup
diff --git a/scripts/release.sh b/scripts/release.sh
deleted file mode 100755
index 93ac738..0000000
--- a/scripts/release.sh
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/bin/zsh
-
-function fail {
-    echo "$@" >&2
-    exit 1
-}
-
-function confirm_eval {
-    command=($@)
-    echo "% ${(q)command[@]}"
-    read -k "confirm?Run? [y/n] "; echo
-    [[ ! "$confirm" =~ [yY] ]] && fail "Cancelled."
-    eval "${(q)command[@]}"
-}
-
-function bump {
-    current_version=$(python3 -c 'from pyairtable import __version__; print(__version__)')
-    read "release_version?Release version [$current_version]: "
-    if [[ -z "$release_version" ]]; then
-        release_version=$current_version
-    elif [[ "$release_version" != "$current_version" ]]; then
-        sed -i "" "s/^__version__ = .*$/__version__ = \"$release_version\"/" pyairtable/__init__.py
-        git add pyairtable/__init__.py
-        PAGER=cat git status
-        PAGER=cat git diff --cached pyairtable/__init__.py
-        confirm_eval echo git commit -m "Release $release_version" pyairtable/__init__.py
-    fi
-}
-
-function push {
-    endpoint=gtalarico/pyairtable
-    origin=$(git remote -v | grep $endpoint | grep '\(push\)' | awk '{print $1}')
-    if [[ -z "$origin" ]]; then
-        fail "no remote matching $endpoint"
-    fi
-    confirm_eval echo git tag -s -m "Release $release_version" $release_version
-    confirm_eval echo git push $origin $release_version
-}
-
-bump
-push


Test patch:
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 7d3763a..624ce73 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -207,15 +207,16 @@ def test_type_validation_LinkField():
     argnames="test_case",
     argvalues=[
         # If a 2-tuple, the API and ORM values should be identical.
+        (f.AITextField, {"state": "empty", "isStale": True, "value": None}),
         (f.AutoNumberField, 1),
         (f.CountField, 1),
         (f.ExternalSyncSourceField, "Source"),
         (f.ButtonField, {"label": "Click me!"}),
         (f.LookupField, ["any", "values"]),
-        # If a 3-tuple, we should be able to convert API -> ORM values.
         (f.CreatedByField, fake_user()),
-        (f.CreatedTimeField, DATETIME_S, DATETIME_V),
         (f.LastModifiedByField, fake_user()),
+        # If a 3-tuple, we should be able to convert API -> ORM values.
+        (f.CreatedTimeField, DATETIME_S, DATETIME_V),
         (f.LastModifiedTimeField, DATETIME_S, DATETIME_V),
     ],
     ids=operator.itemgetter(0),
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 549ad81..c00bcb8 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -77,6 +77,7 @@ if TYPE_CHECKING:
     assert_type(movie.actors[0].name, Optional[str])
 
     class EveryField(orm.Model):
+        aitext = orm.fields.AITextField("AI Generated Text")
         attachments = orm.fields.AttachmentsField("Attachments")
         autonumber = orm.fields.AutoNumberField("Autonumber")
         barcode = orm.fields.BarcodeField("Barcode")
@@ -106,6 +107,7 @@ if TYPE_CHECKING:
         url = orm.fields.UrlField("URL")
 
     record = EveryField()
+    assert_type(record.aitext, Optional[T.AITextDict])
     assert_type(record.attachments, List[T.AttachmentDict])
     assert_type(record.autonumber, Optional[int])
     assert_type(record.barcode, Optional[T.BarcodeDict])