From 70bd81d8335cab8c3f7bbb92c77ccfda81781357 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 18 Aug 2023 17:47:24 -0700 Subject: [PATCH 001/272] Update `make release` for GitHub publish action --- Makefile | 14 ++----------- scripts/bump.sh | 39 ----------------------------------- scripts/clean.sh | 14 ++++++------- scripts/console.sh | 29 -------------------------- scripts/format.sh | 10 --------- scripts/githooks/pre-commit | 8 -------- scripts/githooks/pre-push | 7 ------- scripts/release.sh | 41 +++++++++++++++++++++++++++++++++++++ 8 files changed, 50 insertions(+), 112 deletions(-) delete mode 100755 scripts/bump.sh delete mode 100755 scripts/console.sh delete mode 100755 scripts/format.sh delete mode 100755 scripts/githooks/pre-commit delete mode 100755 scripts/githooks/pre-push create mode 100755 scripts/release.sh diff --git a/Makefile b/Makefile index df2cfaa5..b9bf4093 100644 --- a/Makefile +++ b/Makefile @@ -10,19 +10,9 @@ hooks: .tox/pre-commit/bin/pre-commit install .tox/pre-commit/bin/pre-commit install-hooks -.PHONY: release release-test bump +.PHONY: release release: - 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" + @bash -c "./scripts/release.sh" .PHONY: test test-e2e coverage lint format docs clean test: diff --git a/scripts/bump.sh b/scripts/bump.sh deleted file mode 100755 index 62c6c724..00000000 --- a/scripts/bump.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/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 163f5a9b..6d27e7d9 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -1,14 +1,14 @@ #!/bin/bash -source ./scripts/console.sh - -info "Cleanning up files ๐Ÿงน" +echo "Cleaning up bytecode, cache, and build files ๐Ÿงน" +set -x 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 ./docs/build -rm -rdf ./dist rm -rdf ./build +rm -rdf ./dist +rm -rdf ./docs/build rm -rdf ./htmlcov -rm -rdf pyairtable.egg-info -rm -rdf .pytest_cache +rm -rdf .mypy_cache +rm -rdf .pytest_cache +rm -rdf pyairtable.egg-info diff --git a/scripts/console.sh b/scripts/console.sh deleted file mode 100755 index debad8f1..00000000 --- a/scripts/console.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 deleted file mode 100755 index 4ad422c7..00000000 --- a/scripts/format.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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 deleted file mode 100755 index d0c4ea41..00000000 --- a/scripts/githooks/pre-commit +++ /dev/null @@ -1,8 +0,0 @@ -#!/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 deleted file mode 100755 index 5a4bc047..00000000 --- a/scripts/githooks/pre-push +++ /dev/null @@ -1,7 +0,0 @@ -#!/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 new file mode 100755 index 00000000..93ac7388 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,41 @@ +#!/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 From d48f9430257dcbb99c5f5882e4200816447736dd Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 13 Sep 2023 10:07:50 +0200 Subject: [PATCH 002/272] Fix documentation --- pyairtable/formulas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 3e9e1f50..4531bda3 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(2021), FIELD('DatetimeCol')) + >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol')) "FIND('2021', {DatetimeCol})" Args: From 9a0b6431696e801d7236e34b45f86621958fa644 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 23 Sep 2023 21:50:35 -0700 Subject: [PATCH 003/272] Relax type checking on batch_upsert --- pyairtable/api/table.py | 4 ++-- tests/test_typing.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index b5cb717f..91b6eaff 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -1,7 +1,7 @@ import posixpath import urllib.parse import warnings -from typing import Any, Iterator, List, Optional, Union, overload +from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload import pyairtable.models from pyairtable.api.retrying import Retry @@ -371,7 +371,7 @@ def batch_update( def batch_upsert( self, - records: List[UpdateRecordDict], + records: Iterable[Dict[str, Any]], key_fields: List[FieldName], replace: bool = False, typecast: bool = False, diff --git a/tests/test_typing.py b/tests/test_typing.py index 549ad817..4c8876dd 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -55,6 +55,16 @@ table.update(record_id, {"Field Name": {"email": "alice@example.com"}}) table.update(record_id, {"Field Name": ["rec1", "rec2", "rec3"]}) + # Ensure batch_upsert takes both records with and without IDs + table.batch_upsert( + [ + {"fields": {"Name": "Carol"}}, + {"id": "recAsdf", "fields": {"Name": "Bob"}}, + {"id": "recAsdf", "createdTime": "", "fields": {"Name": "Alice"}}, + ], + key_fields=["Name"], + ) + # Test type annotations for the ORM class Actor(orm.Model): name = orm.fields.TextField("Name") From 99a2b09d2b6842425bec4ea3c79690dc2f1764fd Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 23 Sep 2023 21:51:47 -0700 Subject: [PATCH 004/272] Ensure batch_* methods can accept all iterables --- pyairtable/api/table.py | 20 ++++++++++++++++---- tests/test_api_table.py | 22 ++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 91b6eaff..0ac13289 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -260,7 +260,7 @@ def create( def batch_create( self, - records: List[WritableFields], + records: Iterable[WritableFields], typecast: bool = False, return_fields_by_field_id: bool = False, ) -> List[RecordDict]: @@ -282,12 +282,15 @@ def batch_create( ] Args: - records: List of dicts representing records to be created. + records: Iterable of dicts representing records to be created. typecast: |kwarg_typecast| return_fields_by_field_id: |kwarg_return_fields_by_field_id| """ inserted_records = [] + # If we got an iterator, exhaust it and collect it into a list. + records = list(records) + for chunk in self.api.chunked(records): new_records = [{"fields": fields} for fields in chunk] response = self.api.request( @@ -334,7 +337,7 @@ def update( def batch_update( self, - records: List[UpdateRecordDict], + records: Iterable[UpdateRecordDict], replace: bool = False, typecast: bool = False, return_fields_by_field_id: bool = False, @@ -354,6 +357,9 @@ def batch_update( updated_records = [] method = "put" if replace else "patch" + # If we got an iterator, exhaust it and collect it into a list. + records = list(records) + for chunk in self.api.chunked(records): chunk_records = [{"id": x["id"], "fields": x["fields"]} for x in chunk] response = self.api.request( @@ -395,6 +401,9 @@ def batch_upsert( Returns: Lists of created/updated record IDs, along with the list of all records affected. """ + # If we got an iterator, exhaust it and collect it into a list. + records = list(records) + # The API will reject a request where a record is missing any of fieldsToMergeOn, # but we might not reach that error until we've done several batch operations. # To spare implementers from having to recover from a partially applied upsert, @@ -454,7 +463,7 @@ def delete(self, record_id: RecordId) -> RecordDeletedDict: self.api.request("delete", self.record_url(record_id)), ) - def batch_delete(self, record_ids: List[RecordId]) -> List[RecordDeletedDict]: + def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]: """ Deletes the given records, operating in batches. @@ -472,6 +481,9 @@ def batch_delete(self, record_ids: List[RecordId]) -> List[RecordDeletedDict]: """ deleted_records = [] + # If we got an iterator, exhaust it and collect it into a list. + record_ids = list(record_ids) + for chunk in self.api.chunked(record_ids): result = self.api.request("delete", self.url, params={"records[]": chunk}) deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"]) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 87888e4d..dda1632f 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -215,7 +215,8 @@ def test_create(table: Table, mock_response_single): assert dict_equals(resp, mock_response_single) -def test_batch_create(table: Table, mock_records): +@pytest.mark.parametrize("container", [list, tuple, iter]) +def test_batch_create(table: Table, container, mock_records): with Mocker() as mock: for chunk in _chunk(mock_records, 10): mock.post( @@ -224,7 +225,7 @@ def test_batch_create(table: Table, mock_records): json={"records": chunk}, ) records = [i["fields"] for i in mock_records] - resp = table.batch_create(records) + resp = table.batch_create(container(records)) assert seq_equals(resp, mock_records) @@ -244,8 +245,9 @@ def test_update(table: Table, mock_response_single, replace, http_method): assert dict_equals(resp, mock_response_single) +@pytest.mark.parametrize("container", [list, tuple, iter]) @pytest.mark.parametrize("replace,http_method", [(False, "PATCH"), (True, "PUT")]) -def test_batch_update(table: Table, replace, http_method): +def test_batch_update(table: Table, container, replace, http_method): records = [fake_record(fieldvalue=index) for index in range(50)] with Mocker() as mock: mock.register_uri( @@ -255,13 +257,14 @@ def test_batch_update(table: Table, replace, http_method): {"json": {"records": chunk}} for chunk in table.api.chunked(records) ], ) - resp = table.batch_update(records, replace=replace) + resp = table.batch_update(container(records), replace=replace) assert resp == records +@pytest.mark.parametrize("container", [list, tuple, iter]) @pytest.mark.parametrize("replace,http_method", [(False, "PATCH"), (True, "PUT")]) -def test_batch_upsert(table: Table, replace, http_method, monkeypatch): +def test_batch_upsert(table: Table, container, replace, http_method, monkeypatch): field_name = "Name" exists1 = fake_record({field_name: "Exists 1"}) exists2 = fake_record({field_name: "Exists 2"}) @@ -283,7 +286,9 @@ def test_batch_upsert(table: Table, replace, http_method, monkeypatch): response_list=[{"json": response} for response in responses], ) monkeypatch.setattr(table.api, "MAX_RECORDS_PER_REQUEST", 1) - resp = table.batch_upsert(payload, key_fields=[field_name], replace=replace) + resp = table.batch_upsert( + container(payload), key_fields=[field_name], replace=replace + ) assert resp == { "createdRecords": [created["id"]], @@ -311,7 +316,8 @@ def test_delete(table: Table, mock_response_single): assert resp == expected -def test_batch_delete(table: Table, mock_records): +@pytest.mark.parametrize("container", [list, tuple, iter]) +def test_batch_delete(table: Table, container, mock_records): ids = [i["id"] for i in mock_records] with Mocker() as mock: for chunk in _chunk(ids, 10): @@ -325,7 +331,7 @@ def test_batch_delete(table: Table, mock_records): json=json_response, ) - resp = table.batch_delete(ids) + resp = table.batch_delete(container(ids)) expected = [{"deleted": True, "id": i} for i in ids] assert resp == expected From 1ed7e295f2e96a312a088288350fbd95ff8be38d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 19 Aug 2023 21:12:05 -0700 Subject: [PATCH 005/272] AI Text --- docs/source/orm.rst | 2 ++ pyairtable/api/types.py | 19 +++++++++++++++++++ pyairtable/orm/fields.py | 12 ++++++++++++ tests/test_orm_fields.py | 5 +++-- tests/test_typing.py | 2 ++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 9b2719df..d79af837 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -109,6 +109,8 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.AttachmentsField` - `Attachments `__ * - :class:`~pyairtable.orm.fields.AutoNumberField` ๐Ÿ”’ diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 84878820..097c720d 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -25,6 +25,25 @@ 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/orm/fields.py b/pyairtable/orm/fields.py index 43c6b2f2..a28721dc 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -49,6 +49,7 @@ from pyairtable import utils from pyairtable.api.types import ( + AITextDict, AttachmentDict, BarcodeDict, ButtonDict, @@ -601,6 +602,16 @@ def valid_or_raise(self, value: Any) -> None: # 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 `_ + documentation. + """ + + readonly = True + + class AttachmentsField(_ValidatingListField[AttachmentDict]): """ Accepts a list of dicts in the format detailed in @@ -833,6 +844,7 @@ class UrlField(TextField): #: #: :meta hide-value: FIELD_TYPES_TO_CLASSES = { + "aiText": AITextField, "autoNumber": AutoNumberField, "barcode": BarcodeField, "button": ButtonField, diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 7d3763a0..624ce73a 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -207,15 +207,16 @@ class Container(Model): 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 549ad817..c00bcb8c 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -77,6 +77,7 @@ class Movie(orm.Model): 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 @@ class EveryField(orm.Model): 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]) From eb6ef279d3c9fdfc3d70f35d471375ede8a28283 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 6 Aug 2023 00:08:31 -0700 Subject: [PATCH 006/272] Metadata models for bases and tables This adds schema models for table schemas and base collaborators, along with sample API responses and tests that ensure we can deserialize them correctly. --- .pre-commit-config.yaml | 8 + docs/source/orm.rst | 2 +- pyairtable/models/_base.py | 1 - pyairtable/models/schema.py | 523 ++++++++++++++++++ tests/sample_data/BaseSchema.json | 72 +++ tests/sample_data/ViewSchema.json | 12 + .../field_schema/AutoNumberFieldSchema.json | 5 + .../field_schema/BarcodeFieldSchema.json | 5 + .../field_schema/ButtonFieldSchema.json | 5 + .../field_schema/CheckboxFieldSchema.json | 9 + .../field_schema/CountFieldSchema.json | 9 + .../field_schema/CreatedByFieldSchema.json | 5 + .../field_schema/CreatedTimeFieldSchema.json | 21 + .../field_schema/CurrencyFieldSchema.json | 9 + .../field_schema/DateFieldSchema.json | 11 + .../field_schema/DateTimeFieldSchema.json | 16 + .../field_schema/DurationFieldSchema.json | 8 + .../field_schema/EmailFieldSchema.json | 5 + .../ExternalSyncSourceFieldSchema.json | 24 + .../field_schema/FormulaFieldSchema.json | 17 + .../LastModifiedByFieldSchema.json | 5 + .../LastModifiedTimeFieldSchema.json | 23 + .../MultilineTextFieldSchema.json | 5 + .../MultipleAttachmentsFieldSchema.json | 8 + .../MultipleCollaboratorsFieldSchema.json | 5 + .../MultipleLookupValuesFieldSchema.json | 32 ++ .../MultipleRecordLinksFieldSchema.json | 10 + .../MultipleSelectsFieldSchema.json | 24 + .../field_schema/NumberFieldSchema.json | 8 + .../field_schema/PercentFieldSchema.json | 8 + .../field_schema/PhoneNumberFieldSchema.json | 5 + .../field_schema/RatingFieldSchema.json | 10 + .../field_schema/RichTextFieldSchema.json | 5 + .../field_schema/RollupFieldSchema.json | 17 + .../SingleCollaboratorFieldSchema.json | 5 + .../SingleLineTextFieldSchema.json | 5 + .../field_schema/SingleSelectFieldSchema.json | 24 + .../field_schema/UnknownFieldSchema.json | 9 + .../field_schema/UrlFieldSchema.json | 5 + tests/test_models_schema.py | 41 ++ tox.ini | 2 +- 41 files changed, 1020 insertions(+), 3 deletions(-) create mode 100644 pyairtable/models/schema.py create mode 100644 tests/sample_data/BaseSchema.json create mode 100644 tests/sample_data/ViewSchema.json create mode 100644 tests/sample_data/field_schema/AutoNumberFieldSchema.json create mode 100644 tests/sample_data/field_schema/BarcodeFieldSchema.json create mode 100644 tests/sample_data/field_schema/ButtonFieldSchema.json create mode 100644 tests/sample_data/field_schema/CheckboxFieldSchema.json create mode 100644 tests/sample_data/field_schema/CountFieldSchema.json create mode 100644 tests/sample_data/field_schema/CreatedByFieldSchema.json create mode 100644 tests/sample_data/field_schema/CreatedTimeFieldSchema.json create mode 100644 tests/sample_data/field_schema/CurrencyFieldSchema.json create mode 100644 tests/sample_data/field_schema/DateFieldSchema.json create mode 100644 tests/sample_data/field_schema/DateTimeFieldSchema.json create mode 100644 tests/sample_data/field_schema/DurationFieldSchema.json create mode 100644 tests/sample_data/field_schema/EmailFieldSchema.json create mode 100644 tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json create mode 100644 tests/sample_data/field_schema/FormulaFieldSchema.json create mode 100644 tests/sample_data/field_schema/LastModifiedByFieldSchema.json create mode 100644 tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultilineTextFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json create mode 100644 tests/sample_data/field_schema/MultipleSelectsFieldSchema.json create mode 100644 tests/sample_data/field_schema/NumberFieldSchema.json create mode 100644 tests/sample_data/field_schema/PercentFieldSchema.json create mode 100644 tests/sample_data/field_schema/PhoneNumberFieldSchema.json create mode 100644 tests/sample_data/field_schema/RatingFieldSchema.json create mode 100644 tests/sample_data/field_schema/RichTextFieldSchema.json create mode 100644 tests/sample_data/field_schema/RollupFieldSchema.json create mode 100644 tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json create mode 100644 tests/sample_data/field_schema/SingleLineTextFieldSchema.json create mode 100644 tests/sample_data/field_schema/SingleSelectFieldSchema.json create mode 100644 tests/sample_data/field_schema/UnknownFieldSchema.json create mode 100644 tests/sample_data/field_schema/UrlFieldSchema.json create mode 100644 tests/test_models_schema.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 032fbba1..c9e7b5cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,14 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: +- repo: local + hooks: + - id: cog + name: cog + language: python + additional_dependencies: [cogapp] + entry: python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]" + files: \.py$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/docs/source/orm.rst b/docs/source/orm.rst index d79af837..4edcfb21 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -173,7 +173,7 @@ read `Field types and cell values `__, `Long text `__ * - :class:`~pyairtable.orm.fields.UrlField` - `Url `__ -.. [[[end]]] +.. [[[end]]] (checksum: 4fd61735d7852ef40481dc626d9cfb73) Formulas, Rollups, and Lookups diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 8bf3ff8f..f40a86bd 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -147,7 +147,6 @@ def update_forward_refs( ... class B_Two(AirtableModel): ... >>> update_forward_refs(vars()) """ - # Avoid infinite circular loops memo = set() if memo is None else memo # If it's a type, update its refs, then do the same for any nested classes. # This will raise AttributeError if given a non-AirtableModel type. diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py new file mode 100644 index 00000000..0db35406 --- /dev/null +++ b/pyairtable/models/schema.py @@ -0,0 +1,523 @@ +from functools import partial +from typing import Any, Dict, List, Literal, Optional, TypeVar, Union + +from typing_extensions import TypeAlias + +from pyairtable._compat import pydantic + +from ._base import AirtableModel, update_forward_refs + +PermissionLevel: TypeAlias = Literal[ + "none", "read", "comment", "edit", "create", "owner" +] +T = TypeVar("T", bound=Any) +FL = partial(pydantic.Field, default_factory=list) +FD = partial(pydantic.Field, default_factory=dict) + + +def _find(collection: List[T], id_or_name: str, by_name: bool = True) -> T: + """ + For use on a collection model to find objects by either id or name. + """ + items_by_name: Dict[str, T] = {} + + for item in collection: + if item.id == id_or_name: + return item + items_by_name[item.name] = item + + if not by_name: + raise KeyError(id_or_name) + + return items_by_name[id_or_name] + + +class BaseInfo(AirtableModel): + """ + See https://airtable.com/developers/web/api/list-bases + and https://airtable.com/developers/web/api/get-base-collaborators + """ + + id: str + name: str + permission_level: PermissionLevel + interfaces: Dict[str, "BaseInfo.InterfaceCollaborators"] = FD() + group_collaborators: Optional["BaseInfo.GroupCollaborators"] + individual_collaborators: Optional["BaseInfo.IndividualCollaborators"] + invite_links: Optional["BaseInfo.InviteLinks"] + + class InterfaceCollaborators(AirtableModel): + created_time: str + group_collaborators: List["GroupCollaborator"] = FL() + individual_collaborators: List["IndividualCollaborator"] = FL() + invite_links: List["InviteLink"] = FL() + + class GroupCollaborators(AirtableModel): + base_collaborators: List["GroupCollaborator"] = FL() + workspace_collaborators: List["GroupCollaborator"] = FL() + + class IndividualCollaborators(AirtableModel): + base_collaborators: List["IndividualCollaborator"] = FL() + workspace_collaborators: List["IndividualCollaborator"] = FL() + + class InviteLinks(AirtableModel): + base_invite_links: List["InviteLink"] = FL() + workspace_invite_links: List["InviteLink"] = FL() + + +class BaseSchema(AirtableModel): + """ + See https://airtable.com/developers/web/api/get-base-schema + """ + + tables: List["TableSchema"] + + def table(self, id_or_name: str) -> "TableSchema": + """ + Returns the schema for the table with the given ID or name. + """ + return _find(self.tables, id_or_name) + + +class TableSchema(AirtableModel): + """ + See https://airtable.com/developers/web/api/get-base-schema + """ + + id: str + name: str + primary_field_id: str + description: Optional[str] + fields: List["FieldSchema"] + views: List["ViewSchema"] + + def field(self, id_or_name: str) -> "FieldSchema": + """ + Returns the schema for the field with the given ID or name. + """ + return _find(self.fields, id_or_name) + + def view(self, id_or_name: str) -> "ViewSchema": + """ + Returns the schema for the view with the given ID or name. + """ + return _find(self.views, id_or_name) + + +class ViewSchema(AirtableModel): + """ + See https://airtable.com/developers/web/api/get-view-metadata + """ + + id: str + type: str + name: str + personal_for_user_id: Optional[str] + visible_field_ids: Optional[List[str]] + + +class GroupCollaborator(AirtableModel): + created_time: str + granted_by_user_id: str + group_id: str + name: str + permission_level: PermissionLevel + + +class IndividualCollaborator(AirtableModel): + created_time: str + granted_by_user_id: str + user_id: str + email: str + permission_level: PermissionLevel + + +class InviteLink(AirtableModel): + id: str + type: str + created_time: str + invited_email: Optional[str] + referred_by_user_id: str + permission_level: PermissionLevel + restricted_to_email_domains: List[str] = FL() + + +# The data model is a bit confusing here, but it's designed for maximum reuse. +# SomethingFieldConfig contains the `type` and `options` values for each field type. +# _FieldSchemaBase contains the `id`, `name`, and `description` values. +# SomethingFieldSchema inherits from _FieldSchemaBase and SomethingFieldConfig. +# FieldConfig is a union of all available *FieldConfig classes. +# FieldSchema is a union of all available *FieldSchema classes. + + +class AutoNumberFieldConfig(AirtableModel): + type: Literal["autoNumber"] + + +class BarcodeFieldConfig(AirtableModel): + type: Literal["barcode"] + + +class ButtonFieldConfig(AirtableModel): + type: Literal["button"] + + +class CheckboxFieldConfig(AirtableModel): + type: Literal["checkbox"] + options: Optional["CheckboxFieldConfig.Options"] + + class Options(AirtableModel): + color: str + icon: str + + +class CountFieldConfig(AirtableModel): + type: Literal["count"] + options: Optional["CountFieldConfig.Options"] + + class Options(AirtableModel): + is_valid: bool + record_link_field_id: Optional[str] + + +class CreatedByFieldConfig(AirtableModel): + type: Literal["createdBy"] + + +class CreatedTimeFieldConfig(AirtableModel): + type: Literal["createdTime"] + + +class CurrencyFieldConfig(AirtableModel): + type: Literal["currency"] + options: "CurrencyFieldConfig.Options" + + class Options(AirtableModel): + precision: int + symbol: str + + +class DateFieldConfig(AirtableModel): + type: Literal["date"] + options: "DateFieldConfig.Options" + + class Options(AirtableModel): + date_format: "DateTimeFieldConfig.Options.DateFormat" + + +class DateTimeFieldConfig(AirtableModel): + type: Literal["dateTime"] + options: "DateTimeFieldConfig.Options" + + class Options(AirtableModel): + time_zone: str + date_format: "DateTimeFieldConfig.Options.DateFormat" + time_format: "DateTimeFieldConfig.Options.TimeFormat" + + class DateFormat(AirtableModel): + format: str + name: str + + class TimeFormat(AirtableModel): + format: str + name: str + + +class DurationFieldConfig(AirtableModel): + type: Literal["duration"] + options: Optional["DurationFieldConfig.Options"] + + class Options(AirtableModel): + duration_format: str + + +class EmailFieldConfig(AirtableModel): + type: Literal["email"] + + +class ExternalSyncSourceFieldConfig(AirtableModel): + type: Literal["externalSyncSource"] + options: Optional["SingleSelectFieldConfig.Options"] + + +class FormulaFieldConfig(AirtableModel): + type: Literal["formula"] + options: Optional["FormulaFieldConfig.Options"] + + class Options(AirtableModel): + is_valid: bool + referenced_field_ids: Optional[List[str]] + result: Optional["FieldConfig"] + + +class LastModifiedByFieldConfig(AirtableModel): + type: Literal["lastModifiedBy"] + + +class LastModifiedTimeFieldConfig(AirtableModel): + type: Literal["lastModifiedTime"] + options: Optional["LastModifiedTimeFieldConfig.Options"] + + class Options(AirtableModel): + is_valid: bool + referenced_field_ids: Optional[List[str]] + result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] + + +class MultilineTextFieldConfig(AirtableModel): + type: Literal["multilineText"] + + +class MultipleAttachmentsFieldConfig(AirtableModel): + type: Literal["multipleAttachments"] + options: Optional["MultipleAttachmentsFieldConfig.Options"] + + class Options(AirtableModel): + is_reversed: bool + + +class MultipleCollaboratorsFieldConfig(AirtableModel): + type: Literal["multipleCollaborators"] + + +class MultipleLookupValuesFieldConfig(AirtableModel): + type: Literal["multipleLookupValues"] + options: Optional["MultipleLookupValuesFieldConfig.Options"] + + class Options(AirtableModel): + field_id_in_linked_table: Optional[str] + is_valid: bool + record_link_field_id: Optional[str] + result: Optional["FieldConfig"] + + +class MultipleRecordLinksFieldConfig(AirtableModel): + type: Literal["multipleRecordLinks"] + options: Optional["MultipleRecordLinksFieldConfig.Options"] + + class Options(AirtableModel): + is_reversed: bool + linked_table_id: str + prefers_single_record_link: bool + inverse_link_field_id: Optional[str] + view_id_for_record_selection: Optional[str] + + +class MultipleSelectsFieldConfig(AirtableModel): + type: Literal["multipleSelects"] + options: Optional["SingleSelectFieldConfig.Options"] + + +class NumberFieldConfig(AirtableModel): + type: Literal["number"] + options: Optional["NumberFieldConfig.Options"] + + class Options(AirtableModel): + precision: int + + +class PercentFieldConfig(AirtableModel): + type: Literal["percent"] + options: Optional["NumberFieldConfig.Options"] + + +class PhoneNumberFieldConfig(AirtableModel): + type: Literal["phoneNumber"] + + +class RatingFieldConfig(AirtableModel): + type: Literal["rating"] + options: Optional["RatingFieldConfig.Options"] + + class Options(AirtableModel): + color: str + icon: str + max: int + + +class RichTextFieldConfig(AirtableModel): + type: Literal["richText"] + + +class RollupFieldConfig(AirtableModel): + type: Literal["rollup"] + options: Optional["RollupFieldConfig.Options"] + + class Options(AirtableModel): + field_id_in_linked_table: Optional[str] + is_valid: bool + record_link_field_id: Optional[str] + referenced_field_ids: Optional[List[str]] + result: Optional["FieldConfig"] + + +class SingleCollaboratorFieldConfig(AirtableModel): + type: Literal["singleCollaborator"] + + +class SingleLineTextFieldConfig(AirtableModel): + type: Literal["singleLineText"] + + +class SingleSelectFieldConfig(AirtableModel): + type: Literal["singleSelect"] + options: Optional["SingleSelectFieldConfig.Options"] + + class Options(AirtableModel): + choices: List["SingleSelectFieldConfig.Choice"] + + class Choice(AirtableModel): + id: str + name: str + color: Optional[str] + + +class UrlFieldConfig(AirtableModel): + type: Literal["url"] + + +class UnknownFieldConfig(AirtableModel): + """ + Fallback field configuration class so that the library doesn't crash + with a ValidationError if Airtable adds new types of fields in the future. + """ + + type: str + options: Optional[Dict[Any, Any]] + + +FieldConfig: TypeAlias = Union[ + AutoNumberFieldConfig, + BarcodeFieldConfig, + ButtonFieldConfig, + CheckboxFieldConfig, + CountFieldConfig, + CreatedByFieldConfig, + CreatedTimeFieldConfig, + CurrencyFieldConfig, + DateFieldConfig, + DateTimeFieldConfig, + DurationFieldConfig, + EmailFieldConfig, + ExternalSyncSourceFieldConfig, + FormulaFieldConfig, + LastModifiedByFieldConfig, + LastModifiedTimeFieldConfig, + MultilineTextFieldConfig, + MultipleAttachmentsFieldConfig, + MultipleCollaboratorsFieldConfig, + MultipleLookupValuesFieldConfig, + MultipleRecordLinksFieldConfig, + MultipleSelectsFieldConfig, + NumberFieldConfig, + PercentFieldConfig, + PhoneNumberFieldConfig, + RatingFieldConfig, + RichTextFieldConfig, + RollupFieldConfig, + SingleCollaboratorFieldConfig, + SingleLineTextFieldConfig, + SingleSelectFieldConfig, + UrlFieldConfig, + UnknownFieldConfig, +] + + +class _FieldSchemaBase(AirtableModel): + id: str + name: str + description: Optional[str] + + +# This section is auto-generated so that FieldSchema and FieldConfig are kept aligned. +# See .pre-commit-config.yaml, or just run `tox -e pre-commit` to refresh it. + +# fmt: off +# [[[cog]]] +# import re +# with open(cog.inFile) as fp: +# detail_classes = re.findall(r"class (\w+FieldConfig)\(", fp.read()) +# mapping = {detail: detail[:-6] + "Schema" for detail in detail_classes} +# for detail, schema in mapping.items(): +# cog.outl(f"class {schema}(_FieldSchemaBase, {detail}): pass # noqa") +# cog.outl("\n") +# cog.outl("FieldSchema: TypeAlias = Union[") +# for schema in mapping.values(): +# cog.outl(f" {schema},") +# cog.outl("]") +# [[[out]]] +class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig): pass # noqa +class BarcodeFieldSchema(_FieldSchemaBase, BarcodeFieldConfig): pass # noqa +class ButtonFieldSchema(_FieldSchemaBase, ButtonFieldConfig): pass # noqa +class CheckboxFieldSchema(_FieldSchemaBase, CheckboxFieldConfig): pass # noqa +class CountFieldSchema(_FieldSchemaBase, CountFieldConfig): pass # noqa +class CreatedByFieldSchema(_FieldSchemaBase, CreatedByFieldConfig): pass # noqa +class CreatedTimeFieldSchema(_FieldSchemaBase, CreatedTimeFieldConfig): pass # noqa +class CurrencyFieldSchema(_FieldSchemaBase, CurrencyFieldConfig): pass # noqa +class DateFieldSchema(_FieldSchemaBase, DateFieldConfig): pass # noqa +class DateTimeFieldSchema(_FieldSchemaBase, DateTimeFieldConfig): pass # noqa +class DurationFieldSchema(_FieldSchemaBase, DurationFieldConfig): pass # noqa +class EmailFieldSchema(_FieldSchemaBase, EmailFieldConfig): pass # noqa +class ExternalSyncSourceFieldSchema(_FieldSchemaBase, ExternalSyncSourceFieldConfig): pass # noqa +class FormulaFieldSchema(_FieldSchemaBase, FormulaFieldConfig): pass # noqa +class LastModifiedByFieldSchema(_FieldSchemaBase, LastModifiedByFieldConfig): pass # noqa +class LastModifiedTimeFieldSchema(_FieldSchemaBase, LastModifiedTimeFieldConfig): pass # noqa +class MultilineTextFieldSchema(_FieldSchemaBase, MultilineTextFieldConfig): pass # noqa +class MultipleAttachmentsFieldSchema(_FieldSchemaBase, MultipleAttachmentsFieldConfig): pass # noqa +class MultipleCollaboratorsFieldSchema(_FieldSchemaBase, MultipleCollaboratorsFieldConfig): pass # noqa +class MultipleLookupValuesFieldSchema(_FieldSchemaBase, MultipleLookupValuesFieldConfig): pass # noqa +class MultipleRecordLinksFieldSchema(_FieldSchemaBase, MultipleRecordLinksFieldConfig): pass # noqa +class MultipleSelectsFieldSchema(_FieldSchemaBase, MultipleSelectsFieldConfig): pass # noqa +class NumberFieldSchema(_FieldSchemaBase, NumberFieldConfig): pass # noqa +class PercentFieldSchema(_FieldSchemaBase, PercentFieldConfig): pass # noqa +class PhoneNumberFieldSchema(_FieldSchemaBase, PhoneNumberFieldConfig): pass # noqa +class RatingFieldSchema(_FieldSchemaBase, RatingFieldConfig): pass # noqa +class RichTextFieldSchema(_FieldSchemaBase, RichTextFieldConfig): pass # noqa +class RollupFieldSchema(_FieldSchemaBase, RollupFieldConfig): pass # noqa +class SingleCollaboratorFieldSchema(_FieldSchemaBase, SingleCollaboratorFieldConfig): pass # noqa +class SingleLineTextFieldSchema(_FieldSchemaBase, SingleLineTextFieldConfig): pass # noqa +class SingleSelectFieldSchema(_FieldSchemaBase, SingleSelectFieldConfig): pass # noqa +class UrlFieldSchema(_FieldSchemaBase, UrlFieldConfig): pass # noqa +class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): pass # noqa + + +FieldSchema: TypeAlias = Union[ + AutoNumberFieldSchema, + BarcodeFieldSchema, + ButtonFieldSchema, + CheckboxFieldSchema, + CountFieldSchema, + CreatedByFieldSchema, + CreatedTimeFieldSchema, + CurrencyFieldSchema, + DateFieldSchema, + DateTimeFieldSchema, + DurationFieldSchema, + EmailFieldSchema, + ExternalSyncSourceFieldSchema, + FormulaFieldSchema, + LastModifiedByFieldSchema, + LastModifiedTimeFieldSchema, + MultilineTextFieldSchema, + MultipleAttachmentsFieldSchema, + MultipleCollaboratorsFieldSchema, + MultipleLookupValuesFieldSchema, + MultipleRecordLinksFieldSchema, + MultipleSelectsFieldSchema, + NumberFieldSchema, + PercentFieldSchema, + PhoneNumberFieldSchema, + RatingFieldSchema, + RichTextFieldSchema, + RollupFieldSchema, + SingleCollaboratorFieldSchema, + SingleLineTextFieldSchema, + SingleSelectFieldSchema, + UrlFieldSchema, + UnknownFieldSchema, +] +# [[[end]]] (checksum: f711a065c3583ccad1c69913d42af7d6) +# fmt: on + + +update_forward_refs(vars()) diff --git a/tests/sample_data/BaseSchema.json b/tests/sample_data/BaseSchema.json new file mode 100644 index 00000000..993cae6a --- /dev/null +++ b/tests/sample_data/BaseSchema.json @@ -0,0 +1,72 @@ +{ + "tables": [ + { + "description": "Apartments to track.", + "fields": [ + { + "description": "Name of the apartment", + "id": "fld1VnoyuotSTyxW1", + "name": "Name", + "type": "singleLineText" + }, + { + "id": "fldoaIqdn5szURHpw", + "name": "Pictures", + "type": "multipleAttachments" + }, + { + "id": "fldumZe00w09RYTW6", + "name": "District", + "options": { + "inverseLinkFieldId": "fldWnCJlo2z6ttT8Y", + "isReversed": false, + "linkedTableId": "tblK6MZHez0ZvBChZ", + "prefersSingleRecordLink": true + }, + "type": "multipleRecordLinks" + } + ], + "id": "tbltp8DGLhqbUmjK1", + "name": "Apartments", + "primaryFieldId": "fld1VnoyuotSTyxW1", + "views": [ + { + "id": "viwQpsuEDqHFqegkp", + "name": "Grid view", + "type": "grid" + } + ] + }, + { + "fields": [ + { + "id": "fldEVzvQOoULO38yl", + "name": "Name", + "type": "singleLineText" + }, + { + "description": "Apartments that belong to this district", + "id": "fldWnCJlo2z6ttT8Y", + "name": "Apartments", + "options": { + "inverseLinkFieldId": "fldumZe00w09RYTW6", + "isReversed": false, + "linkedTableId": "tbltp8DGLhqbUmjK1", + "prefersSingleRecordLink": false + }, + "type": "multipleRecordLinks" + } + ], + "id": "tblK6MZHez0ZvBChZ", + "name": "Districts", + "primaryFieldId": "fldEVzvQOoULO38yl", + "views": [ + { + "id": "viwi3KXvrKug2mIBS", + "name": "Grid view", + "type": "grid" + } + ] + } + ] +} diff --git a/tests/sample_data/ViewSchema.json b/tests/sample_data/ViewSchema.json new file mode 100644 index 00000000..e5530fd0 --- /dev/null +++ b/tests/sample_data/ViewSchema.json @@ -0,0 +1,12 @@ + +{ + "id": "viwQpsuEDqHFqegkp", + "name": "My Grid View", + "personalForUserId": "usrL2PNC5o3H4lBEi", + "type": "grid", + "visibleFieldIds": [ + "fldL2PNC5o3H4lBE1", + "fldL2PNC5o3H4lBE2", + "fldL2PNC5o3H4lBE3" + ] +} diff --git a/tests/sample_data/field_schema/AutoNumberFieldSchema.json b/tests/sample_data/field_schema/AutoNumberFieldSchema.json new file mode 100644 index 00000000..7bd1fea3 --- /dev/null +++ b/tests/sample_data/field_schema/AutoNumberFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "autoNumber", + "id": "fldIbDLEAaZTbedTy", + "name": "Autonumber" +} diff --git a/tests/sample_data/field_schema/BarcodeFieldSchema.json b/tests/sample_data/field_schema/BarcodeFieldSchema.json new file mode 100644 index 00000000..aa16f858 --- /dev/null +++ b/tests/sample_data/field_schema/BarcodeFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "barcode", + "id": "flduAhdFgj0sUOiJ6", + "name": "Barcode" +} diff --git a/tests/sample_data/field_schema/ButtonFieldSchema.json b/tests/sample_data/field_schema/ButtonFieldSchema.json new file mode 100644 index 00000000..d4800ab1 --- /dev/null +++ b/tests/sample_data/field_schema/ButtonFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "button", + "id": "fldPamhjpzu0W8VQU", + "name": "Open URL" +} diff --git a/tests/sample_data/field_schema/CheckboxFieldSchema.json b/tests/sample_data/field_schema/CheckboxFieldSchema.json new file mode 100644 index 00000000..ed50f53b --- /dev/null +++ b/tests/sample_data/field_schema/CheckboxFieldSchema.json @@ -0,0 +1,9 @@ +{ + "type": "checkbox", + "options": { + "icon": "check", + "color": "greenBright" + }, + "id": "fldxX0QdH2ROzqDS9", + "name": "Done" +} diff --git a/tests/sample_data/field_schema/CountFieldSchema.json b/tests/sample_data/field_schema/CountFieldSchema.json new file mode 100644 index 00000000..c146153b --- /dev/null +++ b/tests/sample_data/field_schema/CountFieldSchema.json @@ -0,0 +1,9 @@ +{ + "type": "count", + "options": { + "isValid": true, + "recordLinkFieldId": "fldNvFMYxBnf35WkO" + }, + "id": "fldVCMT5NmDVX2IyW", + "name": "Link to Self (Count)" +} diff --git a/tests/sample_data/field_schema/CreatedByFieldSchema.json b/tests/sample_data/field_schema/CreatedByFieldSchema.json new file mode 100644 index 00000000..aaaec311 --- /dev/null +++ b/tests/sample_data/field_schema/CreatedByFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "createdBy", + "id": "fldq5hwHSdDpt77oh", + "name": "Created By" +} diff --git a/tests/sample_data/field_schema/CreatedTimeFieldSchema.json b/tests/sample_data/field_schema/CreatedTimeFieldSchema.json new file mode 100644 index 00000000..2f1c4f1e --- /dev/null +++ b/tests/sample_data/field_schema/CreatedTimeFieldSchema.json @@ -0,0 +1,21 @@ +{ + "type": "createdTime", + "options": { + "result": { + "type": "dateTime", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "client" + } + } + }, + "id": "fldnejiVookFfecbE", + "name": "Created" +} diff --git a/tests/sample_data/field_schema/CurrencyFieldSchema.json b/tests/sample_data/field_schema/CurrencyFieldSchema.json new file mode 100644 index 00000000..f80cc0cf --- /dev/null +++ b/tests/sample_data/field_schema/CurrencyFieldSchema.json @@ -0,0 +1,9 @@ +{ + "type": "currency", + "options": { + "precision": 2, + "symbol": "$" + }, + "id": "fldotSLYreNpZ1oD3", + "name": "Dollars" +} diff --git a/tests/sample_data/field_schema/DateFieldSchema.json b/tests/sample_data/field_schema/DateFieldSchema.json new file mode 100644 index 00000000..1ef04774 --- /dev/null +++ b/tests/sample_data/field_schema/DateFieldSchema.json @@ -0,0 +1,11 @@ +{ + "type": "date", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + } + }, + "id": "fldgstMCbpPNgbXBN", + "name": "Date" +} diff --git a/tests/sample_data/field_schema/DateTimeFieldSchema.json b/tests/sample_data/field_schema/DateTimeFieldSchema.json new file mode 100644 index 00000000..ca046956 --- /dev/null +++ b/tests/sample_data/field_schema/DateTimeFieldSchema.json @@ -0,0 +1,16 @@ +{ + "type": "dateTime", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "client" + }, + "id": "fldVK2DrQVUZDYfvu", + "name": "DateTime" +} diff --git a/tests/sample_data/field_schema/DurationFieldSchema.json b/tests/sample_data/field_schema/DurationFieldSchema.json new file mode 100644 index 00000000..3369abc1 --- /dev/null +++ b/tests/sample_data/field_schema/DurationFieldSchema.json @@ -0,0 +1,8 @@ +{ + "type": "duration", + "options": { + "durationFormat": "h:mm:ss.S" + }, + "id": "fldiHUj34Rtni86oW", + "name": "Duration (h:mm:ss.s)" +} diff --git a/tests/sample_data/field_schema/EmailFieldSchema.json b/tests/sample_data/field_schema/EmailFieldSchema.json new file mode 100644 index 00000000..c394932b --- /dev/null +++ b/tests/sample_data/field_schema/EmailFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "email", + "id": "fldXk4Av7rZVXhjYW", + "name": "Email" +} diff --git a/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json b/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json new file mode 100644 index 00000000..54b61de4 --- /dev/null +++ b/tests/sample_data/field_schema/ExternalSyncSourceFieldSchema.json @@ -0,0 +1,24 @@ +{ + "type": "externalSyncSource", + "options": { + "choices": [ + { + "id": "selX4cKixuDso5PEX", + "name": "One", + "color": "blueLight2" + }, + { + "id": "seloqP1Gzj8Glrspo", + "name": "Two", + "color": "cyanLight2" + }, + { + "id": "selqPFVai30QE6Kp8", + "name": "Three", + "color": "tealLight2" + } + ] + }, + "id": "fldI5gjLnq0RXUleV", + "name": "Sync Source" +} diff --git a/tests/sample_data/field_schema/FormulaFieldSchema.json b/tests/sample_data/field_schema/FormulaFieldSchema.json new file mode 100644 index 00000000..7eadb77b --- /dev/null +++ b/tests/sample_data/field_schema/FormulaFieldSchema.json @@ -0,0 +1,17 @@ +{ + "type": "formula", + "options": { + "isValid": true, + "referencedFieldIds": [ + "fldgstMCbpPNgbXBN" + ], + "result": { + "type": "number", + "options": { + "precision": 0 + } + } + }, + "id": "fldBeqw3negrW9MMK", + "name": "Formula NaN" +} diff --git a/tests/sample_data/field_schema/LastModifiedByFieldSchema.json b/tests/sample_data/field_schema/LastModifiedByFieldSchema.json new file mode 100644 index 00000000..580d56e3 --- /dev/null +++ b/tests/sample_data/field_schema/LastModifiedByFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "lastModifiedBy", + "id": "fld3oKAJRAQAqHe25", + "name": "Last Modified By" +} diff --git a/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json b/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json new file mode 100644 index 00000000..090b608c --- /dev/null +++ b/tests/sample_data/field_schema/LastModifiedTimeFieldSchema.json @@ -0,0 +1,23 @@ +{ + "type": "lastModifiedTime", + "options": { + "isValid": true, + "referencedFieldIds": [], + "result": { + "type": "dateTime", + "options": { + "dateFormat": { + "name": "local", + "format": "l" + }, + "timeFormat": { + "name": "12hour", + "format": "h:mma" + }, + "timeZone": "client" + } + } + }, + "id": "fldoGZqYuPMGHyt51", + "name": "Last Modified" +} diff --git a/tests/sample_data/field_schema/MultilineTextFieldSchema.json b/tests/sample_data/field_schema/MultilineTextFieldSchema.json new file mode 100644 index 00000000..fce38c94 --- /dev/null +++ b/tests/sample_data/field_schema/MultilineTextFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "multilineText", + "id": "fldEHLmq3SvZCvgOT", + "name": "Notes" +} diff --git a/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json b/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json new file mode 100644 index 00000000..05d321c6 --- /dev/null +++ b/tests/sample_data/field_schema/MultipleAttachmentsFieldSchema.json @@ -0,0 +1,8 @@ +{ + "type": "multipleAttachments", + "options": { + "isReversed": false + }, + "id": "fldDACsrCOBiPrlZv", + "name": "Attachments" +} diff --git a/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json b/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json new file mode 100644 index 00000000..b5fefa4b --- /dev/null +++ b/tests/sample_data/field_schema/MultipleCollaboratorsFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "multipleCollaborators", + "id": "fldbDOAG70ADnuF1g", + "name": "Watchers" +} diff --git a/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json b/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json new file mode 100644 index 00000000..c8982908 --- /dev/null +++ b/tests/sample_data/field_schema/MultipleLookupValuesFieldSchema.json @@ -0,0 +1,32 @@ +{ + "type": "multipleLookupValues", + "options": { + "isValid": true, + "recordLinkFieldId": "fldNvFMYxBnf35WkO", + "fieldIdInLinkedTable": "fldI5gjLnq0RXUleV", + "result": { + "type": "multipleSelects", + "options": { + "choices": [ + { + "id": "selX4cKixuDso5PEX", + "name": "One", + "color": "blueLight2" + }, + { + "id": "seloqP1Gzj8Glrspo", + "name": "Two", + "color": "cyanLight2" + }, + { + "id": "selqPFVai30QE6Kp8", + "name": "Three", + "color": "tealLight2" + } + ] + } + } + }, + "id": "fldwYZ0pPzf0uvyg7", + "name": "Lookup Multi Select" +} diff --git a/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json b/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json new file mode 100644 index 00000000..fd325c16 --- /dev/null +++ b/tests/sample_data/field_schema/MultipleRecordLinksFieldSchema.json @@ -0,0 +1,10 @@ +{ + "type": "multipleRecordLinks", + "options": { + "linkedTableId": "tblFgcHgyO7LlhgUe", + "isReversed": false, + "prefersSingleRecordLink": false + }, + "id": "fldNvFMYxBnf35WkO", + "name": "Link to Self" +} diff --git a/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json b/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json new file mode 100644 index 00000000..ff9e227e --- /dev/null +++ b/tests/sample_data/field_schema/MultipleSelectsFieldSchema.json @@ -0,0 +1,24 @@ +{ + "type": "multipleSelects", + "options": { + "choices": [ + { + "id": "selX4cKixuDso5PEX", + "name": "One", + "color": "blueLight2" + }, + { + "id": "seloqP1Gzj8Glrspo", + "name": "Two", + "color": "cyanLight2" + }, + { + "id": "selqPFVai30QE6Kp8", + "name": "Three", + "color": "tealLight2" + } + ] + }, + "id": "fldI5gjLnq0RXUleV", + "name": "Tags" +} diff --git a/tests/sample_data/field_schema/NumberFieldSchema.json b/tests/sample_data/field_schema/NumberFieldSchema.json new file mode 100644 index 00000000..8d8bf432 --- /dev/null +++ b/tests/sample_data/field_schema/NumberFieldSchema.json @@ -0,0 +1,8 @@ +{ + "type": "number", + "options": { + "precision": 0 + }, + "id": "fldrkqRoHDtIko3wc", + "name": "Integer" +} diff --git a/tests/sample_data/field_schema/PercentFieldSchema.json b/tests/sample_data/field_schema/PercentFieldSchema.json new file mode 100644 index 00000000..0935f448 --- /dev/null +++ b/tests/sample_data/field_schema/PercentFieldSchema.json @@ -0,0 +1,8 @@ +{ + "type": "percent", + "options": { + "precision": 0 + }, + "id": "fldLETyD8dPtNqyIi", + "name": "Percent" +} diff --git a/tests/sample_data/field_schema/PhoneNumberFieldSchema.json b/tests/sample_data/field_schema/PhoneNumberFieldSchema.json new file mode 100644 index 00000000..d7d122be --- /dev/null +++ b/tests/sample_data/field_schema/PhoneNumberFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "phoneNumber", + "id": "fld94Wcs7zIYcWsem", + "name": "Phone" +} diff --git a/tests/sample_data/field_schema/RatingFieldSchema.json b/tests/sample_data/field_schema/RatingFieldSchema.json new file mode 100644 index 00000000..2c58f02a --- /dev/null +++ b/tests/sample_data/field_schema/RatingFieldSchema.json @@ -0,0 +1,10 @@ +{ + "type": "rating", + "options": { + "icon": "star", + "max": 5, + "color": "yellowBright" + }, + "id": "fldlvlcSYXyatQSZI", + "name": "Stars" +} diff --git a/tests/sample_data/field_schema/RichTextFieldSchema.json b/tests/sample_data/field_schema/RichTextFieldSchema.json new file mode 100644 index 00000000..c67ca739 --- /dev/null +++ b/tests/sample_data/field_schema/RichTextFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "richText", + "id": "fldEHLmq3SvZCvgOT", + "name": "Notes" +} diff --git a/tests/sample_data/field_schema/RollupFieldSchema.json b/tests/sample_data/field_schema/RollupFieldSchema.json new file mode 100644 index 00000000..8615a02a --- /dev/null +++ b/tests/sample_data/field_schema/RollupFieldSchema.json @@ -0,0 +1,17 @@ +{ + "type": "rollup", + "options": { + "isValid": true, + "recordLinkFieldId": "fldNvFMYxBnf35WkO", + "fieldIdInLinkedTable": "fld4QpwrhRppNhjFM", + "referencedFieldIds": [], + "result": { + "type": "number", + "options": { + "precision": 0 + } + } + }, + "id": "fldSTbXQ3nFW18CG5", + "name": "Rollup Error" +} diff --git a/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json b/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json new file mode 100644 index 00000000..d64d3a75 --- /dev/null +++ b/tests/sample_data/field_schema/SingleCollaboratorFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "singleCollaborator", + "id": "fldx2Pz3OV1ikvqFw", + "name": "Assignee" +} diff --git a/tests/sample_data/field_schema/SingleLineTextFieldSchema.json b/tests/sample_data/field_schema/SingleLineTextFieldSchema.json new file mode 100644 index 00000000..0b2a89c4 --- /dev/null +++ b/tests/sample_data/field_schema/SingleLineTextFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "singleLineText", + "id": "fldeica4zrxCGxsl1", + "name": "Name" +} diff --git a/tests/sample_data/field_schema/SingleSelectFieldSchema.json b/tests/sample_data/field_schema/SingleSelectFieldSchema.json new file mode 100644 index 00000000..632709f1 --- /dev/null +++ b/tests/sample_data/field_schema/SingleSelectFieldSchema.json @@ -0,0 +1,24 @@ +{ + "type": "singleSelect", + "options": { + "choices": [ + { + "id": "selSwz6Sw0qCkFTll", + "name": "Todo", + "color": "redLight2" + }, + { + "id": "selTYUU62nFD9JX50", + "name": "In progress", + "color": "yellowLight2" + }, + { + "id": "selvoFITCGqwGxYtB", + "name": "Done", + "color": "greenLight2" + } + ] + }, + "id": "fldqCjrs1UhXgHUIc", + "name": "Status" +} diff --git a/tests/sample_data/field_schema/UnknownFieldSchema.json b/tests/sample_data/field_schema/UnknownFieldSchema.json new file mode 100644 index 00000000..460b7b23 --- /dev/null +++ b/tests/sample_data/field_schema/UnknownFieldSchema.json @@ -0,0 +1,9 @@ +{ + "type": "somethingUnrecognizable", + "id": "fld8cfZQtRNP5OXu9", + "name": "Unknown Field Type", + "options": { + "something": "wacky", + "and": ["unusual"] + } +} diff --git a/tests/sample_data/field_schema/UrlFieldSchema.json b/tests/sample_data/field_schema/UrlFieldSchema.json new file mode 100644 index 00000000..84a68631 --- /dev/null +++ b/tests/sample_data/field_schema/UrlFieldSchema.json @@ -0,0 +1,5 @@ +{ + "type": "url", + "id": "fld8cfZQtRNP5OXu9", + "name": "URL" +} diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py new file mode 100644 index 00000000..3544000a --- /dev/null +++ b/tests/test_models_schema.py @@ -0,0 +1,41 @@ +from operator import attrgetter + +import pytest + +import pyairtable.models.schema + + +@pytest.mark.parametrize( + "clsname", + [ + "BaseInfo", + "BaseSchema", + "TableSchema", + "ViewSchema", + ], +) +def test_parse(sample_json, clsname): + cls = attrgetter(clsname)(pyairtable.models.schema) + cls.parse_obj(sample_json(clsname)) + + +@pytest.mark.parametrize("cls", pyairtable.models.schema.FieldSchema.__args__) +def test_parse_field(sample_json, cls): + cls.parse_obj(sample_json("field_schema/" + cls.__name__)) + + +@pytest.mark.parametrize( + "clsname,method,id_or_name", + [ + ("BaseSchema", "table", "tbltp8DGLhqbUmjK1"), + ("BaseSchema", "table", "Apartments"), + ("TableSchema", "field", "fld1VnoyuotSTyxW1"), + ("TableSchema", "field", "Name"), + ("TableSchema", "view", "viwQpsuEDqHFqegkp"), + ("TableSchema", "view", "Grid view"), + ], +) +def test_find_in_collection(clsname, method, id_or_name, sample_json): + cls = attrgetter(clsname)(pyairtable.models.schema) + obj = cls.parse_obj(sample_json(clsname)) + assert getattr(obj, method)(id_or_name) diff --git a/tox.ini b/tox.ini index 58bdfffc..90b07768 100644 --- a/tox.ini +++ b/tox.ini @@ -43,7 +43,7 @@ basepython = python3.8 deps = -r requirements-dev.txt commands = - python -m cogapp -r --verbosity=1 {toxinidir}/docs/source/*.rst + python -m cogapp -cr --verbosity=1 {toxinidir}/docs/source/*.rst python -m sphinx -T -E -b html {toxinidir}/docs/source {toxinidir}/docs/build [pytest] From d60e2f54dd89c3638051d8cddb2e4ad3303ca3e1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 6 Aug 2023 22:40:57 -0700 Subject: [PATCH 007/272] Api.bases, Base.{tables,info,schema}, Table.schema This adds methods for retrieving base and table schema through the existing Api, Base, and Table classes. --- docs/source/_substitutions.rst | 4 + pyairtable/api/api.py | 26 +++- pyairtable/api/base.py | 116 ++++++++++++++++-- pyairtable/api/table.py | 22 ++++ pyairtable/metadata.py | 24 +++- pyairtable/models/_base.py | 9 +- pyairtable/models/schema.py | 7 +- pyairtable/orm/fields.py | 8 +- pyairtable/orm/model.py | 17 --- tests/sample_data/BaseInfo.json | 93 ++++++++++++++ tests/sample_data/Bases.json | 14 +++ tests/sample_data/TableSchema.json | 37 ++++++ .../field_schema/FormulaFieldSchema.json | 1 + tests/test_api_api.py | 35 ++++++ tests/test_api_base.py | 64 +++++++++- tests/test_api_retrying.py | 24 ++++ tests/test_api_table.py | 7 ++ tests/test_orm_fields.py | 28 ++++- tests/test_orm_model.py | 44 +++++++ tox.ini | 8 +- 20 files changed, 534 insertions(+), 54 deletions(-) create mode 100644 tests/sample_data/BaseInfo.json create mode 100644 tests/sample_data/Bases.json create mode 100644 tests/sample_data/TableSchema.json diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index b06a1ee3..d794bf03 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -60,3 +60,7 @@ .. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the key is the field id. This defaults to `false`, which returns field objects where the key is the field name. + +.. |kwarg_force_metadata| replace:: + If ``False``, will not fetch information from the API if it has already been retrieved. + If ``True``, will fetch base information from the API, overwriting any cached values. diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 7f4f5dc0..3f069042 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -1,5 +1,4 @@ import posixpath -from functools import lru_cache from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar, Union import requests @@ -71,6 +70,8 @@ def __init__( self.timeout = timeout self.api_key = api_key + self._bases: Dict[str, "pyairtable.api.base.Base"] = {} + @property def api_key(self) -> str: """ @@ -86,12 +87,31 @@ def api_key(self, value: str) -> None: def __repr__(self) -> str: return "" - @lru_cache def base(self, base_id: str) -> "pyairtable.api.base.Base": """ Returns a new :class:`Base` instance that uses this instance of :class:`Api`. """ - return pyairtable.api.base.Base(self, base_id) + if base_id not in self._bases: + self._bases[base_id] = pyairtable.api.base.Base(self, base_id) + return self._bases[base_id] + + def bases(self, /, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: + """ + Returns a mapping of IDs to :class:`Base` instances. + + Args: + force: |kwarg_force_metadata| + """ + url = self.build_url("meta/bases") + if force or not self._bases: + self._bases = { + data["id"]: pyairtable.api.base.Base( + self, data["id"], name=data["name"] + ) + for page in self.iterate_requests("GET", url) + for data in page["bases"] + } + return dict(self._bases) def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table": """ diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 43dc3951..e46fe0fc 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -1,9 +1,9 @@ import warnings -from functools import lru_cache -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union import pyairtable.api.api import pyairtable.api.table +from pyairtable.models import schema from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, @@ -23,7 +23,16 @@ class Base: #: The base ID, in the format ``appXXXXXXXXXXXXXX`` id: str - def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str): + _name: Optional[str] + _info: Optional[schema.BaseInfo] + + def __init__( + self, + api: Union["pyairtable.api.api.Api", str], + base_id: str, + /, + name: Optional[str] = None, + ): """ Old style constructor takes ``str`` arguments, and will create its own instance of :class:`Api`. @@ -52,25 +61,114 @@ def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str): self.api = api self.id = base_id + self._name = name + self._info: Optional[schema.BaseInfo] = None + self._schema: Optional[schema.BaseSchema] = None + self._tables: Dict[str, "pyairtable.api.table.Table"] = {} + def __repr__(self) -> str: return f"" - @lru_cache - def table(self, table_name: str) -> "pyairtable.api.table.Table": + def table(self, id_or_name: str) -> "pyairtable.api.table.Table": """ - Returns a new :class:`Table` instance using all shared - attributes from :class:`Base`. + Returns a new :class:`Table` instance using this instance of :class:`Base`. Args: - table_name: An Airtable table name. Table name should be unencoded, + id_or_name: An Airtable table ID or name. Table name should be unencoded, as shown on browser. """ - return pyairtable.api.table.Table(None, self, table_name) + # If we've got the schema already, we can validate the ID or name exists. + if self._schema: + try: + return self.tables()[id_or_name] + except KeyError: + # This will raise KeyError (again) if the name/ID really doesn't exist + info = self._schema.table(id_or_name) + return self._tables[info.id] + + # If the schema is not cached, we're not going to perform network + # traffic just to look it up, so we assume it's a valid name/ID. + return pyairtable.api.table.Table(None, self, id_or_name) + + def tables(self, *, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: + """ + Retrieves the base's table schema from the metadata API + and returns a mapping of IDs to :class:`Table` instances. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._tables: + self._tables = { + table_info.id: pyairtable.api.table.Table(None, self, table_info.id) + for table_info in self.schema().tables + } + return dict(self._tables) + + @property + def name(self) -> Optional[str]: + """ + Returns the name of the base, if known. + + pyAirtable will not perform network traffic as a result of property calls, + so this property only returns a value if one of these conditions are met: + + 1. The Base was initialized with the ``name=`` keyword parameter, + usually because it was created by :meth:`Api.bases `. + 2. The :meth:`~pyairtable.Base.info` method has already been called. + """ + if self._name: + return self._name + if self._info: + return self._info.name + return None @property def url(self) -> str: return self.api.build_url(self.id) + def meta_url(self, *components: Any) -> str: + """ + Builds a URL to a metadata endpoint for this base. + """ + return self.api.build_url("meta/bases", self.id, *components) + + def info(self, /, force: bool = False) -> schema.BaseInfo: + """ + Retrieves `base information `__ + from the API and caches it locally. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._info: + params = {"include": ["collaborators", "inviteLinks", "interfaces"]} + result = self.api.request("GET", self.meta_url(), params=params) + self._info = schema.BaseInfo.parse_obj(result) + return self._info + + def schema(self, /, force: bool = False) -> schema.BaseSchema: + """ + Retrieves the schema of all tables in the base. + + Args: + force: |kwarg_force_metadata| + + Usage: + >>> base.schema().tables + [TableSchema(...), TableSchema(...), ...] + >>> base.schema().table("tblXXXXXXXXXXXXXX") + TableSchema(id="tblXXXXXXXXXXXXXX", ...) + >>> base.schema().table("My Table") + TableSchema(id="...", name="My Table", ...) + """ + if force or not self._schema: + url = self.meta_url("tables") + params = {"include": ["visibleFieldIds"]} + data = self.api.request("GET", url, params=params) + self._schema = schema.BaseSchema.parse_obj(data) + return self._schema + @property def webhooks_url(self) -> str: return self.api.build_url("bases", self.id, "webhooks") diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 0ac13289..1a91f611 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -16,6 +16,7 @@ assert_typed_dict, assert_typed_dicts, ) +from pyairtable.models.schema import TableSchema class Table: @@ -561,6 +562,27 @@ def add_comment( obj=response, ) + def schema(self) -> TableSchema: + """ + Retrieves the schema of the current table. The return value + will be cached on the :class:`~pyairtable.Base` instance + using `lru_cache `_; + to clear the cache, call ``table.base.schema.cache_clear()``. + + Usage: + >>> table.schema() + TableSchema( + id='tblslc6jG0XedVMNx', + name='My Table', + primary_field_id='fld6jG0XedVMNxFQW', + fields=[...], + views=[...] + ) + >>> table.schema().field("fld6jG0XedVMNxFQW") + SingleLineTextFieldSchema(id='fld6jG0XedVMNxFQW', name='Name', type='singleLineText') + """ + return self.base.schema().table(self.name) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py index 5cf815cf..f0b208cd 100644 --- a/pyairtable/metadata.py +++ b/pyairtable/metadata.py @@ -1,9 +1,10 @@ +import warnings from typing import Any, Dict, Optional, Union from pyairtable.api import Api, Base, Table -def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: +def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: # pragma: no cover """ Return list of Bases from an Api or Base instance. For More Details `Metadata Api Documentation `_ @@ -12,7 +13,7 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: api: :class:`Api` or :class:`Base` instance Usage: - >>> table.get_bases() + >>> get_api_bases(api) { "bases": [ { @@ -28,6 +29,11 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: ] } """ + warnings.warn( + "get_api_bases is deprecated; use Api().bases() instead.", + category=DeprecationWarning, + stacklevel=2, + ) api = api.api if isinstance(api, Base) else api base_list_url = api.build_url("meta", "bases") return { @@ -39,7 +45,7 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: } -def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: +def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: # pragma: no cover """ Returns Schema of a Base For More Details `Metadata Api Documentation `_ @@ -83,13 +89,18 @@ def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: ] } """ + warnings.warn( + "get_base_schema is deprecated; use Base().schema() instead.", + category=DeprecationWarning, + stacklevel=2, + ) base = base.base if isinstance(base, Table) else base base_schema_url = base.api.build_url("meta", "bases", base.id, "tables") assert isinstance(response := base.api.request("get", base_schema_url), dict) return response -def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: +def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no cover """ Returns the specific table schema record provided by base schema list @@ -118,6 +129,11 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: ] } """ + warnings.warn( + "get_table_schema is deprecated; use Table().schema() instead.", + category=DeprecationWarning, + stacklevel=2, + ) base_schema = get_base_schema(table) for table_record in base_schema.get("tables", {}): assert isinstance(table_record, dict) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index f40a86bd..23131513 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -56,10 +56,11 @@ def __init_subclass__(cls, **kwargs: Any) -> None: # These are private to SerializableModel if "writable" in kwargs and "readonly" in kwargs: raise ValueError("incompatible kwargs 'writable' and 'readonly'") - cls.__writable = kwargs.get("writable") - cls.__readonly = kwargs.get("readonly") - cls.__allow_update = bool(kwargs.get("allow_update", True)) - cls.__allow_delete = bool(kwargs.get("allow_delete", True)) + cls.__writable = kwargs.pop("writable", None) + cls.__readonly = kwargs.pop("readonly", None) + cls.__allow_update = bool(kwargs.pop("allow_update", True)) + cls.__allow_delete = bool(kwargs.pop("allow_delete", True)) + super().__init_subclass__(**kwargs) _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() _url: str = pydantic.PrivateAttr() diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0db35406..c061683d 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -15,7 +15,7 @@ FD = partial(pydantic.Field, default_factory=dict) -def _find(collection: List[T], id_or_name: str, by_name: bool = True) -> T: +def _find(collection: List[T], id_or_name: str) -> T: """ For use on a collection model to find objects by either id or name. """ @@ -26,9 +26,6 @@ def _find(collection: List[T], id_or_name: str, by_name: bool = True) -> T: return item items_by_name[item.name] = item - if not by_name: - raise KeyError(id_or_name) - return items_by_name[id_or_name] @@ -41,6 +38,7 @@ class BaseInfo(AirtableModel): id: str name: str permission_level: PermissionLevel + workspace_id: str interfaces: Dict[str, "BaseInfo.InterfaceCollaborators"] = FD() group_collaborators: Optional["BaseInfo.GroupCollaborators"] individual_collaborators: Optional["BaseInfo.IndividualCollaborators"] @@ -245,6 +243,7 @@ class FormulaFieldConfig(AirtableModel): options: Optional["FormulaFieldConfig.Options"] class Options(AirtableModel): + formula: str is_valid: bool referenced_field_ids: Optional[List[str]] result: Optional["FieldConfig"] diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index a28721dc..b63e710f 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -136,11 +136,7 @@ def _description(self) -> str: """ if self._model and self._attribute_name: return f"{self._model.__name__}.{self._attribute_name}" - if self._model: - return f"{self._model.__name__}.{self.field_name}" - if self.field_name: - return f"{self.field_name!r} field" - return "Field" + return f"{self.field_name!r} field" # __get__ and __set__ are called when accessing an instance of Field on an object. # Model.field should return the Field instance itself, whereas @@ -570,8 +566,6 @@ def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: """ Returns the list of record IDs which should be persisted to the API. """ - if not value: - return [] # If the _fields value contains str, it means we loaded it from the API # but we never actually accessed the value (see _get_list_value). # When persisting this model back to the API, we can just write those IDs. diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 97897382..4615060a 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -116,23 +116,6 @@ def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]: """ return {f.field_name: f for f in cls._attribute_descriptor_map().values()} - @classmethod - def _field_name_attribute_map(cls) -> Dict[FieldName, str]: - """ - Returns a dictionary that maps field names to attribute names. - - >>> class Test(Model): - ... first_name = TextField("First Name") - ... age = NumberField("Age") - ... - >>> Test._field_name_attribute_map() - >>> { - ... "First Name": "first_name" - ... "Age": "age" - ... } - """ - return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()} - def __init__(self, **fields: Any): """ Constructs a model instance with field values based on the given keyword args. diff --git a/tests/sample_data/BaseInfo.json b/tests/sample_data/BaseInfo.json new file mode 100644 index 00000000..414aecf9 --- /dev/null +++ b/tests/sample_data/BaseInfo.json @@ -0,0 +1,93 @@ +{ + "collaborators": { + "baseCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bam.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "create", + "userId": "usrsOEchC9xuwRgKk" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bar.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "owner", + "userId": "usrL2PNC5o3H4lBEi" + } + ] + }, + "createdTime": "2019-01-03T12:33:12.421Z", + "groupCollaborators": { + "baseCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "groupId": "ugpR8ZT9KtIgp8Bh3", + "name": "group 2", + "permissionLevel": "create" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "groupId": "ugp1mKGb3KXUyQfOZ", + "name": "group 1", + "permissionLevel": "edit" + } + ] + }, + "id": "appLkNDICXNqxSDhG", + "individualCollaborators": { + "baseCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bam.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "create", + "userId": "usrsOEchC9xuwRgKk" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bar.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "owner", + "userId": "usrL2PNC5o3H4lBEi" + } + ] + }, + "inviteLinks": { + "baseInviteLinks": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "invJiqaXmPqq6Ec87", + "invitedEmail": null, + "permissionLevel": "read", + "referredByUserId": "usrsOEchC9xuwRgKk", + "restrictedToEmailDomains": [ + "bam.com" + ], + "type": "multiUse" + } + ], + "workspaceInviteLinks": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "invJiqaXmPqq6Ec87", + "invitedEmail": "bam@bam.com", + "permissionLevel": "edit", + "referredByUserId": "usrL2PNC5o3H4lBEi", + "restrictedToEmailDomains": [], + "type": "singleUse" + } + ] + }, + "name": "my first base", + "permissionLevel": "none", + "workspaceId": "wspmhESAta6clCCwF" +} diff --git a/tests/sample_data/Bases.json b/tests/sample_data/Bases.json new file mode 100644 index 00000000..ad865743 --- /dev/null +++ b/tests/sample_data/Bases.json @@ -0,0 +1,14 @@ +{ + "bases": [ + { + "id": "appLkNDICXNqxSDhG", + "name": "Apartment Hunting", + "permissionLevel": "create" + }, + { + "id": "appSW9R5uCNmRmfl6", + "name": "Project Tracker", + "permissionLevel": "edit" + } + ] +} diff --git a/tests/sample_data/TableSchema.json b/tests/sample_data/TableSchema.json new file mode 100644 index 00000000..ddeeb3c1 --- /dev/null +++ b/tests/sample_data/TableSchema.json @@ -0,0 +1,37 @@ +{ + "description": "Apartments to track.", + "fields": [ + { + "description": "Name of the apartment", + "id": "fld1VnoyuotSTyxW1", + "name": "Name", + "type": "singleLineText" + }, + { + "id": "fldoaIqdn5szURHpw", + "name": "Pictures", + "type": "multipleAttachments" + }, + { + "id": "fldumZe00w09RYTW6", + "name": "District", + "options": { + "inverseLinkFieldId": "fldWnCJlo2z6ttT8Y", + "isReversed": false, + "linkedTableId": "tblK6MZHez0ZvBChZ", + "prefersSingleRecordLink": true + }, + "type": "multipleRecordLinks" + } + ], + "id": "tbltp8DGLhqbUmjK1", + "name": "Apartments", + "primaryFieldId": "fld1VnoyuotSTyxW1", + "views": [ + { + "id": "viwQpsuEDqHFqegkp", + "name": "Grid view", + "type": "grid" + } + ] +} diff --git a/tests/sample_data/field_schema/FormulaFieldSchema.json b/tests/sample_data/field_schema/FormulaFieldSchema.json index 7eadb77b..509c7bba 100644 --- a/tests/sample_data/field_schema/FormulaFieldSchema.json +++ b/tests/sample_data/field_schema/FormulaFieldSchema.json @@ -1,6 +1,7 @@ { "type": "formula", "options": { + "formula": "{fldgstMCbpPNgbXBN} + \"FOO\"", "isValid": true, "referencedFieldIds": [ "fldgstMCbpPNgbXBN" diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 9dcb4c17..3ac15232 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -57,3 +57,38 @@ def test_whoami(api, requests_mock): } requests_mock.get("https://api.airtable.com/v0/meta/whoami", json=payload) assert api.whoami() == payload + + +def test_bases(api, requests_mock, sample_json): + m = requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases")) + bases = api.bases() + assert m.call_count == 1 + assert set(bases) == {"appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"} + assert bases["appLkNDICXNqxSDhG"].id == "appLkNDICXNqxSDhG" + + # Should not make a second API call... + assert api.bases() == bases + assert m.call_count == 1 + # ....unless we force it: + reloaded = api.bases(force=True) + assert set(reloaded) == set(bases) + assert reloaded != bases + assert m.call_count == 2 + + +def test_iterate_requests(api: Api, requests_mock): + url = "https://example.com" + response_list = [{"json": {"page": n, "offset": n + 1}} for n in range(1, 3)] + response_list[-1]["json"]["offset"] = None + requests_mock.get(url, response_list=response_list) + responses = list(api.iterate_requests("GET", url)) + assert responses == [response["json"] for response in response_list] + + +def test_iterate_requests__invalid_type(api: Api, requests_mock): + url = "https://example.com" + response_list = [{"json": {"page": n, "offset": n + 1}} for n in range(1, 3)] + response_list.append({"json": "anything but a dict, and we stop immediately"}) + requests_mock.get(url, response_list=response_list) + responses = list(api.iterate_requests("GET", url)) + assert responses == [response["json"] for response in response_list] diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 3dee5131..5c6bff55 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -34,7 +34,25 @@ def test_repr(base): assert "Base" in base.__repr__() -def test_get_table(base: Base): +def test_url(base): + assert base.url == "https://api.airtable.com/v0/appJMY16gZDQrMWpA" + + +def test_schema(base: Base, requests_mock, sample_json): + m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + table_schema = base.schema().table("tbltp8DGLhqbUmjK1") + assert table_schema.name == "Apartments" + assert m.call_count == 1 + + # Test that we cache the result unless force=True + base.schema() + assert m.call_count == 1 + base.schema(force=True) + assert m.call_count == 2 + + +def test_table(base: Base, requests_mock): + # no network traffic expected; requests_mock will fail if it happens rv = base.table("tablename") assert isinstance(rv, Table) assert rv.base == base @@ -42,6 +60,19 @@ def test_get_table(base: Base): assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename" +def test_table__with_validation(base: Base, requests_mock, sample_json): + """ + Test that Base.table() behaves differently once we've loaded a schema. + """ + requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + base.schema() + # once a schema has been loaded, Base.table() can reuse objects by ID or name + assert base.table("tbltp8DGLhqbUmjK1") == base.table("Apartments") + # ...and will raise an exception if called with an invalid ID/name: + with pytest.raises(KeyError): + base.table("DoesNotExist") + + def test_webhooks(base: Base, requests_mock, sample_json): m = requests_mock.get( base.webhooks_url, @@ -54,6 +85,15 @@ def test_webhooks(base: Base, requests_mock, sample_json): assert webhooks[0].last_notification_result.error +def test_webhook(base: Base, requests_mock, sample_json): + requests_mock.get(base.webhooks_url, json={"webhooks": [sample_json("Webhook")]}) + webhook = base.webhook("ach00000000000001") + assert webhook.id == "ach00000000000001" + assert webhook.notification_url == "https://example.com/receive-ping" + with pytest.raises(KeyError): + base.webhook("DoesNotExist") + + def test_add_webhook(base: Base, requests_mock): def _callback(request, context): expires = datetime.datetime.now() + datetime.timedelta(days=7) @@ -85,3 +125,25 @@ def _callback(request, context): assert m.last_request.json()["specification"] == spec assert result.id.startswith("ach") assert result.mac_secret_base64 == "secret" + + +def test_name(base, requests_mock): + """ + Test that Base().name is only set if passed explicitly to the constructor, + or if retrieved by a call to Base().info() + """ + assert base.name is None + assert Base("token", "base_id").name is None + assert Base("token", "base_id", name="Base Name").name == "Base Name" + + requests_mock.get( + base.meta_url(), + json={ + "id": base.id, + "name": "Base Name", + "permissionLevel": "create", + "workspaceId": "wspFake", + }, + ) + assert base.info().name == "Base Name" + assert base.name == "Base Name" diff --git a/tests/test_api_retrying.py b/tests/test_api_retrying.py index 46e6ad5d..d519b9c2 100644 --- a/tests/test_api_retrying.py +++ b/tests/test_api_retrying.py @@ -162,6 +162,30 @@ def _table_with_retry(retry_strategy): return _table_with_retry +def test_without_retry_strategy__succeed( + table_with_retry_strategy, + mock_endpoint, + mock_response_single, +): + mock_endpoint.canned_responses = [(200, mock_response_single)] + table = table_with_retry_strategy(None) + assert table.get("record") == mock_response_single + + +def test_without_retry_strategy__fail( + table_with_retry_strategy, + mock_endpoint, + mock_response_single, +): + mock_endpoint.canned_responses = [ + (429, None), + (200, mock_response_single), + ] + table = table_with_retry_strategy(None) + with pytest.raises(requests.exceptions.HTTPError): + table.get("record") + + def test_retry_exceed(table_with_retry_strategy, mock_endpoint): """ Test that we raise a RetryError if we get too many retryable error codes. diff --git a/tests/test_api_table.py b/tests/test_api_table.py index dda1632f..be5f7154 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -52,6 +52,13 @@ def test_repr(table: Table): assert repr(table) == "" +def test_schema(requests_mock, sample_json): + schema_json = sample_json("BaseSchema") + table = Table("api_key", "base_id", "Apartments") + requests_mock.get(table.base.meta_url("tables"), json=schema_json) + assert table.schema().id == "tbltp8DGLhqbUmjK1" + + @pytest.mark.parametrize( "base_id,table_name,table_url_suffix", [ diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 624ce73a..4cc32641 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -33,6 +33,16 @@ class T: del t.name +def test_description(): + class T: + name = f.Field("Name") + + T.other = f.Field("Other") + + assert T.name._description == "T.name" + assert T.other._description == "'Other' field" + + @pytest.mark.parametrize( "instance,expected", [ @@ -387,18 +397,28 @@ class T(Model): assert T.from_record(fake_record(Fld=None)).the_field == [] -def test_list_field_with_invalid_type(): +@pytest.mark.parametrize( + "field_class,invalid_value", + [ + (f._ListField, object()), + (f.AttachmentsField, [1, 2, 3]), + (f.MultipleCollaboratorsField, [1, 2, 3]), + (f.MultipleSelectField, [{"complex": "type"}]), + ], +) +def test_list_field_with_invalid_type(field_class, invalid_value): """ - Ensure that a ListField represents a null value as an empty list. + Ensure that a ListField raises TypeError when given a non-list, + or a list of objects that don't match `contains_type`. """ class T(Model): Meta = fake_meta() - the_field = f._ListField("Field Name", str) + the_field = field_class("Field Name", str) obj = T.from_record(fake_record()) with pytest.raises(TypeError): - obj.the_field = object() + obj.the_field = invalid_value def test_list_field_with_string(): diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index a9d3e38c..3f069305 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -70,6 +70,8 @@ def test_model_overlapping(name): class FakeModel(Model): Meta = fake_meta() + one = f.TextField("one") + two = f.TextField("two") def test_repr(): @@ -78,6 +80,39 @@ def test_repr(): assert repr(FakeModel()) == "" +def test_delete(): + obj = FakeModel.from_record(record := fake_record()) + with mock.patch("pyairtable.Table.delete") as mock_delete: + obj.delete() + + mock_delete.assert_called_once_with(record["id"]) + + +def test_delete__unsaved(): + obj = FakeModel() + with pytest.raises(ValueError): + obj.delete() + + +def test_fetch(): + obj = FakeModel(id=fake_id()) + assert not obj.one + assert not obj.two + + with mock.patch("pyairtable.Table.get") as mock_get: + mock_get.return_value = fake_record(one=1, two=2) + obj.fetch() + + assert obj.one == 1 + assert obj.two == 2 + + +def test_fetch__unsaved(): + obj = FakeModel() + with pytest.raises(ValueError): + obj.fetch() + + @pytest.mark.parametrize( "method,args", [ @@ -129,6 +164,15 @@ def test_from_ids(mock_all): mock_all.assert_called_once() +@mock.patch("pyairtable.Table.all") +def test_from_ids__no_fetch(mock_all): + fake_ids = [fake_id() for _ in range(10)] + contacts = FakeModel.from_ids(fake_ids, fetch=False) + assert mock_all.call_count == 0 + assert len(contacts) == 10 + assert set(contact.id for contact in contacts) == set(fake_ids) + + def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide diff --git a/tox.ini b/tox.ini index 90b07768..426fe49e 100644 --- a/tox.ini +++ b/tox.ini @@ -53,7 +53,7 @@ markers = [flake8] filename = *.py count = True -# Per Black Formmater Documentation +# See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html ignore = E203, E266, E501, W503 select = B,C,E,F,W,T4,B9 max-line-length = 88 @@ -70,3 +70,9 @@ omit = tests/* .venv/* .tox/* + +[coverage:report] +# See https://github.com/nedbat/coveragepy/issues/970 +exclude_also = + @overload + if (typing\.)?TYPE_CHECKING: From 5dcc8412eacd837106d29c225141fde1a552e7ea Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 8 Aug 2023 00:25:50 -0700 Subject: [PATCH 008/272] Docs for metadata This is a first pass at documentation for the new metadata APIs. --- docs/source/api.rst | 21 ++++++++++++++------- docs/source/metadata.rst | 24 +++++++++++++++++++----- docs/source/migrations.rst | 4 ++-- pyairtable/api/base.py | 8 ++++---- pyairtable/api/table.py | 5 ++--- pyairtable/models/schema.py | 33 +++++++++++++++++---------------- 6 files changed, 58 insertions(+), 37 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 480d51b6..797adef6 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -6,7 +6,7 @@ API Reference ============= -Module: pyairtable +API: pyairtable ******************************* .. autoclass:: pyairtable.Api @@ -21,35 +21,42 @@ Module: pyairtable .. autofunction:: pyairtable.retry_strategy -Module: pyairtable.api.types +API: pyairtable.api.types ******************************* .. automodule:: pyairtable.api.types :members: -Module: pyairtable.formulas +API: pyairtable.formulas ******************************* .. automodule:: pyairtable.formulas :members: -Module: pyairtable.models +API: pyairtable.models ******************************* .. automodule:: pyairtable.models :members: -Module: pyairtable.orm +API: pyairtable.models.schema +******************************** + +.. automodule:: pyairtable.models.schema + :members: + + +API: pyairtable.orm ******************************* .. autoclass:: pyairtable.orm.Model :members: -Module: pyairtable.orm.fields +API: pyairtable.orm.fields ******************************* .. automodule:: pyairtable.orm.fields @@ -59,7 +66,7 @@ Module: pyairtable.orm.fields :no-inherited-members: -Module: pyairtable.utils +API: pyairtable.utils ******************************* .. automodule:: pyairtable.utils diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 1b8a8c5a..609adc93 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -5,11 +5,25 @@ Metadata ============== -The metadata API gives you the ability to list all of your bases, tables, fields, and views. +The Airtable API gives you the ability to list all of your bases, tables, fields, and views. +pyAirtable allows you to inspect and interact with this metadata through the following methods: -.. warning:: - This API is experimental and subject to change. +All of the methods above return complex nested data structures, some of which +have their own convenience methods for searching their contents, such as +:meth:`TableSchema.field() `. +You'll find more detail in the API reference for :mod:`pyairtable.models.schema`. +.. automethod:: pyairtable.Api.bases + :noindex: -.. automodule:: pyairtable.metadata - :members: +.. automethod:: pyairtable.Base.info + :noindex: + +.. automethod:: pyairtable.Base.schema + :noindex: + +.. automethod:: pyairtable.Base.tables + :noindex: + +.. automethod:: pyairtable.Table.schema + :noindex: diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 5891c8c6..13cec4a8 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -76,7 +76,7 @@ You may need to change how your code looks up some pieces of connection metadata - :meth:`table.record_url() ` There is no fully exhaustive list of changes; please refer to -:ref:`the API documentation ` for a list of available methods and attributes. +:ref:`the API documentation ` for a list of available methods and attributes. Retry by default ---------------- @@ -97,7 +97,7 @@ Changes to types ---------------- * All functions and methods in this library have full type annotations that will pass ``mypy --strict``. - See the :ref:`types ` module for more information on the types this library accepts and returns. + See the :mod:`pyairtable.api.types` module for more information on the types this library accepts and returns. batch_upsert has a different return type -------------------------------------------- diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index e46fe0fc..29af8fc7 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -90,9 +90,9 @@ def table(self, id_or_name: str) -> "pyairtable.api.table.Table": # traffic just to look it up, so we assume it's a valid name/ID. return pyairtable.api.table.Table(None, self, id_or_name) - def tables(self, *, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: + def tables(self, /, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: """ - Retrieves the base's table schema from the metadata API + Retrieves the base's schema from the API and returns a mapping of IDs to :class:`Table` instances. Args: @@ -136,7 +136,7 @@ def meta_url(self, *components: Any) -> str: def info(self, /, force: bool = False) -> schema.BaseInfo: """ Retrieves `base information `__ - from the API and caches it locally. + from the API and caches it. Args: force: |kwarg_force_metadata| @@ -149,7 +149,7 @@ def info(self, /, force: bool = False) -> schema.BaseInfo: def schema(self, /, force: bool = False) -> schema.BaseSchema: """ - Retrieves the schema of all tables in the base. + Retrieves the schema of all tables in the base and caches it. Args: force: |kwarg_force_metadata| diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 1a91f611..24b63fc8 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -565,9 +565,8 @@ def add_comment( def schema(self) -> TableSchema: """ Retrieves the schema of the current table. The return value - will be cached on the :class:`~pyairtable.Base` instance - using `lru_cache `_; - to clear the cache, call ``table.base.schema.cache_clear()``. + will be cached on the :class:`~pyairtable.Base` instance; + to clear the cache, call ``table.base.schema(force=True)``. Usage: >>> table.schema() diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index c061683d..ae3a1caf 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -10,16 +10,17 @@ PermissionLevel: TypeAlias = Literal[ "none", "read", "comment", "edit", "create", "owner" ] -T = TypeVar("T", bound=Any) -FL = partial(pydantic.Field, default_factory=list) -FD = partial(pydantic.Field, default_factory=dict) +_T = TypeVar("_T", bound=Any) +_FL = partial(pydantic.Field, default_factory=list) +_FD = partial(pydantic.Field, default_factory=dict) -def _find(collection: List[T], id_or_name: str) -> T: + +def _find(collection: List[_T], id_or_name: str) -> _T: """ For use on a collection model to find objects by either id or name. """ - items_by_name: Dict[str, T] = {} + items_by_name: Dict[str, _T] = {} for item in collection: if item.id == id_or_name: @@ -39,28 +40,28 @@ class BaseInfo(AirtableModel): name: str permission_level: PermissionLevel workspace_id: str - interfaces: Dict[str, "BaseInfo.InterfaceCollaborators"] = FD() + interfaces: Dict[str, "BaseInfo.InterfaceCollaborators"] = _FD() group_collaborators: Optional["BaseInfo.GroupCollaborators"] individual_collaborators: Optional["BaseInfo.IndividualCollaborators"] invite_links: Optional["BaseInfo.InviteLinks"] class InterfaceCollaborators(AirtableModel): created_time: str - group_collaborators: List["GroupCollaborator"] = FL() - individual_collaborators: List["IndividualCollaborator"] = FL() - invite_links: List["InviteLink"] = FL() + group_collaborators: List["GroupCollaborator"] = _FL() + individual_collaborators: List["IndividualCollaborator"] = _FL() + invite_links: List["InviteLink"] = _FL() class GroupCollaborators(AirtableModel): - base_collaborators: List["GroupCollaborator"] = FL() - workspace_collaborators: List["GroupCollaborator"] = FL() + base_collaborators: List["GroupCollaborator"] = _FL() + workspace_collaborators: List["GroupCollaborator"] = _FL() class IndividualCollaborators(AirtableModel): - base_collaborators: List["IndividualCollaborator"] = FL() - workspace_collaborators: List["IndividualCollaborator"] = FL() + base_collaborators: List["IndividualCollaborator"] = _FL() + workspace_collaborators: List["IndividualCollaborator"] = _FL() class InviteLinks(AirtableModel): - base_invite_links: List["InviteLink"] = FL() - workspace_invite_links: List["InviteLink"] = FL() + base_invite_links: List["InviteLink"] = _FL() + workspace_invite_links: List["InviteLink"] = _FL() class BaseSchema(AirtableModel): @@ -137,7 +138,7 @@ class InviteLink(AirtableModel): invited_email: Optional[str] referred_by_user_id: str permission_level: PermissionLevel - restricted_to_email_domains: List[str] = FL() + restricted_to_email_domains: List[str] = _FL() # The data model is a bit confusing here, but it's designed for maximum reuse. From 0b5acd3e3cba95650302c0435b1d8f0941549e0c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 12 Aug 2023 01:49:23 -0700 Subject: [PATCH 009/272] Add Enterprise & Workspace This added objects to represent an enterprise and a workspace, along with methods for retrieving metadata about users and groups. --- docs/source/_substitutions.rst | 12 +- docs/source/api.rst | 6 + docs/source/metadata.rst | 59 +++++- pyairtable/__init__.py | 4 + pyairtable/api/api.py | 87 ++++++--- pyairtable/api/base.py | 150 +++++++++------ pyairtable/api/enterprise.py | 80 ++++++++ pyairtable/api/table.py | 58 ++++-- pyairtable/api/workspace.py | 83 +++++++++ pyairtable/models/schema.py | 173 +++++++++++++++--- pyairtable/utils.py | 51 +++++- .../integration/test_integration_metadata.py | 55 +++++- tests/sample_data/Enterprise.json | 23 +++ tests/sample_data/Workspace.json | 103 +++++++++++ tests/test_api_api.py | 8 +- tests/test_api_base.py | 99 ++++++++-- tests/test_api_enterprise.py | 19 ++ tests/test_api_table.py | 21 ++- tests/test_api_workspace.py | 32 ++++ tests/test_models_schema.py | 1 + 20 files changed, 960 insertions(+), 164 deletions(-) create mode 100644 pyairtable/api/enterprise.py create mode 100644 pyairtable/api/workspace.py create mode 100644 tests/sample_data/Enterprise.json create mode 100644 tests/sample_data/Workspace.json create mode 100644 tests/test_api_enterprise.py create mode 100644 tests/test_api_workspace.py diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index d794bf03..3505d439 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -62,5 +62,13 @@ key is the field id. This defaults to `false`, which returns field objects where the key is the field name. .. |kwarg_force_metadata| replace:: - If ``False``, will not fetch information from the API if it has already been retrieved. - If ``True``, will fetch base information from the API, overwriting any cached values. + If ``False``, will only fetch information from the API if it has not been cached. + If ``True``, will always fetch information from the API, overwriting any cached values. + +.. |kwarg_validate_metadata| replace:: + If ``False``, will create an object without validating the ID/name provided. + If ``True``, will fetch information from the metadata API and validate the ID/name exists. + +.. |warn| unicode:: U+26A0 .. WARNING SIGN + +.. |enterprise_only| replace:: |warn| This feature is only available on Enterprise billing plans. diff --git a/docs/source/api.rst b/docs/source/api.rst index 797adef6..46fb0443 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -18,6 +18,12 @@ API: pyairtable .. autoclass:: pyairtable.Table :members: +.. autoclass:: pyairtable.Workspace + :members: + +.. autoclass:: pyairtable.Enterprise + :members: + .. autofunction:: pyairtable.retry_strategy diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 609adc93..fcee1269 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -6,9 +6,13 @@ Metadata ============== The Airtable API gives you the ability to list all of your bases, tables, fields, and views. -pyAirtable allows you to inspect and interact with this metadata through the following methods: +pyAirtable allows you to inspect and interact with the metadata in your bases. -All of the methods above return complex nested data structures, some of which + +Reading schemas +----------------------------- + +All of the methods below return complex nested data structures, some of which have their own convenience methods for searching their contents, such as :meth:`TableSchema.field() `. You'll find more detail in the API reference for :mod:`pyairtable.models.schema`. @@ -16,9 +20,6 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` .. automethod:: pyairtable.Api.bases :noindex: -.. automethod:: pyairtable.Base.info - :noindex: - .. automethod:: pyairtable.Base.schema :noindex: @@ -27,3 +28,51 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` .. automethod:: pyairtable.Table.schema :noindex: + + +Modifying schemas +----------------------------- + +The following methods allow you to modify parts of the Airtable schema. +There may be parts of the pyAirtable API which are not supported below; +you can always use :meth:`Api.request ` to +call them directly. + +.. automethod:: pyairtable.Workspace.create_base + :noindex: + +.. automethod:: pyairtable.Base.create_table + :noindex: + +.. .. automethod:: pyairtable.Base.delete +.. :noindex: + +.. .. automethod:: pyairtable.Table.create_field +.. :noindex: + +.. .. automethod:: pyairtable.Table.delete_field +.. :noindex: + +.. .. automethod:: pyairtable.Table.delete +.. :noindex: + + +Enterprise information +----------------------------- + +pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. +The following methods are only available on an `Enterprise plan `__. +If you call one of them against a base that is not part of an enterprise workspace, Airtable will +return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan. + +.. automethod:: pyairtable.Api.enterprise + :noindex: + +.. automethod:: pyairtable.Base.info + :noindex: + +.. automethod:: pyairtable.Workspace.info + :noindex: + +.. automethod:: pyairtable.Enterprise.info + :noindex: diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index cd64ed62..ff41b670 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,11 +1,15 @@ __version__ = "2.1.0.post1" from .api import Api, Base, Table +from .api.enterprise import Enterprise from .api.retrying import retry_strategy +from .api.workspace import Workspace __all__ = [ "Api", "Base", + "Enterprise", "Table", + "Workspace", "retry_strategy", ] diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 3f069042..1043d2e2 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -8,9 +8,12 @@ import pyairtable.api.base import pyairtable.api.table from pyairtable.api import retrying +from pyairtable.api.enterprise import Enterprise from pyairtable.api.params import options_to_json_and_params, options_to_params from pyairtable.api.types import UserAndScopesDict, assert_typed_dict -from pyairtable.utils import chunked +from pyairtable.api.workspace import Workspace +from pyairtable.models.schema import Bases +from pyairtable.utils import chunked, enterprise_only T = TypeVar("T") TimeoutTuple: TypeAlias = Tuple[int, int] @@ -71,6 +74,7 @@ def __init__( self.api_key = api_key self._bases: Dict[str, "pyairtable.api.base.Base"] = {} + self._base_info: Optional[Bases] = None @property def api_key(self) -> str: @@ -87,29 +91,75 @@ def api_key(self, value: str) -> None: def __repr__(self) -> str: return "" - def base(self, base_id: str) -> "pyairtable.api.base.Base": + def whoami(self) -> UserAndScopesDict: + """ + Return the current user ID and (if connected via OAuth) the list of scopes. + See `Get user ID & scopes `_ for more information. + """ + data = self.request("GET", self.build_url("meta/whoami")) + return assert_typed_dict(UserAndScopesDict, data) + + @enterprise_only + def enterprise(self, enterprise_account_id: str) -> Enterprise: + return Enterprise(self, enterprise_account_id) + + def workspace(self, workspace_id: str) -> Workspace: + return Workspace(self, workspace_id) + + def base( + self, + base_id: str, + *, + validate: bool = False, + ) -> "pyairtable.api.base.Base": """ Returns a new :class:`Base` instance that uses this instance of :class:`Api`. + + Args: + base_id: |arg_base_id| + validate: |kwarg_validate_metadata| + + Raises: + KeyError: if ``fetch=True`` and the given base ID does not exist. """ - if base_id not in self._bases: - self._bases[base_id] = pyairtable.api.base.Base(self, base_id) - return self._bases[base_id] + if validate: + return self.bases(force=True)[base_id] + return pyairtable.api.base.Base(self, base_id) - def bases(self, /, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: + def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: """ - Returns a mapping of IDs to :class:`Base` instances. + Retrieves a list of all bases from the API and caches it, + returning a mapping of IDs to :class:`Base` instances. Args: force: |kwarg_force_metadata| + + Usage: + >>> api.bases() + { + 'appSW9R5uCNmRmfl6': , + 'appLkNDICXNqxSDhG': + } """ - url = self.build_url("meta/bases") if force or not self._bases: + url = self.build_url("meta/bases") + self._base_info = Bases.parse_obj( + { + "bases": [ + base_info + for page in self.iterate_requests("GET", url) + for base_info in page["bases"] + ] + } + ) self._bases = { - data["id"]: pyairtable.api.base.Base( - self, data["id"], name=data["name"] + info.id: pyairtable.api.base.Base( + self, + info.id, + name=info.name, + permission_level=info.permission_level, ) - for page in self.iterate_requests("GET", url) - for data in page["bases"] + for info in self._base_info.bases } return dict(self._bases) @@ -189,8 +239,6 @@ def _process_response(self, response: requests.Response) -> Any: try: response.raise_for_status() except requests.exceptions.HTTPError as exc: - err_msg = str(exc) - # Attempt to get Error message from response, Issue #16 try: error_dict = response.json() @@ -198,8 +246,7 @@ def _process_response(self, response: requests.Response) -> Any: pass else: if "error" in error_dict: - err_msg += " [Error: {}]".format(error_dict["error"]) - exc.args = (*exc.args, err_msg) + exc.args = (*exc.args, repr(error_dict["error"])) raise exc # Some Airtable endpoints will respond with an empty body and a 200. @@ -250,11 +297,3 @@ def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: to the maximum number of records per request allowed by the API. """ return chunked(iterable, self.MAX_RECORDS_PER_REQUEST) - - def whoami(self) -> UserAndScopesDict: - """ - Return the current user ID and (if connected via OAuth) the list of scopes. - See `Get user ID & scopes `_ for more information. - """ - data = self.request("GET", self.build_url("meta/whoami")) - return assert_typed_dict(UserAndScopesDict, data) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 29af8fc7..e2473cc2 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -1,15 +1,16 @@ import warnings -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union import pyairtable.api.api import pyairtable.api.table -from pyairtable.models import schema +from pyairtable.models.schema import BaseInfo, BaseSchema, PermissionLevel from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, Webhook, WebhookSpecification, ) +from pyairtable.utils import enterprise_only class Base: @@ -23,15 +24,20 @@ class Base: #: The base ID, in the format ``appXXXXXXXXXXXXXX`` id: str - _name: Optional[str] - _info: Optional[schema.BaseInfo] + #: The permission level the current user has on the base + permission_level: Optional[PermissionLevel] + + # Cached metadata to reduce API calls + _info: Optional[BaseInfo] = None + _schema: Optional[BaseSchema] = None def __init__( self, api: Union["pyairtable.api.api.Api", str], base_id: str, - /, + *, name: Optional[str] = None, + permission_level: Optional[PermissionLevel] = None, ): """ Old style constructor takes ``str`` arguments, and will create its own @@ -60,68 +66,91 @@ def __init__( self.api = api self.id = base_id - + self.permission_level = permission_level self._name = name - self._info: Optional[schema.BaseInfo] = None - self._schema: Optional[schema.BaseSchema] = None - self._tables: Dict[str, "pyairtable.api.table.Table"] = {} - def __repr__(self) -> str: - return f"" + @property + def name(self) -> Optional[str]: + """ + The name of the base, if provided to the constructor + or available in cached base information. + """ + if self._info: + return self._info.name + return self._name - def table(self, id_or_name: str) -> "pyairtable.api.table.Table": + def __repr__(self) -> str: + repr = f"" + + def table( + self, + id_or_name: str, + *, + validate: bool = False, + ) -> "pyairtable.api.table.Table": """ Returns a new :class:`Table` instance using this instance of :class:`Base`. Args: id_or_name: An Airtable table ID or name. Table name should be unencoded, as shown on browser. + validate: |kwarg_validate_metadata| + + Usage: + >>> base.table('Apartments') +
""" - # If we've got the schema already, we can validate the ID or name exists. - if self._schema: - try: - return self.tables()[id_or_name] - except KeyError: - # This will raise KeyError (again) if the name/ID really doesn't exist - info = self._schema.table(id_or_name) - return self._tables[info.id] - - # If the schema is not cached, we're not going to perform network - # traffic just to look it up, so we assume it's a valid name/ID. + if validate: + schema = self.schema(force=True).table(id_or_name) + return pyairtable.api.table.Table(None, self, schema) return pyairtable.api.table.Table(None, self, id_or_name) - def tables(self, /, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: + def tables(self, *, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: """ Retrieves the base's schema from the API and returns a mapping of IDs to :class:`Table` instances. Args: force: |kwarg_force_metadata| - """ - if force or not self._tables: - self._tables = { - table_info.id: pyairtable.api.table.Table(None, self, table_info.id) - for table_info in self.schema().tables - } - return dict(self._tables) - @property - def name(self) -> Optional[str]: + Usage: + >>> base.tables() + { + 'tbltp8DGLhqbUmjK1':
, + 'tblK6MZHez0ZvBChZ':
+ } """ - Returns the name of the base, if known. + return { + info.id: pyairtable.api.table.Table(None, self, info) + for info in self.schema(force=force).tables + } - pyAirtable will not perform network traffic as a result of property calls, - so this property only returns a value if one of these conditions are met: + def create_table( + self, + name: str, + fields: Sequence[Dict[str, Any]], + description: Optional[str] = None, + ) -> "pyairtable.api.table.Table": + """ + Creates a table in the given base. - 1. The Base was initialized with the ``name=`` keyword parameter, - usually because it was created by :meth:`Api.bases `. - 2. The :meth:`~pyairtable.Base.info` method has already been called. + Args: + name: The unique table name. + fields: A list of ``dict`` objects that conform to Airtable's + `Field model `__. + description: The table description. Must be no longer than 20k characters. """ - if self._name: - return self._name - if self._info: - return self._info.name - return None + url = self.meta_url("tables") + payload = {"name": name, "fields": fields} + if description: + payload["description"] = description + response = self.api.request("POST", url, json=payload) + return self.table(response["id"], validate=True) @property def url(self) -> str: @@ -133,21 +162,7 @@ def meta_url(self, *components: Any) -> str: """ return self.api.build_url("meta/bases", self.id, *components) - def info(self, /, force: bool = False) -> schema.BaseInfo: - """ - Retrieves `base information `__ - from the API and caches it. - - Args: - force: |kwarg_force_metadata| - """ - if force or not self._info: - params = {"include": ["collaborators", "inviteLinks", "interfaces"]} - result = self.api.request("GET", self.meta_url(), params=params) - self._info = schema.BaseInfo.parse_obj(result) - return self._info - - def schema(self, /, force: bool = False) -> schema.BaseSchema: + def schema(self, *, force: bool = False) -> BaseSchema: """ Retrieves the schema of all tables in the base and caches it. @@ -166,9 +181,24 @@ def schema(self, /, force: bool = False) -> schema.BaseSchema: url = self.meta_url("tables") params = {"include": ["visibleFieldIds"]} data = self.api.request("GET", url, params=params) - self._schema = schema.BaseSchema.parse_obj(data) + self._schema = BaseSchema.parse_obj(data) return self._schema + @enterprise_only + def info(self, *, force: bool = False) -> "BaseInfo": + """ + Retrieves `base collaborators `__ + from the API. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._info: + params = {"include": ["collaborators", "inviteLinks", "interfaces"]} + data = self.api.request("GET", self.meta_url(), params=params) + self._info = BaseInfo.parse_obj(data) + return self._info + @property def webhooks_url(self) -> str: return self.api.build_url("bases", self.id, "webhooks") diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py new file mode 100644 index 00000000..0e0e07ae --- /dev/null +++ b/pyairtable/api/enterprise.py @@ -0,0 +1,80 @@ +from typing import Iterable, List, Optional + +from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo +from pyairtable.utils import enterprise_only + + +@enterprise_only +class Enterprise: + """ + Represents an Airtable enterprise account. + + >>> enterprise = api.enterprise("entUBq2RGdihxl3vU") + >>> enterprise.info().workspace_ids + ['wspmhESAta6clCCwF', ...] + """ + + def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): + self.api = api + self.id = workspace_id + self._info: Optional[EnterpriseInfo] = None + + @property + def url(self) -> str: + return self.api.build_url("meta/enterpriseAccounts", self.id) + + def info(self, *, force: bool = False) -> EnterpriseInfo: + """ + Retrieves basic information about the enterprise, caching the result. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._info: + params = {"include": ["collaborators", "inviteLinks"]} + payload = self.api.request("GET", self.url, params=params) + self._info = EnterpriseInfo.parse_obj(payload) + return self._info + + def group(self, group_id: str) -> GroupInfo: + url = self.api.build_url(f"meta/groups/{group_id}") + return GroupInfo.parse_obj(self.api.request("GET", url)) + + def user(self, id_or_email: str) -> UserInfo: + """ + Returns information on a single user with the given ID or email. + + Args: + id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address. + """ + return self.users([id_or_email])[0] + + def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: + """ + Returns information on the users with the given IDs or emails. + + Args: + ids_or_emails: A sequence of user IDs (``usrQBq2RGdihxl3vU``) + or email addresses (or both). + """ + user_ids: List[str] = [] + emails: List[str] = [] + for value in ids_or_emails: + (user_ids, emails)["@" in value].append(value) + + users = [] + for user_id in user_ids: + response = self.api.request("GET", f"{self.url}/users/{user_id}") + users.append(UserInfo.parse_obj(response)) + if emails: + response = self.api.request( + "GET", f"{self.url}/users", params={"email": emails} + ) + users += [UserInfo.parse_obj(user_obj) for user_obj in response["users"]] + + return users + + +# These are at the bottom of the module to avoid circular imports +import pyairtable.api.api # noqa +import pyairtable.api.base # noqa diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 24b63fc8..5d064abd 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -35,6 +35,9 @@ class Table: #: Can be either the table name or the table ID (``tblXXXXXXXXXXXXXX``). name: str + # Cached schema information to reduce API calls + _schema: Optional[TableSchema] = None + @overload def __init__( self, @@ -57,11 +60,20 @@ def __init__( ): ... + @overload + def __init__( + self, + api_key: None, + base_id: "pyairtable.api.base.Base", + table_name: TableSchema, + ): + ... + def __init__( self, api_key: Union[None, str], base_id: Union["pyairtable.api.base.Base", str], - table_name: str, + table_name: Union[str, TableSchema], **kwargs: Any, ): """ @@ -92,27 +104,38 @@ def __init__( stacklevel=2, ) api = pyairtable.api.api.Api(api_key, **kwargs) - base = api.base(base_id) - elif api_key is None and isinstance(base_id, pyairtable.api.base.Base): - base = base_id + self.base = api.base(base_id) + elif api_key is None and isinstance(base := base_id, pyairtable.api.base.Base): + self.base = base else: raise TypeError( - "Table() expects either (str, str, str) or (None, Base, str);" + "Table() expects (None, Base, str | TableSchema);" f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})" ) - self.base = base - self.name = table_name + if isinstance(table_name, str): + self.name = table_name + elif isinstance(schema := table_name, TableSchema): + self._schema = schema + self.name = schema.name + else: + raise TypeError( + "Table() expects (None, Base, str | TableSchema);" + f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})" + ) def __repr__(self) -> str: - return f"
" + if self._schema: + return f"
" + return f"
" @property def url(self) -> str: """ Returns the URL for this table. """ - return self.api.build_url(self.base.id, urllib.parse.quote(self.name, safe="")) + token = self._schema.id if self._schema else self.name + return self.api.build_url(self.base.id, urllib.parse.quote(token, safe="")) def record_url(self, record_id: RecordId, *components: str) -> str: """ @@ -562,11 +585,11 @@ def add_comment( obj=response, ) - def schema(self) -> TableSchema: + def schema(self, *, force: bool = False) -> TableSchema: """ Retrieves the schema of the current table. The return value will be cached on the :class:`~pyairtable.Base` instance; - to clear the cache, call ``table.base.schema(force=True)``. + to refresh the cache, call `table.base.schema(force=True) `. Usage: >>> table.schema() @@ -578,9 +601,18 @@ def schema(self) -> TableSchema: views=[...] ) >>> table.schema().field("fld6jG0XedVMNxFQW") - SingleLineTextFieldSchema(id='fld6jG0XedVMNxFQW', name='Name', type='singleLineText') + SingleLineTextFieldSchema( + id='fld6jG0XedVMNxFQW', + name='Name', + type='singleLineText' + ) + + Args: + force: |kwarg_force_metadata| """ - return self.base.schema().table(self.name) + if force or not self._schema: + self._schema = self.base.schema(force=force).table(self.name) + return self._schema # These are at the bottom of the module to avoid circular imports diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py new file mode 100644 index 00000000..dc2b3f0f --- /dev/null +++ b/pyairtable/api/workspace.py @@ -0,0 +1,83 @@ +from typing import Any, Dict, List, Optional, Sequence + +from pyairtable.models.schema import WorkspaceInfo +from pyairtable.utils import enterprise_only + + +class Workspace: + """ + Represents an Airtable workspace, which contains a number of bases + and its own set of collaborators. + + >>> ws = api.workspace("wspmhESAta6clCCwF") + >>> ws.info().name + 'my first workspace' + >>> ws.create_base("Base Name", tables=[...]) + + + Most workspace functionality is limited to users on Enterprise billing plans. + """ + + def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): + self.api = api + self.id = workspace_id + self._info: Optional[WorkspaceInfo] = None + + @property + def url(self) -> str: + return self.api.build_url("meta/workspaces", self.id) + + def create_base( + self, + name: str, + tables: Sequence[Dict[str, Any]], + ) -> "pyairtable.api.base.Base": + """ + Creates a base in the given workspace. + + Args: + name: The name to give to the new base. Does not need to be unique. + tables: A list of ``dict`` objects that conform to Airtable's + `Table model `__. + """ + url = self.api.build_url("meta/bases") + payload = {"name": name, "workspaceId": self.id, "tables": list(tables)} + response = self.api.request("POST", url, json=payload) + return self.api.base(response["id"], validate=True) + + # Everything below here requires .info() and is therefore Enterprise-only + + @enterprise_only + def info(self, *, force: bool = False) -> WorkspaceInfo: + """ + Retrieves basic information, collaborators, and invites + for the given workspace, caching the result. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._info: + params = {"include": ["collaborators", "inviteLinks"]} + payload = self.api.request("GET", self.url, params=params) + self._info = WorkspaceInfo.parse_obj(payload) + return self._info + + @enterprise_only + def bases(self) -> List["pyairtable.api.base.Base"]: + """ + Retrieves all bases within the workspace. + """ + return [self.api.base(base_id) for base_id in self.info().base_ids] + + @property + @enterprise_only + def name(self) -> str: + """ + The name of the workspace. + """ + return self.info().name + + +# These are at the bottom of the module to avoid circular imports +import pyairtable.api.api # noqa +import pyairtable.api.base # noqa diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index ae3a1caf..8114d140 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -30,10 +30,28 @@ def _find(collection: List[_T], id_or_name: str) -> _T: return items_by_name[id_or_name] -class BaseInfo(AirtableModel): +class Bases(AirtableModel): """ See https://airtable.com/developers/web/api/list-bases - and https://airtable.com/developers/web/api/get-base-collaborators + """ + + bases: List["Bases.Info"] = _FL() + + def base(self, base_id: str) -> "Bases.Info": + """ + Returns basic information about the base with the given ID. + """ + return _find(self.bases, base_id) + + class Info(AirtableModel): + id: str + name: str + permission_level: PermissionLevel + + +class BaseInfo(AirtableModel): + """ + See https://airtable.com/developers/web/api/get-base-collaborators """ id: str @@ -52,12 +70,14 @@ class InterfaceCollaborators(AirtableModel): invite_links: List["InviteLink"] = _FL() class GroupCollaborators(AirtableModel): - base_collaborators: List["GroupCollaborator"] = _FL() - workspace_collaborators: List["GroupCollaborator"] = _FL() + via_base: List["GroupCollaborator"] = _FL(alias="baseCollaborators") + via_workspace: List["GroupCollaborator"] = _FL(alias="workspaceCollaborators") class IndividualCollaborators(AirtableModel): - base_collaborators: List["IndividualCollaborator"] = _FL() - workspace_collaborators: List["IndividualCollaborator"] = _FL() + via_base: List["IndividualCollaborator"] = _FL(alias="baseCollaborators") + via_workspace: List["IndividualCollaborator"] = _FL( + alias="workspaceCollaborators" + ) class InviteLinks(AirtableModel): base_invite_links: List["InviteLink"] = _FL() @@ -141,6 +161,91 @@ class InviteLink(AirtableModel): restricted_to_email_domains: List[str] = _FL() +class BaseIndividualCollaborator(IndividualCollaborator): + base_id: str + + +class BaseGroupCollaborator(GroupCollaborator): + base_id: str + + +class BaseInviteLink(InviteLink): + base_id: str + + +class EnterpriseInfo(AirtableModel): + id: str + created_time: str + group_ids: List[str] + user_ids: List[str] + workspace_ids: List[str] + email_domains: List["EnterpriseInfo.EmailDomain"] + + class EmailDomain(AirtableModel): + email_domain: str + is_sso_required: bool + + +class WorkspaceInfo(AirtableModel): + id: str + name: str + created_time: str + base_ids: List[str] + restrictions: "WorkspaceInfo.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip + group_collaborators: Optional["WorkspaceInfo.GroupCollaborators"] = None + individual_collaborators: Optional["WorkspaceInfo.IndividualCollaborators"] = None + invite_links: Optional["WorkspaceInfo.InviteLinks"] = None + + class Restrictions(AirtableModel): + invite_creation: str = pydantic.Field(alias="inviteCreationRestriction") + share_creation: str = pydantic.Field(alias="shareCreationRestriction") + + class GroupCollaborators(AirtableModel): + base_collaborators: List["BaseGroupCollaborator"] + workspace_collaborators: List["GroupCollaborator"] + + class IndividualCollaborators(AirtableModel): + base_collaborators: List["BaseIndividualCollaborator"] + workspace_collaborators: List["IndividualCollaborator"] + + class InviteLinks(AirtableModel): + base_invite_links: List["BaseInviteLink"] + workspace_invite_links: List["InviteLink"] + + +class _NestedId(AirtableModel): + id: str + + +class UserInfo(AirtableModel): + id: str + name: str + email: str + state: str + created_time: Optional[str] + invited_to_airtable_by_user_id: Optional[str] + last_activity_time: Optional[str] + is_managed_user: bool + groups: List[_NestedId] = pydantic.Field(default_factory=list) + + +class GroupInfo(AirtableModel): + id: str + name: str + enterprise_account_id: str + created_time: str + updated_time: str + members: List["GroupInfo.GroupMember"] + + class GroupMember(AirtableModel): + user_id: str + email: str + first_name: str + last_name: str + role: str + created_time: str + + # The data model is a bit confusing here, but it's designed for maximum reuse. # SomethingFieldConfig contains the `type` and `options` values for each field type. # _FieldSchemaBase contains the `id`, `name`, and `description` values. @@ -386,6 +491,37 @@ class UnknownFieldConfig(AirtableModel): options: Optional[Dict[Any, Any]] +class _FieldSchemaBase(AirtableModel): + id: str + name: str + description: Optional[str] + + +# This section is auto-generated so that FieldSchema and FieldConfig are kept aligned. +# See .pre-commit-config.yaml, or just run `tox -e pre-commit` to refresh it. +# fmt: off +r"""[[[cog]]] + +import re +with open(cog.inFile) as fp: + field_types = re.findall(r"class (\w+Field)Config\(", fp.read()) + +cog.outl("FieldConfig: TypeAlias = Union[") +for fld in field_types: + cog.outl(f" {fld}Config,") +cog.outl("]") +cog.out("\n\n") + +for fld in field_types: + cog.outl(f"class {fld}Schema(_FieldSchemaBase, {fld}Config): pass # noqa") +cog.out("\n\n") + +cog.outl("FieldSchema: TypeAlias = Union[") +for fld in field_types: + cog.outl(f" {fld}Schema,") +cog.outl("]") + +[[[out]]]""" FieldConfig: TypeAlias = Union[ AutoNumberFieldConfig, BarcodeFieldConfig, @@ -423,29 +559,6 @@ class UnknownFieldConfig(AirtableModel): ] -class _FieldSchemaBase(AirtableModel): - id: str - name: str - description: Optional[str] - - -# This section is auto-generated so that FieldSchema and FieldConfig are kept aligned. -# See .pre-commit-config.yaml, or just run `tox -e pre-commit` to refresh it. - -# fmt: off -# [[[cog]]] -# import re -# with open(cog.inFile) as fp: -# detail_classes = re.findall(r"class (\w+FieldConfig)\(", fp.read()) -# mapping = {detail: detail[:-6] + "Schema" for detail in detail_classes} -# for detail, schema in mapping.items(): -# cog.outl(f"class {schema}(_FieldSchemaBase, {detail}): pass # noqa") -# cog.outl("\n") -# cog.outl("FieldSchema: TypeAlias = Union[") -# for schema in mapping.values(): -# cog.outl(f" {schema},") -# cog.outl("]") -# [[[out]]] class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig): pass # noqa class BarcodeFieldSchema(_FieldSchemaBase, BarcodeFieldConfig): pass # noqa class ButtonFieldSchema(_FieldSchemaBase, ButtonFieldConfig): pass # noqa @@ -516,7 +629,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): pass # noqa UrlFieldSchema, UnknownFieldSchema, ] -# [[[end]]] (checksum: f711a065c3583ccad1c69913d42af7d6) +# [[[end]]] (checksum: 3f8dd40da7b03b16f7299cd99365692c) # fmt: on diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 1ca55bd3..0c094917 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -1,8 +1,16 @@ +import inspect +import re +import textwrap from datetime import date, datetime -from typing import Iterator, Sequence, TypeVar, Union +from functools import wraps +from typing import Any, Callable, Iterator, Sequence, TypeVar, Union, cast + +import requests +from typing_extensions import ParamSpec from pyairtable.api.types import CreateAttachmentDict +P = ParamSpec("P") T = TypeVar("T") @@ -94,3 +102,44 @@ def chunked(iterable: Sequence[T], chunk_size: int) -> Iterator[Sequence[T]]: """ for i in range(0, len(iterable), chunk_size): yield iterable[i : i + chunk_size] + + +F = TypeVar("F", bound=Callable[..., Any]) + + +def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F: + """ + Wraps a function or method so that if Airtable returns a 404, + we will annotate the error with a helpful note to the user. + """ + + if modify_docstring and (doc := wrapped.__doc__): + wrapped.__doc__ = _prepend_docstring_text(doc, "|enterprise_only|") + + # Allow putting the decorator on a class + if inspect.isclass(wrapped): + for name, obj in vars(wrapped).items(): + if inspect.isfunction(obj): + setattr(wrapped, name, enterprise_only(obj, modify_docstring=False)) + return cast(F, wrapped) + + @wraps(wrapped) + def _decorated(*args: Any, **kwargs: Any) -> Any: + try: + return wrapped(*args, **kwargs) + except requests.exceptions.HTTPError as exc: + if exc.response.status_code == 404: + exc.args = ( + *exc.args, + f"NOTE: {wrapped.__name__}() requires an enterprise billing plan.", + ) + raise exc + + return _decorated # type: ignore[return-value] + + +def _prepend_docstring_text(doc: str, text: str) -> str: + doc = doc.lstrip("\n") + if has_leading_spaces := re.match(r"^\s+", doc): + text = textwrap.indent(text, has_leading_spaces[0]) + return f"{text}\n\n{doc}" diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py index d1f25d60..d8573d86 100644 --- a/tests/integration/test_integration_metadata.py +++ b/tests/integration/test_integration_metadata.py @@ -1,18 +1,53 @@ import pytest +import requests -from pyairtable import Base, Table +from pyairtable import Api, Base, Table from pyairtable.metadata import get_api_bases, get_base_schema, get_table_schema pytestmark = [pytest.mark.integration] -def test_get_api_bases(base: Base, base_name: str): - rv = get_api_bases(base) +def test_api_bases(api: Api, base_id: str, base_name: str, table_name: str): + bases = api.bases() + assert bases[base_id].name == base_name + assert bases[base_id].table(table_name).name == table_name + + +def test_api_base(api: Api, base_id: str, base_name: str): + base = api.base(base_id, validate=True) + assert base.name == base_name + + +def test_base_collaborators(base: Base): + with pytest.raises( + requests.HTTPError, + match=r"collaborators\(\) requires an enterprise billing plan", + ): + base.info() + + +def test_base_schema(base: Base, table_name: str): + schema = base.schema() + assert table_name in [t.name for t in schema.tables] + assert schema.table(table_name).name == table_name + + +def test_table_schema(base: Base, table_name: str, cols): + schema = base.table(table_name).schema() + assert cols.TEXT in [f.name for f in schema.fields] + assert schema.field(cols.TEXT).id == cols.TEXT_ID + assert schema.field(cols.TEXT_ID).name == cols.TEXT + + +def test_deprecated_get_api_bases(base: Base, base_name: str): + with pytest.warns(DeprecationWarning): + rv = get_api_bases(base) assert base_name in [b["name"] for b in rv["bases"]] -def test_get_base_schema(base: Base): - rv = get_base_schema(base) +def test_deprecated_get_base_schema(base: Base): + with pytest.warns(DeprecationWarning): + rv = get_base_schema(base) assert sorted(table["name"] for table in rv["tables"]) == [ "Address", "Contact", @@ -21,11 +56,13 @@ def test_get_base_schema(base: Base): ] -def test_get_table_schema(table: Table): - rv = get_table_schema(table) +def test_deprecated_get_table_schema(table: Table): + with pytest.warns(DeprecationWarning): + rv = get_table_schema(table) assert rv and rv["name"] == table.name -def test_get_table_schema__invalid_table(table, monkeypatch): +def test_deprecated_get_table_schema__invalid_table(table, monkeypatch): monkeypatch.setattr(table, "name", "DoesNotExist") - assert get_table_schema(table) is None + with pytest.warns(DeprecationWarning): + assert get_table_schema(table) is None diff --git a/tests/sample_data/Enterprise.json b/tests/sample_data/Enterprise.json new file mode 100644 index 00000000..02ebde50 --- /dev/null +++ b/tests/sample_data/Enterprise.json @@ -0,0 +1,23 @@ +{ + "createdTime": "2019-01-03T12:33:12.421Z", + "emailDomains": [ + { + "emailDomain": "foobar.com", + "isSsoRequired": true + } + ], + "groupIds": [ + "ugp1mKGb3KXUyQfOZ", + "ugpR8ZT9KtIgp8Bh3" + ], + "id": "entUBq2RGdihxl3vU", + "userIds": [ + "usrL2PNC5o3H4lBEi", + "usrsOEchC9xuwRgKk", + "usrGcrteE5fUMqq0R" + ], + "workspaceIds": [ + "wspmhESAta6clCCwF", + "wspHvvm4dAktsStZH" + ] +} diff --git a/tests/sample_data/Workspace.json b/tests/sample_data/Workspace.json new file mode 100644 index 00000000..ca14986f --- /dev/null +++ b/tests/sample_data/Workspace.json @@ -0,0 +1,103 @@ +{ + "baseIds": [ + "appLkNDICXNqxSDhG", + "appSW9R5uCNmRmfl6" + ], + "collaborators": { + "baseCollaborators": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bam.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "create", + "userId": "usrsOEchC9xuwRgKk" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bar.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "owner", + "userId": "usrL2PNC5o3H4lBEi" + } + ] + }, + "createdTime": "2019-01-03T12:33:12.421Z", + "groupCollaborators": { + "baseCollaborators": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "groupId": "ugpR8ZT9KtIgp8Bh3", + "name": "group 2", + "permissionLevel": "create" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "groupId": "ugp1mKGb3KXUyQfOZ", + "name": "group 1", + "permissionLevel": "edit" + } + ] + }, + "id": "wspmhESAta6clCCwF", + "individualCollaborators": { + "baseCollaborators": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bam.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "create", + "userId": "usrsOEchC9xuwRgKk" + } + ], + "workspaceCollaborators": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bar.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "owner", + "userId": "usrL2PNC5o3H4lBEi" + } + ] + }, + "inviteLinks": { + "baseInviteLinks": [ + { + "baseId": "appSW9R5uCNmRmfl6", + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "invJiqaXmPqq6Ec87", + "invitedEmail": null, + "permissionLevel": "read", + "referredByUserId": "usrsOEchC9xuwRgKk", + "restrictedToEmailDomains": [], + "type": "multiUse" + } + ], + "workspaceInviteLinks": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "invJiqaXmPqq6Ec87", + "invitedEmail": "bam@bam.com", + "permissionLevel": "owner", + "referredByUserId": "usrL2PNC5o3H4lBEi", + "restrictedToEmailDomains": [ + "foobar.com" + ], + "type": "singleUse" + } + ] + }, + "name": "my first workspace", + "workspaceRestrictions": { + "inviteCreationRestriction": "onlyOwners", + "shareCreationRestriction": "unrestricted" + } +} diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 3ac15232..555f9221 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -72,7 +72,6 @@ def test_bases(api, requests_mock, sample_json): # ....unless we force it: reloaded = api.bases(force=True) assert set(reloaded) == set(bases) - assert reloaded != bases assert m.call_count == 2 @@ -92,3 +91,10 @@ def test_iterate_requests__invalid_type(api: Api, requests_mock): requests_mock.get(url, response_list=response_list) responses = list(api.iterate_requests("GET", url)) assert responses == [response["json"] for response in response_list] + + +def test_enterprise(api: Api, requests_mock, sample_json): + url = api.build_url("meta/enterpriseAccount/entUBq2RGdihxl3vU") + requests_mock.get(url, json=sample_json("Enterprise")) + enterprise = api.enterprise("entUBq2RGdihxl3vU") + assert enterprise.id == "entUBq2RGdihxl3vU" diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 5c6bff55..00c2d1e5 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -30,8 +30,26 @@ def test_invalid_constructor(): Base("api_key", "base_id", timeout=(1, 1)) -def test_repr(base): - assert "Base" in base.__repr__() +@pytest.mark.parametrize( + "kwargs,expected", + [ + ( + dict(base_id="appFake"), + "", + ), + ( + dict(base_id="appFake", name="Some name"), + "", + ), + ( + dict(base_id="appFake", permission_level="editor"), + "", + ), + ], +) +def test_repr(api, kwargs, expected): + base = Base(api, **kwargs) + assert repr(base) == expected def test_url(base): @@ -60,17 +78,36 @@ def test_table(base: Base, requests_mock): assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename" -def test_table__with_validation(base: Base, requests_mock, sample_json): +def test_table_validate(base: Base, requests_mock, sample_json): """ - Test that Base.table() behaves differently once we've loaded a schema. + Test that Base.table(..., validate=True) allows us to look up a table + by either ID or name and get the correct properties. """ - requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) - base.schema() - # once a schema has been loaded, Base.table() can reuse objects by ID or name - assert base.table("tbltp8DGLhqbUmjK1") == base.table("Apartments") + m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + base.table("tbltp8DGLhqbUmjK1", validate=True) + base.table("Apartments", validate=True) + assert m.call_count == 2 # ...and will raise an exception if called with an invalid ID/name: with pytest.raises(KeyError): - base.table("DoesNotExist") + base.table("DoesNotExist", validate=True) + + +def test_tables(base: Base, requests_mock, sample_json): + """ + Test that Base.tables() returns a dict of validated Base instances. + """ + requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + result = base.tables() + assert len(result) == 2 + assert result["tbltp8DGLhqbUmjK1"].name == "Apartments" + assert result["tblK6MZHez0ZvBChZ"].name == "Districts" + + +def test_collaborators(base: Base, requests_mock, sample_json): + requests_mock.get(base.meta_url(), json=sample_json("BaseInfo")) + result = base.info() + assert result.individual_collaborators.via_base[0].email == "foo@bam.com" + assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ" def test_webhooks(base: Base, requests_mock, sample_json): @@ -127,23 +164,49 @@ def _callback(request, context): assert result.mac_secret_base64 == "secret" -def test_name(base, requests_mock): +def test_name(api, base, requests_mock): """ Test that Base().name is only set if passed explicitly to the constructor, - or if retrieved by a call to Base().info() + or if it is available in cached schema information. """ - assert base.name is None - assert Base("token", "base_id").name is None - assert Base("token", "base_id", name="Base Name").name == "Base Name" - requests_mock.get( base.meta_url(), json={ "id": base.id, - "name": "Base Name", + "name": "Mocked Base Name", "permissionLevel": "create", "workspaceId": "wspFake", }, ) - assert base.info().name == "Base Name" - assert base.name == "Base Name" + + assert api.base(base.id).name is None + assert base.name is None + assert base.info().name == "Mocked Base Name" + assert base.name == "Mocked Base Name" + + # Test behavior with older constructor pattern + with pytest.warns(DeprecationWarning): + assert Base("tok", "app").name is None + with pytest.warns(DeprecationWarning): + assert Base("tok", "app", name="Base Name").name == "Base Name" + + +def test_create_table(base, requests_mock, sample_json): + """ + Test that Base.create_table() makes two calls, one to create the table, + and another to re-retrieve the entire base's schema. + """ + schema = sample_json("BaseSchema") + url = base.meta_url("tables") + m = requests_mock.post(url, json={"id": "tbltp8DGLhqbUmjK1"}) + m_get = requests_mock.get(url + "?include=visibleFieldIds", json=schema) + table = base.create_table( + "Table Name", [{"name": "Whatever"}], description="Description" + ) + assert isinstance(table, Table) + assert m.call_count == m_get.call_count == 1 + assert m.request_history[-1].json() == { + "name": "Table Name", + "description": "Description", + "fields": [{"name": "Whatever"}], + } diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py new file mode 100644 index 00000000..36c18693 --- /dev/null +++ b/tests/test_api_enterprise.py @@ -0,0 +1,19 @@ +import pytest + +from pyairtable.api.enterprise import Enterprise + + +@pytest.fixture +def enterprise(api): + return Enterprise(api, "entUBq2RGdihxl3vU") + + +def test_info(enterprise, requests_mock, sample_json): + m = requests_mock.get(enterprise.url, json=sample_json("Enterprise")) + assert enterprise.info().id == "entUBq2RGdihxl3vU" + assert enterprise.info().workspace_ids == ["wspmhESAta6clCCwF", "wspHvvm4dAktsStZH"] + assert enterprise.info().email_domains[0].is_sso_required is True + assert m.call_count == 1 + + assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU" + assert m.call_count == 2 diff --git a/tests/test_api_table.py b/tests/test_api_table.py index be5f7154..51c4e9d7 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -5,10 +5,16 @@ from requests_mock import Mocker from pyairtable import Api, Base, Table +from pyairtable.models.schema import TableSchema from pyairtable.testing import fake_record from pyairtable.utils import chunked +@pytest.fixture() +def table_schema(sample_json) -> TableSchema: + return TableSchema.parse_obj(sample_json("TableSchema")) + + def test_constructor(base: Base): """ Test the constructor. @@ -19,6 +25,18 @@ def test_constructor(base: Base): assert table.name == "table_name" +def test_constructor_with_schema(base: Base, table_schema: TableSchema): + table = Table(None, base, table_schema) + assert table.api == base.api + assert table.base == base + assert table.name == table_schema.name + assert table.url == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}" + assert ( + repr(table) + == f"
" + ) + + def test_deprecated_constructor(api: Api, base: Base): """ Test that "legacy" constructor (passing strings instead of instances) @@ -41,6 +59,7 @@ def test_invalid_constructor(api, base): [api, "base_id", "table_name"], ["api_key", base, "table_name"], [api, base, "table_name"], + [None, base, -1], ]: kwargs = args.pop() if isinstance(args[-1], dict) else {} with pytest.raises(TypeError): @@ -49,7 +68,7 @@ def test_invalid_constructor(api, base): def test_repr(table: Table): - assert repr(table) == "
" + assert repr(table) == "
" def test_schema(requests_mock, sample_json): diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py new file mode 100644 index 00000000..3dcfe334 --- /dev/null +++ b/tests/test_api_workspace.py @@ -0,0 +1,32 @@ +import pytest + +from pyairtable.api.workspace import Workspace + + +@pytest.fixture +def workspace(api): + return Workspace(api, "wspFakeWorkspaceId") + + +@pytest.fixture +def mock_info(workspace, requests_mock, sample_json): + return requests_mock.get(workspace.url, json=sample_json("Workspace")) + + +def test_info(workspace, mock_info): + assert workspace.info().id == "wspmhESAta6clCCwF" + assert workspace.info().name == "my first workspace" + assert mock_info.call_count == 1 + + +def test_name(workspace, mock_info): + assert workspace.name == "my first workspace" + assert mock_info.call_count == 1 + + +def test_bases(workspace, mock_info): + bases = workspace.bases() + assert len(bases) == 2 + assert bases[0].id == "appLkNDICXNqxSDhG" + assert bases[1].id == "appSW9R5uCNmRmfl6" + assert mock_info.call_count == 1 diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 3544000a..94d8268f 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -8,6 +8,7 @@ @pytest.mark.parametrize( "clsname", [ + "Bases", "BaseInfo", "BaseSchema", "TableSchema", From 8d482219b3a1ca81df22ecd5a786ff28acc8c0c9 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 4 Sep 2023 11:19:44 -0700 Subject: [PATCH 010/272] Api.delete_base, Api.create_base, Table.create_field This adds methods for creating bases, deleting bases, and creating fields. (Airtable does not support deleting fields via API.) --- docs/source/metadata.rst | 18 +++++--------- pyairtable/api/api.py | 34 +++++++++++++++++++++++---- pyairtable/api/base.py | 30 +++++++++++------------ pyairtable/api/table.py | 47 ++++++++++++++++++++++++++++++++++++- pyairtable/models/schema.py | 10 ++++++++ pyairtable/utils.py | 25 ++++++++++++++++++-- tests/test_api_api.py | 39 +++++++++++++++++++++++++++++- tests/test_api_table.py | 27 +++++++++++++++++---- tests/test_api_workspace.py | 10 ++++++++ tests/test_utils.py | 21 +++++++++++++++++ 10 files changed, 222 insertions(+), 39 deletions(-) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index fcee1269..22a46d2f 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -38,23 +38,17 @@ There may be parts of the pyAirtable API which are not supported below; you can always use :meth:`Api.request ` to call them directly. -.. automethod:: pyairtable.Workspace.create_base +.. automethod:: pyairtable.Api.create_base :noindex: -.. automethod:: pyairtable.Base.create_table +.. automethod:: pyairtable.Api.delete_base :noindex: -.. .. automethod:: pyairtable.Base.delete -.. :noindex: - -.. .. automethod:: pyairtable.Table.create_field -.. :noindex: - -.. .. automethod:: pyairtable.Table.delete_field -.. :noindex: +.. automethod:: pyairtable.Base.create_table + :noindex: -.. .. automethod:: pyairtable.Table.delete -.. :noindex: +.. automethod:: pyairtable.Table.create_field + :noindex: Enterprise information diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 1043d2e2..83eddc5c 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -99,10 +99,6 @@ def whoami(self) -> UserAndScopesDict: data = self.request("GET", self.build_url("meta/whoami")) return assert_typed_dict(UserAndScopesDict, data) - @enterprise_only - def enterprise(self, enterprise_account_id: str) -> Enterprise: - return Enterprise(self, enterprise_account_id) - def workspace(self, workspace_id: str) -> Workspace: return Workspace(self, workspace_id) @@ -163,6 +159,23 @@ def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"] } return dict(self._bases) + def create_base( + self, + workspace_id: str, + name: str, + tables: Sequence[Dict[str, Any]], + ) -> "pyairtable.api.base.Base": + """ + Creates a base in the given workspace. + + Args: + workspace_id: The ID of the workspace for the new base (e.g. ``wspmhESAta6clCCwF``). + name: The name to give to the new base. Does not need to be unique. + tables: A list of ``dict`` objects that conform to Airtable's + `Table model `__. + """ + return self.workspace(workspace_id).create_base(name, tables) + def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table": """ Returns a new :class:`Table` instance that uses this instance of :class:`Api`. @@ -297,3 +310,16 @@ def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: to the maximum number of records per request allowed by the API. """ return chunked(iterable, self.MAX_RECORDS_PER_REQUEST) + + @enterprise_only + def enterprise(self, enterprise_account_id: str) -> Enterprise: + return Enterprise(self, enterprise_account_id) + + @enterprise_only + def delete_base(self, base: Union[str, "pyairtable.api.base.Base"]) -> None: + """ + Deletes the base. + """ + if isinstance(base, str): + base = self.base(base) + self.request("DELETE", base.meta_url()) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index e2473cc2..9138dd54 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -184,21 +184,6 @@ def schema(self, *, force: bool = False) -> BaseSchema: self._schema = BaseSchema.parse_obj(data) return self._schema - @enterprise_only - def info(self, *, force: bool = False) -> "BaseInfo": - """ - Retrieves `base collaborators `__ - from the API. - - Args: - force: |kwarg_force_metadata| - """ - if force or not self._info: - params = {"include": ["collaborators", "inviteLinks", "interfaces"]} - data = self.api.request("GET", self.meta_url(), params=params) - self._info = BaseInfo.parse_obj(data) - return self._info - @property def webhooks_url(self) -> str: return self.api.build_url("bases", self.id, "webhooks") @@ -296,3 +281,18 @@ def add_webhook( request = create.dict(by_alias=True, exclude_unset=True) response = self.api.request("POST", self.webhooks_url, json=request) return CreateWebhookResponse.parse_obj(response) + + @enterprise_only + def info(self, *, force: bool = False) -> "BaseInfo": + """ + Retrieves `base collaborators `__ + from the API. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._info: + params = {"include": ["collaborators", "inviteLinks", "interfaces"]} + data = self.api.request("GET", self.meta_url(), params=params) + self._info = BaseInfo.parse_obj(data) + return self._info diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 5d064abd..66fd86b4 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -16,7 +16,8 @@ assert_typed_dict, assert_typed_dicts, ) -from pyairtable.models.schema import TableSchema +from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema +from pyairtable.utils import is_table_id class Table: @@ -129,6 +130,17 @@ def __repr__(self) -> str: return f"
" return f"
" + @property + def id(self) -> str: + """ + Returns the table's Airtable ID. If the instance was created with a name + rather than an ID, this property may perform an API request to retrieve + the base's schema. + """ + if is_table_id(self.name): + return self.name + return self.schema().id + @property def url(self) -> str: """ @@ -137,6 +149,14 @@ def url(self) -> str: token = self._schema.id if self._schema else self.name return self.api.build_url(self.base.id, urllib.parse.quote(token, safe="")) + def meta_url(self, *components: str) -> str: + """ + Builds a URL to a metadata endpoint for this table. + """ + return self.api.build_url( + f"meta/bases/{self.base.id}/tables/{self.id}", *components + ) + def record_url(self, record_id: RecordId, *components: str) -> str: """ Returns the URL for the given record ID, with optional trailing components. @@ -614,6 +634,31 @@ def schema(self, *, force: bool = False) -> TableSchema: self._schema = self.base.schema(force=force).table(self.name) return self._schema + def create_field( + self, + name: str, + field_type: str, + description: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, + ) -> FieldSchema: + """ + Creates a field on the table. + + Args: + name: The unique name of the field. + field_type: One of the `Airtable field types `__. + description: A long form description of the table. + options: Only available for some field types. For more information, read about the + `Airtable field model `__. + """ + request: Dict[str, Any] = {"name": name, "type": field_type} + if description: + request["description"] = description + if options: + request["options"] = options + response = self.api.request("POST", self.meta_url("fields"), json=request) + return parse_field_schema(response) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 8114d140..07659f94 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -633,4 +633,14 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): pass # noqa # fmt: on +# Shortcut to allow parsing unions, which is not possible otherwise in Pydantic v1. +# See https://github.com/pydantic/pydantic/discussions/4950 +class HasFieldSchema(AirtableModel): + field_schema: FieldSchema + + +def parse_field_schema(obj: Any) -> FieldSchema: + return HasFieldSchema.parse_obj({"field_schema": obj}).field_schema + + update_forward_refs(vars()) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 0c094917..d5208890 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -2,7 +2,7 @@ import re import textwrap from datetime import date, datetime -from functools import wraps +from functools import partial, wraps from typing import Any, Callable, Iterator, Sequence, TypeVar, Union, cast import requests @@ -104,6 +104,27 @@ def chunked(iterable: Sequence[T], chunk_size: int) -> Iterator[Sequence[T]]: yield iterable[i : i + chunk_size] +def is_airtable_id(value: Any, prefix: str = "") -> bool: + """ + Check whether the given value is an Airtable ID. + + Args: + value: The value to check. + prefix: If provided, the ID must have the given prefix. + """ + if not isinstance(value, str): + return False + if prefix and not value.startswith(prefix): + return False + return len(value) == 17 + + +is_record_id = partial(is_airtable_id, prefix="rec") +is_base_id = partial(is_airtable_id, prefix="app") +is_table_id = partial(is_airtable_id, prefix="tbl") +is_field_id = partial(is_airtable_id, prefix="fld") + + F = TypeVar("F", bound=Callable[..., Any]) @@ -131,7 +152,7 @@ def _decorated(*args: Any, **kwargs: Any) -> Any: if exc.response.status_code == 404: exc.args = ( *exc.args, - f"NOTE: {wrapped.__name__}() requires an enterprise billing plan.", + f"NOTE: {wrapped.__qualname__}() requires an enterprise billing plan.", ) raise exc diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 555f9221..817dbf85 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -1,3 +1,8 @@ +from unittest import mock + +import pytest +from requests import HTTPError + from pyairtable import Api, Base, Table # noqa @@ -93,8 +98,40 @@ def test_iterate_requests__invalid_type(api: Api, requests_mock): assert responses == [response["json"] for response in response_list] -def test_enterprise(api: Api, requests_mock, sample_json): +def test_workspace(api): + assert api.workspace("wspFake").id == "wspFake" + + +def test_enterprise(api, requests_mock, sample_json): url = api.build_url("meta/enterpriseAccount/entUBq2RGdihxl3vU") requests_mock.get(url, json=sample_json("Enterprise")) enterprise = api.enterprise("entUBq2RGdihxl3vU") assert enterprise.id == "entUBq2RGdihxl3vU" + + +def test_create_base(api): + """ + Test that Api.create_base is a passthrough to Workspace.create_base + """ + with mock.patch("pyairtable.Workspace.create_base") as m: + api.create_base("wspFake", "Fake Name", []) + + m.assert_called_once_with("Fake Name", []) + + +def test_delete_base(api, base, requests_mock): + """ + Test that Api.delete_base accepts either a Base or an ID. + """ + m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True}) + api.delete_base(base) + assert m.call_count == 1 + api.delete_base(base.id) + assert m.call_count == 2 + + +def test_delete_base__enterprise_only_table(api, base, requests_mock): + requests_mock.delete(base.meta_url(), status_code=404) + with pytest.raises(HTTPError) as excinfo: + api.delete_base(base.id) + assert "Api.delete_base() requires an enterprise billing plan" in str(excinfo) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 51c4e9d7..fba17c78 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -6,7 +6,7 @@ from pyairtable import Api, Base, Table from pyairtable.models.schema import TableSchema -from pyairtable.testing import fake_record +from pyairtable.testing import fake_id, fake_record from pyairtable.utils import chunked @@ -71,10 +71,10 @@ def test_repr(table: Table): assert repr(table) == "
" -def test_schema(requests_mock, sample_json): +def test_schema(base, requests_mock, sample_json): schema_json = sample_json("BaseSchema") - table = Table("api_key", "base_id", "Apartments") - requests_mock.get(table.base.meta_url("tables"), json=schema_json) + table = base.table("Apartments") + requests_mock.get(base.meta_url("tables"), json=schema_json) assert table.schema().id == "tbltp8DGLhqbUmjK1" @@ -362,6 +362,25 @@ def test_batch_delete(table: Table, container, mock_records): assert resp == expected +def test_create_field(table, requests_mock, sample_json): + """ + Tests the API for creating a field (but without actually performing the operation). + """ + table.name = fake_id("tbl") # so that the .id property doesn't request schema + field_schema = sample_json("field_schema/SingleSelectFieldSchema") + choices = ["Todo", "In progress", "Done"] + m = requests_mock.post(table.meta_url("fields"), json=field_schema) + f = table.create_field("Status", "singleSelect", options={"choices": choices}) + assert f.id == "fldqCjrs1UhXgHUIc" + assert {c.name for c in f.options.choices} == set(choices) + assert m.call_count == 1 + assert m.request_history[-1].json() == { + "name": "Status", + "type": "singleSelect", + "options": {"choices": choices}, + } + + # Helpers diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index 3dcfe334..802561e0 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -1,5 +1,6 @@ import pytest +from pyairtable.api.base import Base from pyairtable.api.workspace import Workspace @@ -30,3 +31,12 @@ def test_bases(workspace, mock_info): assert bases[0].id == "appLkNDICXNqxSDhG" assert bases[1].id == "appSW9R5uCNmRmfl6" assert mock_info.call_count == 1 + + +def test_create_base(workspace, requests_mock, sample_json): + url = workspace.api.build_url("meta/bases") + requests_mock.get(url, json=sample_json("Bases")) + requests_mock.post(url, json={"id": "appLkNDICXNqxSDhG"}) + base = workspace.create_base("Base Name", []) + assert isinstance(base, Base) + assert base.id == "appLkNDICXNqxSDhG" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2e2404be..2dfc0413 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -37,3 +37,24 @@ def test_attachment(): "url": "https://url.com", "filename": "test.jpg", } + + +@pytest.mark.parametrize( + "func,value,expected", + [ + (utils.is_airtable_id, -1, False), + (utils.is_airtable_id, "appAkBDICXDqESDhF", True), + (utils.is_airtable_id, "app0000000000Fake", True), + (utils.is_airtable_id, "appWrongLength", False), + (utils.is_record_id, "rec0000000000Fake", True), + (utils.is_record_id, "app0000000000Fake", False), + (utils.is_base_id, "app0000000000Fake", True), + (utils.is_base_id, "rec0000000000Fake", False), + (utils.is_table_id, "tbl0000000000Fake", True), + (utils.is_table_id, "rec0000000000Fake", False), + (utils.is_field_id, "fld0000000000Fake", True), + (utils.is_field_id, "rec0000000000Fake", False), + ], +) +def test_id_check(func, value, expected): + assert func(value) is expected From 6123af158f2c3178fe4f2158363d02e3c4910f28 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 18 Sep 2023 21:22:08 -0700 Subject: [PATCH 011/272] Metadata test coverage & doc fixes This cleans up the documentation and gets us as close to full test coverage as possible. --- docs/source/api.rst | 2 +- docs/source/conf.py | 2 +- pyairtable/api/api.py | 8 +- pyairtable/api/base.py | 8 +- pyairtable/api/enterprise.py | 37 +- pyairtable/api/table.py | 4 +- pyairtable/metadata.py | 17 +- pyairtable/models/schema.py | 574 ++++++++++++++---- pyairtable/utils.py | 1 + .../integration/test_integration_metadata.py | 4 +- tests/sample_data/User.json | 45 ++ tests/sample_data/UserGroup.json | 60 ++ tests/test_api_enterprise.py | 79 ++- tests/test_api_table.py | 34 +- tests/test_models_schema.py | 2 + 15 files changed, 714 insertions(+), 163 deletions(-) create mode 100644 tests/sample_data/User.json create mode 100644 tests/sample_data/UserGroup.json diff --git a/docs/source/api.rst b/docs/source/api.rst index 46fb0443..7f0010bf 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -53,6 +53,7 @@ API: pyairtable.models.schema .. automodule:: pyairtable.models.schema :members: + :inherited-members: AirtableModel API: pyairtable.orm @@ -67,7 +68,6 @@ API: pyairtable.orm.fields .. automodule:: pyairtable.orm.fields :members: - :member-order: bysource :exclude-members: valid_types, contains_type :no-inherited-members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 380add5f..3d50c94f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -47,7 +47,7 @@ autodoc_member_order = "bysource" autoclass_content = "class" -# See https://autodoc-pydantic.readthedocs.io/en/stable/users/configuration.html +# See https://autodoc-pydantic.readthedocs.io/en/v1.9.0/users/configuration.html autodoc_pydantic_field_show_alias = False autodoc_pydantic_model_member_order = "bysource" autodoc_pydantic_model_show_config_summary = False diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 83eddc5c..ef9bdb71 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -169,7 +169,7 @@ def create_base( Creates a base in the given workspace. Args: - workspace_id: The ID of the workspace for the new base (e.g. ``wspmhESAta6clCCwF``). + workspace_id: The ID of the workspace where the new base will live. name: The name to give to the new base. Does not need to be unique. tables: A list of ``dict`` objects that conform to Airtable's `Table model `__. @@ -313,12 +313,18 @@ def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: @enterprise_only def enterprise(self, enterprise_account_id: str) -> Enterprise: + """ + Returns an object representing an enterprise account. + """ return Enterprise(self, enterprise_account_id) @enterprise_only def delete_base(self, base: Union[str, "pyairtable.api.base.Base"]) -> None: """ Deletes the base. + + Args: + base: Either a base ID or a :class:`~pyairtable.Base` instance. """ if isinstance(base, str): base = self.base(base) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 9138dd54..fa5fb061 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -112,8 +112,7 @@ def table( def tables(self, *, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: """ - Retrieves the base's schema from the API - and returns a mapping of IDs to :class:`Table` instances. + Retrieves the base's schema and returns a mapping of IDs to :class:`Table` instances. Args: force: |kwarg_force_metadata| @@ -190,7 +189,7 @@ def webhooks_url(self) -> str: def webhooks(self) -> List[Webhook]: """ - Retrieves all the base's webhooks from the API + Retrieves all the base's webhooks (see: `List webhooks `_). Usage: @@ -285,8 +284,7 @@ def add_webhook( @enterprise_only def info(self, *, force: bool = False) -> "BaseInfo": """ - Retrieves `base collaborators `__ - from the API. + Retrieves `base collaborators `__. Args: force: |kwarg_force_metadata| diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 0e0e07ae..1e9c4e14 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,7 +1,7 @@ -from typing import Iterable, List, Optional +from typing import Dict, Iterable, List, Optional from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo -from pyairtable.utils import enterprise_only +from pyairtable.utils import enterprise_only, is_user_id @enterprise_only @@ -53,26 +53,45 @@ def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: """ Returns information on the users with the given IDs or emails. + Following the Airtable API specification, pyAirtable will perform + one API request for each user ID. However, when given a list of emails, + pyAirtable only needs to perform one API request for the entire list. + + Read more at `Get user by ID `__ + and `Get user by email `__. + Args: ids_or_emails: A sequence of user IDs (``usrQBq2RGdihxl3vU``) or email addresses (or both). """ + users: Dict[str, UserInfo] = {} # key by user ID to avoid returning duplicates user_ids: List[str] = [] emails: List[str] = [] for value in ids_or_emails: - (user_ids, emails)["@" in value].append(value) + if "@" in value: + emails.append(value) + elif is_user_id(value): + user_ids.append(value) + else: + raise ValueError(f"unrecognized user ID or email: {value!r}") - users = [] for user_id in user_ids: response = self.api.request("GET", f"{self.url}/users/{user_id}") - users.append(UserInfo.parse_obj(response)) + info = UserInfo.parse_obj(response) + users[info.id] = info + if emails: - response = self.api.request( - "GET", f"{self.url}/users", params={"email": emails} + params = {"email": emails} + response = self.api.request("GET", f"{self.url}/users", params=params) + users.update( + { + info.id: info + for user_obj in response["users"] + if (info := UserInfo.parse_obj(user_obj)) + } ) - users += [UserInfo.parse_obj(user_obj) for user_obj in response["users"]] - return users + return list(users.values()) # These are at the bottom of the module to avoid circular imports diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 66fd86b4..5eac3209 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -607,9 +607,7 @@ def add_comment( def schema(self, *, force: bool = False) -> TableSchema: """ - Retrieves the schema of the current table. The return value - will be cached on the :class:`~pyairtable.Base` instance; - to refresh the cache, call `table.base.schema(force=True) `. + Retrieves the schema of the current table. Usage: >>> table.schema() diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py index f0b208cd..b231eac5 100644 --- a/pyairtable/metadata.py +++ b/pyairtable/metadata.py @@ -7,7 +7,9 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: # pragma: no cover """ Return list of Bases from an Api or Base instance. - For More Details `Metadata Api Documentation `_ + + This function has been deprecated. Use + :meth:`Api.bases() ` instead. Args: api: :class:`Api` or :class:`Base` instance @@ -30,7 +32,7 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: # pragma: no cover } """ warnings.warn( - "get_api_bases is deprecated; use Api().bases() instead.", + "get_api_bases is deprecated; use Api.bases() instead.", category=DeprecationWarning, stacklevel=2, ) @@ -48,7 +50,9 @@ def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: # pragma: no cover def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: # pragma: no cover """ Returns Schema of a Base - For More Details `Metadata Api Documentation `_ + + This function has been deprecated. Use + :meth:`Base.schema() ` instead. Args: base: :class:`Base` or :class:`Table` instance @@ -90,7 +94,7 @@ def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: # pragma: no c } """ warnings.warn( - "get_base_schema is deprecated; use Base().schema() instead.", + "get_base_schema is deprecated; use Base.schema() instead.", category=DeprecationWarning, stacklevel=2, ) @@ -104,6 +108,9 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no co """ Returns the specific table schema record provided by base schema list + This function has been deprecated. Use + :meth:`Table.schema() ` instead. + Args: table: :class:`Table` instance @@ -130,7 +137,7 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no co } """ warnings.warn( - "get_table_schema is deprecated; use Table().schema() instead.", + "get_table_schema is deprecated; use Table.schema() instead.", category=DeprecationWarning, stacklevel=2, ) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 07659f94..dc2ba887 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -213,7 +213,7 @@ class InviteLinks(AirtableModel): workspace_invite_links: List["InviteLink"] -class _NestedId(AirtableModel): +class NestedId(AirtableModel): id: str @@ -225,8 +225,8 @@ class UserInfo(AirtableModel): created_time: Optional[str] invited_to_airtable_by_user_id: Optional[str] last_activity_time: Optional[str] - is_managed_user: bool - groups: List[_NestedId] = pydantic.Field(default_factory=list) + is_managed: bool + groups: List[NestedId] = pydantic.Field(default_factory=list) class GroupInfo(AirtableModel): @@ -255,221 +255,364 @@ class GroupMember(AirtableModel): class AutoNumberFieldConfig(AirtableModel): + """ + Field configuration for `Auto number `__. + """ + type: Literal["autoNumber"] class BarcodeFieldConfig(AirtableModel): + """ + Field configuration for `Barcode `__. + """ + type: Literal["barcode"] class ButtonFieldConfig(AirtableModel): + """ + Field configuration for `Button `__. + """ + type: Literal["button"] class CheckboxFieldConfig(AirtableModel): + """ + Field configuration for `Checkbox `__. + """ + type: Literal["checkbox"] - options: Optional["CheckboxFieldConfig.Options"] + options: Optional["CheckboxFieldOptions"] - class Options(AirtableModel): - color: str - icon: str + +class CheckboxFieldOptions(AirtableModel): + color: str + icon: str class CountFieldConfig(AirtableModel): + """ + Field configuration for `Count `__. + """ + type: Literal["count"] - options: Optional["CountFieldConfig.Options"] + options: Optional["CountFieldOptions"] - class Options(AirtableModel): - is_valid: bool - record_link_field_id: Optional[str] + +class CountFieldOptions(AirtableModel): + is_valid: bool + record_link_field_id: Optional[str] class CreatedByFieldConfig(AirtableModel): + """ + Field configuration for `Created by `__. + """ + type: Literal["createdBy"] class CreatedTimeFieldConfig(AirtableModel): + """ + Field configuration for `Created time `__. + """ + type: Literal["createdTime"] class CurrencyFieldConfig(AirtableModel): + """ + Field configuration for `Currency `__. + """ + type: Literal["currency"] - options: "CurrencyFieldConfig.Options" + options: "CurrencyFieldOptions" - class Options(AirtableModel): - precision: int - symbol: str + +class CurrencyFieldOptions(AirtableModel): + precision: int + symbol: str class DateFieldConfig(AirtableModel): + """ + Field configuration for `Date `__. + """ + type: Literal["date"] - options: "DateFieldConfig.Options" + options: "DateFieldOptions" - class Options(AirtableModel): - date_format: "DateTimeFieldConfig.Options.DateFormat" + +class DateFieldOptions(AirtableModel): + date_format: "DateTimeFieldOptions.DateFormat" class DateTimeFieldConfig(AirtableModel): + """ + Field configuration for `Date and time `__. + """ + type: Literal["dateTime"] - options: "DateTimeFieldConfig.Options" + options: "DateTimeFieldOptions" - class Options(AirtableModel): - time_zone: str - date_format: "DateTimeFieldConfig.Options.DateFormat" - time_format: "DateTimeFieldConfig.Options.TimeFormat" - class DateFormat(AirtableModel): - format: str - name: str +class DateTimeFieldOptions(AirtableModel): + time_zone: str + date_format: "DateTimeFieldOptions.DateFormat" + time_format: "DateTimeFieldOptions.TimeFormat" + + class DateFormat(AirtableModel): + format: str + name: str - class TimeFormat(AirtableModel): - format: str - name: str + class TimeFormat(AirtableModel): + format: str + name: str class DurationFieldConfig(AirtableModel): + """ + Field configuration for `Duration `__. + """ + type: Literal["duration"] - options: Optional["DurationFieldConfig.Options"] + options: Optional["DurationFieldOptions"] - class Options(AirtableModel): - duration_format: str + +class DurationFieldOptions(AirtableModel): + duration_format: str class EmailFieldConfig(AirtableModel): + """ + Field configuration for `Email `__. + """ + type: Literal["email"] class ExternalSyncSourceFieldConfig(AirtableModel): + """ + Field configuration for `Sync source `__. + """ + type: Literal["externalSyncSource"] - options: Optional["SingleSelectFieldConfig.Options"] + options: Optional["SingleSelectFieldOptions"] class FormulaFieldConfig(AirtableModel): + """ + Field configuration for `Formula `__. + """ + type: Literal["formula"] - options: Optional["FormulaFieldConfig.Options"] + options: Optional["FormulaFieldOptions"] - class Options(AirtableModel): - formula: str - is_valid: bool - referenced_field_ids: Optional[List[str]] - result: Optional["FieldConfig"] + +class FormulaFieldOptions(AirtableModel): + formula: str + is_valid: bool + referenced_field_ids: Optional[List[str]] + result: Optional["FieldConfig"] class LastModifiedByFieldConfig(AirtableModel): + """ + Field configuration for `Last modified by `__. + """ + type: Literal["lastModifiedBy"] class LastModifiedTimeFieldConfig(AirtableModel): + """ + Field configuration for `Last modified time `__. + """ + type: Literal["lastModifiedTime"] - options: Optional["LastModifiedTimeFieldConfig.Options"] + options: Optional["LastModifiedTimeFieldOptions"] + - class Options(AirtableModel): - is_valid: bool - referenced_field_ids: Optional[List[str]] - result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] +class LastModifiedTimeFieldOptions(AirtableModel): + is_valid: bool + referenced_field_ids: Optional[List[str]] + result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] class MultilineTextFieldConfig(AirtableModel): + """ + Field configuration for `Long text `__. + """ + type: Literal["multilineText"] class MultipleAttachmentsFieldConfig(AirtableModel): + """ + Field configuration for `Attachments `__. + """ + type: Literal["multipleAttachments"] - options: Optional["MultipleAttachmentsFieldConfig.Options"] + options: Optional["MultipleAttachmentsFieldOptions"] + + +class MultipleAttachmentsFieldOptions(AirtableModel): + """ + Field configuration for `Attachments `__. + """ - class Options(AirtableModel): - is_reversed: bool + is_reversed: bool class MultipleCollaboratorsFieldConfig(AirtableModel): + """ + Field configuration for `Multiple Collaborators `__. + """ + type: Literal["multipleCollaborators"] class MultipleLookupValuesFieldConfig(AirtableModel): + """ + Field configuration for `Lookup __`. + """ + type: Literal["multipleLookupValues"] - options: Optional["MultipleLookupValuesFieldConfig.Options"] + options: Optional["MultipleLookupValuesFieldOptions"] + - class Options(AirtableModel): - field_id_in_linked_table: Optional[str] - is_valid: bool - record_link_field_id: Optional[str] - result: Optional["FieldConfig"] +class MultipleLookupValuesFieldOptions(AirtableModel): + field_id_in_linked_table: Optional[str] + is_valid: bool + record_link_field_id: Optional[str] + result: Optional["FieldConfig"] class MultipleRecordLinksFieldConfig(AirtableModel): + """ + Field configuration for `Link to another record __`. + """ + type: Literal["multipleRecordLinks"] - options: Optional["MultipleRecordLinksFieldConfig.Options"] + options: Optional["MultipleRecordLinksFieldOptions"] + - class Options(AirtableModel): - is_reversed: bool - linked_table_id: str - prefers_single_record_link: bool - inverse_link_field_id: Optional[str] - view_id_for_record_selection: Optional[str] +class MultipleRecordLinksFieldOptions(AirtableModel): + is_reversed: bool + linked_table_id: str + prefers_single_record_link: bool + inverse_link_field_id: Optional[str] + view_id_for_record_selection: Optional[str] class MultipleSelectsFieldConfig(AirtableModel): + """ + Field configuration for `Multiple select `__. + """ + type: Literal["multipleSelects"] - options: Optional["SingleSelectFieldConfig.Options"] + options: Optional["SingleSelectFieldOptions"] class NumberFieldConfig(AirtableModel): + """ + Field configuration for `Number `__. + """ + type: Literal["number"] - options: Optional["NumberFieldConfig.Options"] + options: Optional["NumberFieldOptions"] - class Options(AirtableModel): - precision: int + +class NumberFieldOptions(AirtableModel): + precision: int class PercentFieldConfig(AirtableModel): + """ + Field configuration for `Percent `__. + """ + type: Literal["percent"] - options: Optional["NumberFieldConfig.Options"] + options: Optional["NumberFieldOptions"] class PhoneNumberFieldConfig(AirtableModel): + """ + Field configuration for `Phone `__. + """ + type: Literal["phoneNumber"] class RatingFieldConfig(AirtableModel): + """ + Field configuration for `Rating `__. + """ + type: Literal["rating"] - options: Optional["RatingFieldConfig.Options"] + options: Optional["RatingFieldOptions"] - class Options(AirtableModel): - color: str - icon: str - max: int + +class RatingFieldOptions(AirtableModel): + color: str + icon: str + max: int class RichTextFieldConfig(AirtableModel): + """ + Field configuration for `Rich text `__. + """ + type: Literal["richText"] class RollupFieldConfig(AirtableModel): + """ + Field configuration for `Rollup __`. + """ + type: Literal["rollup"] - options: Optional["RollupFieldConfig.Options"] + options: Optional["RollupFieldOptions"] + - class Options(AirtableModel): - field_id_in_linked_table: Optional[str] - is_valid: bool - record_link_field_id: Optional[str] - referenced_field_ids: Optional[List[str]] - result: Optional["FieldConfig"] +class RollupFieldOptions(AirtableModel): + field_id_in_linked_table: Optional[str] + is_valid: bool + record_link_field_id: Optional[str] + referenced_field_ids: Optional[List[str]] + result: Optional["FieldConfig"] class SingleCollaboratorFieldConfig(AirtableModel): + """ + Field configuration for `Collaborator `__. + """ + type: Literal["singleCollaborator"] class SingleLineTextFieldConfig(AirtableModel): + """ + Field configuration for `Single line text `__. + """ + type: Literal["singleLineText"] class SingleSelectFieldConfig(AirtableModel): + """ + Field configuration for `Single select `__. + """ + type: Literal["singleSelect"] - options: Optional["SingleSelectFieldConfig.Options"] + options: Optional["SingleSelectFieldOptions"] + - class Options(AirtableModel): - choices: List["SingleSelectFieldConfig.Choice"] +class SingleSelectFieldOptions(AirtableModel): + choices: List["SingleSelectFieldOptions.Choice"] class Choice(AirtableModel): id: str @@ -478,17 +621,21 @@ class Choice(AirtableModel): class UrlFieldConfig(AirtableModel): + """ + Field configuration for `Url `__. + """ + type: Literal["url"] class UnknownFieldConfig(AirtableModel): """ - Fallback field configuration class so that the library doesn't crash - with a ValidationError if Airtable adds new types of fields in the future. + Field configuration class used as a fallback for unrecognized types. + This ensures we don't raise pydantic.ValidationError if Airtable adds new types. """ type: str - options: Optional[Dict[Any, Any]] + options: Optional[Dict[str, Any]] class _FieldSchemaBase(AirtableModel): @@ -504,24 +651,37 @@ class _FieldSchemaBase(AirtableModel): import re with open(cog.inFile) as fp: - field_types = re.findall(r"class (\w+Field)Config\(", fp.read()) + field_types = re.findall( + r"class (\w+Field)Config\(.*?\):(?:\n \"{3}(.*?)\"{3})?", + fp.read(), + re.MULTILINE + re.DOTALL + ) + +cog.out("\n\n") cog.outl("FieldConfig: TypeAlias = Union[") -for fld in field_types: +for fld, _ in field_types: cog.outl(f" {fld}Config,") cog.outl("]") cog.out("\n\n") -for fld in field_types: - cog.outl(f"class {fld}Schema(_FieldSchemaBase, {fld}Config): pass # noqa") -cog.out("\n\n") +for fld, doc in field_types: + cog.out(f"class {fld}Schema(_FieldSchemaBase, {fld}Config):\n ") + if doc: + doc = doc.replace('ield configuration', 'ield schema') + cog.outl("\"\"\"" + doc + "\"\"\"") + else: + cog.outl("pass") + cog.out("\n\n") cog.outl("FieldSchema: TypeAlias = Union[") -for fld in field_types: +for fld, _ in field_types: cog.outl(f" {fld}Schema,") cog.outl("]") [[[out]]]""" + + FieldConfig: TypeAlias = Union[ AutoNumberFieldConfig, BarcodeFieldConfig, @@ -559,39 +719,203 @@ class _FieldSchemaBase(AirtableModel): ] -class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig): pass # noqa -class BarcodeFieldSchema(_FieldSchemaBase, BarcodeFieldConfig): pass # noqa -class ButtonFieldSchema(_FieldSchemaBase, ButtonFieldConfig): pass # noqa -class CheckboxFieldSchema(_FieldSchemaBase, CheckboxFieldConfig): pass # noqa -class CountFieldSchema(_FieldSchemaBase, CountFieldConfig): pass # noqa -class CreatedByFieldSchema(_FieldSchemaBase, CreatedByFieldConfig): pass # noqa -class CreatedTimeFieldSchema(_FieldSchemaBase, CreatedTimeFieldConfig): pass # noqa -class CurrencyFieldSchema(_FieldSchemaBase, CurrencyFieldConfig): pass # noqa -class DateFieldSchema(_FieldSchemaBase, DateFieldConfig): pass # noqa -class DateTimeFieldSchema(_FieldSchemaBase, DateTimeFieldConfig): pass # noqa -class DurationFieldSchema(_FieldSchemaBase, DurationFieldConfig): pass # noqa -class EmailFieldSchema(_FieldSchemaBase, EmailFieldConfig): pass # noqa -class ExternalSyncSourceFieldSchema(_FieldSchemaBase, ExternalSyncSourceFieldConfig): pass # noqa -class FormulaFieldSchema(_FieldSchemaBase, FormulaFieldConfig): pass # noqa -class LastModifiedByFieldSchema(_FieldSchemaBase, LastModifiedByFieldConfig): pass # noqa -class LastModifiedTimeFieldSchema(_FieldSchemaBase, LastModifiedTimeFieldConfig): pass # noqa -class MultilineTextFieldSchema(_FieldSchemaBase, MultilineTextFieldConfig): pass # noqa -class MultipleAttachmentsFieldSchema(_FieldSchemaBase, MultipleAttachmentsFieldConfig): pass # noqa -class MultipleCollaboratorsFieldSchema(_FieldSchemaBase, MultipleCollaboratorsFieldConfig): pass # noqa -class MultipleLookupValuesFieldSchema(_FieldSchemaBase, MultipleLookupValuesFieldConfig): pass # noqa -class MultipleRecordLinksFieldSchema(_FieldSchemaBase, MultipleRecordLinksFieldConfig): pass # noqa -class MultipleSelectsFieldSchema(_FieldSchemaBase, MultipleSelectsFieldConfig): pass # noqa -class NumberFieldSchema(_FieldSchemaBase, NumberFieldConfig): pass # noqa -class PercentFieldSchema(_FieldSchemaBase, PercentFieldConfig): pass # noqa -class PhoneNumberFieldSchema(_FieldSchemaBase, PhoneNumberFieldConfig): pass # noqa -class RatingFieldSchema(_FieldSchemaBase, RatingFieldConfig): pass # noqa -class RichTextFieldSchema(_FieldSchemaBase, RichTextFieldConfig): pass # noqa -class RollupFieldSchema(_FieldSchemaBase, RollupFieldConfig): pass # noqa -class SingleCollaboratorFieldSchema(_FieldSchemaBase, SingleCollaboratorFieldConfig): pass # noqa -class SingleLineTextFieldSchema(_FieldSchemaBase, SingleLineTextFieldConfig): pass # noqa -class SingleSelectFieldSchema(_FieldSchemaBase, SingleSelectFieldConfig): pass # noqa -class UrlFieldSchema(_FieldSchemaBase, UrlFieldConfig): pass # noqa -class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): pass # noqa +class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig): + """ + Field schema for `Auto number `__. + """ + + +class BarcodeFieldSchema(_FieldSchemaBase, BarcodeFieldConfig): + """ + Field schema for `Barcode `__. + """ + + +class ButtonFieldSchema(_FieldSchemaBase, ButtonFieldConfig): + """ + Field schema for `Button `__. + """ + + +class CheckboxFieldSchema(_FieldSchemaBase, CheckboxFieldConfig): + """ + Field schema for `Checkbox `__. + """ + + +class CountFieldSchema(_FieldSchemaBase, CountFieldConfig): + """ + Field schema for `Count `__. + """ + + +class CreatedByFieldSchema(_FieldSchemaBase, CreatedByFieldConfig): + """ + Field schema for `Created by `__. + """ + + +class CreatedTimeFieldSchema(_FieldSchemaBase, CreatedTimeFieldConfig): + """ + Field schema for `Created time `__. + """ + + +class CurrencyFieldSchema(_FieldSchemaBase, CurrencyFieldConfig): + """ + Field schema for `Currency `__. + """ + + +class DateFieldSchema(_FieldSchemaBase, DateFieldConfig): + """ + Field schema for `Date `__. + """ + + +class DateTimeFieldSchema(_FieldSchemaBase, DateTimeFieldConfig): + """ + Field schema for `Date and time `__. + """ + + +class DurationFieldSchema(_FieldSchemaBase, DurationFieldConfig): + """ + Field schema for `Duration `__. + """ + + +class EmailFieldSchema(_FieldSchemaBase, EmailFieldConfig): + """ + Field schema for `Email `__. + """ + + +class ExternalSyncSourceFieldSchema(_FieldSchemaBase, ExternalSyncSourceFieldConfig): + """ + Field schema for `Sync source `__. + """ + + +class FormulaFieldSchema(_FieldSchemaBase, FormulaFieldConfig): + """ + Field schema for `Formula `__. + """ + + +class LastModifiedByFieldSchema(_FieldSchemaBase, LastModifiedByFieldConfig): + """ + Field schema for `Last modified by `__. + """ + + +class LastModifiedTimeFieldSchema(_FieldSchemaBase, LastModifiedTimeFieldConfig): + """ + Field schema for `Last modified time `__. + """ + + +class MultilineTextFieldSchema(_FieldSchemaBase, MultilineTextFieldConfig): + """ + Field schema for `Long text `__. + """ + + +class MultipleAttachmentsFieldSchema(_FieldSchemaBase, MultipleAttachmentsFieldConfig): + """ + Field schema for `Attachments `__. + """ + + +class MultipleCollaboratorsFieldSchema(_FieldSchemaBase, MultipleCollaboratorsFieldConfig): + """ + Field schema for `Multiple Collaborators `__. + """ + + +class MultipleLookupValuesFieldSchema(_FieldSchemaBase, MultipleLookupValuesFieldConfig): + """ + Field schema for `Lookup __`. + """ + + +class MultipleRecordLinksFieldSchema(_FieldSchemaBase, MultipleRecordLinksFieldConfig): + """ + Field schema for `Link to another record __`. + """ + + +class MultipleSelectsFieldSchema(_FieldSchemaBase, MultipleSelectsFieldConfig): + """ + Field schema for `Multiple select `__. + """ + + +class NumberFieldSchema(_FieldSchemaBase, NumberFieldConfig): + """ + Field schema for `Number `__. + """ + + +class PercentFieldSchema(_FieldSchemaBase, PercentFieldConfig): + """ + Field schema for `Percent `__. + """ + + +class PhoneNumberFieldSchema(_FieldSchemaBase, PhoneNumberFieldConfig): + """ + Field schema for `Phone `__. + """ + + +class RatingFieldSchema(_FieldSchemaBase, RatingFieldConfig): + """ + Field schema for `Rating `__. + """ + + +class RichTextFieldSchema(_FieldSchemaBase, RichTextFieldConfig): + """ + Field schema for `Rich text `__. + """ + + +class RollupFieldSchema(_FieldSchemaBase, RollupFieldConfig): + """ + Field schema for `Rollup __`. + """ + + +class SingleCollaboratorFieldSchema(_FieldSchemaBase, SingleCollaboratorFieldConfig): + """ + Field schema for `Collaborator `__. + """ + + +class SingleLineTextFieldSchema(_FieldSchemaBase, SingleLineTextFieldConfig): + """ + Field schema for `Single line text `__. + """ + + +class SingleSelectFieldSchema(_FieldSchemaBase, SingleSelectFieldConfig): + """ + Field schema for `Single select `__. + """ + + +class UrlFieldSchema(_FieldSchemaBase, UrlFieldConfig): + """ + Field schema for `Url `__. + """ + + +class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): + """ + Field schema class used as a fallback for unrecognized types. + This ensures we don't raise pydantic.ValidationError if Airtable adds new types. + """ FieldSchema: TypeAlias = Union[ @@ -629,18 +953,18 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): pass # noqa UrlFieldSchema, UnknownFieldSchema, ] -# [[[end]]] (checksum: 3f8dd40da7b03b16f7299cd99365692c) +# [[[end]]] (checksum: 656f8c8bca467689a3f404ec9f1058fe) # fmt: on # Shortcut to allow parsing unions, which is not possible otherwise in Pydantic v1. # See https://github.com/pydantic/pydantic/discussions/4950 -class HasFieldSchema(AirtableModel): +class _HasFieldSchema(AirtableModel): field_schema: FieldSchema def parse_field_schema(obj: Any) -> FieldSchema: - return HasFieldSchema.parse_obj({"field_schema": obj}).field_schema + return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema update_forward_refs(vars()) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index d5208890..2a4a1622 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -123,6 +123,7 @@ def is_airtable_id(value: Any, prefix: str = "") -> bool: is_base_id = partial(is_airtable_id, prefix="app") is_table_id = partial(is_airtable_id, prefix="tbl") is_field_id = partial(is_airtable_id, prefix="fld") +is_user_id = partial(is_airtable_id, prefix="usr") F = TypeVar("F", bound=Callable[..., Any]) diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py index d8573d86..0700172d 100644 --- a/tests/integration/test_integration_metadata.py +++ b/tests/integration/test_integration_metadata.py @@ -18,10 +18,10 @@ def test_api_base(api: Api, base_id: str, base_name: str): assert base.name == base_name -def test_base_collaborators(base: Base): +def test_base_info(base: Base): with pytest.raises( requests.HTTPError, - match=r"collaborators\(\) requires an enterprise billing plan", + match=r"Base.info\(\) requires an enterprise billing plan", ): base.info() diff --git a/tests/sample_data/User.json b/tests/sample_data/User.json new file mode 100644 index 00000000..d23ce89b --- /dev/null +++ b/tests/sample_data/User.json @@ -0,0 +1,45 @@ +{ + "collaborations": { + "baseCollaborations": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrqccqnMB2eHylqB", + "permissionLevel": "edit" + } + ], + "interfaceCollaborations": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrqccqnMB2eHylqB", + "interfaceId": "pbdyGA3PsOziEHPDE", + "permissionLevel": "edit" + } + ], + "workspaceCollaborations": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrGcrteE5fUMqq0R", + "permissionLevel": "owner", + "workspaceId": "wspmhESAta6clCCwF" + } + ] + }, + "createdTime": "2019-01-03T12:33:12.421Z", + "email": "foo@bar.com", + "groups": [ + { + "id": "ugp1mKGb3KXUyQfOZ" + }, + { + "id": "ugpR8ZT9KtIgp8Bh3" + } + ], + "id": "usrL2PNC5o3H4lBEi", + "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk", + "isManaged": true, + "lastActivityTime": "2019-01-03T12:33:12.421Z", + "name": "Jane Doe", + "state": "provisioned" +} diff --git a/tests/sample_data/UserGroup.json b/tests/sample_data/UserGroup.json new file mode 100644 index 00000000..53518009 --- /dev/null +++ b/tests/sample_data/UserGroup.json @@ -0,0 +1,60 @@ +{ + "collaborations": { + "baseCollaborations": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2021-06-02T07:37:50.000Z", + "grantedByUserId": "usrogvSbotRtzdtZW", + "permissionLevel": "create" + } + ], + "interfaceCollaborations": [ + { + "baseId": "appLkNDICXNqxSDhG", + "createdTime": "2019-01-03T12:33:12.421Z", + "grantedByUserId": "usrqccqnMB2eHylqB", + "interfaceId": "pbdyGA3PsOziEHPDE", + "permissionLevel": "edit" + } + ], + "workspaceCollaborations": [ + { + "createdTime": "2021-06-02T07:37:48.000Z", + "grantedByUserId": "usrqccqnMB2eHylqB", + "permissionLevel": "edit", + "workspaceId": "wspmhESAta6clCCwF" + } + ] + }, + "createdTime": "2021-06-02T07:37:19.000Z", + "enterpriseAccountId": "entUBq2RGdihxl3vU", + "id": "ugp1mKGb3KXUyQfOZ", + "members": [ + { + "createdTime": "2021-06-02T07:37:19.000Z", + "email": "foo@bar.com", + "firstName": "Jane", + "lastName": "Doe", + "role": "member", + "userId": "usrL2PNC5o3H4lBEi" + }, + { + "createdTime": "2021-06-02T07:37:19.000Z", + "email": "foo@bam.com", + "firstName": "Alex", + "lastName": "Hay", + "role": "manager", + "userId": "usrsOEchC9xuwRgKk" + }, + { + "createdTime": "2021-06-02T07:37:19.000Z", + "email": "bam@bam.com", + "firstName": "John", + "lastName": "Dane", + "role": "member", + "userId": "usrGcrteE5fUMqq0R" + } + ], + "name": "Group name", + "updatedTime": "2022-09-02T10:10:35:000Z" +} diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 36c18693..c99887eb 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -1,6 +1,9 @@ +from unittest.mock import Mock + import pytest from pyairtable.api.enterprise import Enterprise +from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo @pytest.fixture @@ -8,12 +11,74 @@ def enterprise(api): return Enterprise(api, "entUBq2RGdihxl3vU") -def test_info(enterprise, requests_mock, sample_json): - m = requests_mock.get(enterprise.url, json=sample_json("Enterprise")) - assert enterprise.info().id == "entUBq2RGdihxl3vU" - assert enterprise.info().workspace_ids == ["wspmhESAta6clCCwF", "wspHvvm4dAktsStZH"] - assert enterprise.info().email_domains[0].is_sso_required is True - assert m.call_count == 1 +@pytest.fixture +def enterprise_mocks(enterprise, requests_mock, sample_json): + user_json = sample_json("User") + group_json = sample_json("UserGroup") + m = Mock() + m.user_id = user_json["id"] + m.get_info = requests_mock.get( + enterprise.url, + json=sample_json("Enterprise"), + ) + m.get_user = requests_mock.get( + f"{enterprise.url}/users/{m.user_id}", + json=user_json, + ) + m.get_users = requests_mock.get( + f"{enterprise.url}/users", + json={"users": [user_json]}, + ) + m.get_group = requests_mock.get( + enterprise.api.build_url(f"meta/groups/{group_json['id']}"), + json=group_json, + ) + return m + + +def test_info(enterprise, enterprise_mocks): + assert isinstance(info := enterprise.info(), EnterpriseInfo) + assert info.id == "entUBq2RGdihxl3vU" + assert info.workspace_ids == ["wspmhESAta6clCCwF", "wspHvvm4dAktsStZH"] + assert info.email_domains[0].is_sso_required is True + assert enterprise_mocks.get_info.call_count == 1 assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU" - assert m.call_count == 2 + assert enterprise_mocks.get_info.call_count == 2 + + +def test_user(enterprise, enterprise_mocks): + user = enterprise.user(enterprise_mocks.user_id) + assert isinstance(user, UserInfo) + assert user.name == "Jane Doe" + assert enterprise_mocks.get_user.call_count == 1 + + +@pytest.mark.parametrize( + "search_for", + ( + ["usrL2PNC5o3H4lBEi"], + ["foo@bar.com"], + ["usrL2PNC5o3H4lBEi", "foo@bar.com"], # should not return duplicates + ), +) +def test_users(enterprise, enterprise_mocks, search_for): + results = enterprise.users(search_for) + assert len(results) == 1 + assert isinstance(user := results[0], UserInfo) + assert user.id == "usrL2PNC5o3H4lBEi" + assert user.state == "provisioned" + + +def test_users__invalid_value(enterprise, enterprise_mocks): + with pytest.raises(ValueError): + enterprise.users(["not an ID or email"]) + + +def test_group(enterprise, enterprise_mocks): + info = enterprise.group("ugp1mKGb3KXUyQfOZ") + assert enterprise_mocks.get_group.call_count == 1 + assert isinstance(info, GroupInfo) + assert info.id == "ugp1mKGb3KXUyQfOZ" + assert info.name == "Group name" + assert info.members[0].email == "foo@bar.com" diff --git a/tests/test_api_table.py b/tests/test_api_table.py index fba17c78..dbda85e9 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -72,10 +72,30 @@ def test_repr(table: Table): def test_schema(base, requests_mock, sample_json): - schema_json = sample_json("BaseSchema") + """ + Test that we can load schema from API. + """ + table = base.table("Apartments") + m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + assert isinstance(schema := table.schema(), TableSchema) + assert m.call_count == 1 + assert schema.id == "tbltp8DGLhqbUmjK1" + + +def test_id(base, requests_mock, sample_json): + """ + Test that we load schema from API if we need the ID and don't have it, + but if we get a name that *looks* like an ID, we trust it. + """ + m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + + table = base.table("tbltp8DGLhqbUmjK1") + assert table.id == "tbltp8DGLhqbUmjK1" + assert m.call_count == 0 + table = base.table("Apartments") - requests_mock.get(base.meta_url("tables"), json=schema_json) - assert table.schema().id == "tbltp8DGLhqbUmjK1" + assert table.id == "tbltp8DGLhqbUmjK1" + assert m.call_count == 1 @pytest.mark.parametrize( @@ -370,13 +390,19 @@ def test_create_field(table, requests_mock, sample_json): field_schema = sample_json("field_schema/SingleSelectFieldSchema") choices = ["Todo", "In progress", "Done"] m = requests_mock.post(table.meta_url("fields"), json=field_schema) - f = table.create_field("Status", "singleSelect", options={"choices": choices}) + f = table.create_field( + "Status", + "singleSelect", + description="field description", + options={"choices": choices}, + ) assert f.id == "fldqCjrs1UhXgHUIc" assert {c.name for c in f.options.choices} == set(choices) assert m.call_count == 1 assert m.request_history[-1].json() == { "name": "Status", "type": "singleSelect", + "description": "field description", "options": {"choices": choices}, } diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 94d8268f..12ad2cc8 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -28,6 +28,8 @@ def test_parse_field(sample_json, cls): @pytest.mark.parametrize( "clsname,method,id_or_name", [ + ("Bases", "base", "appLkNDICXNqxSDhG"), + ("Bases", "base", "Apartment Hunting"), ("BaseSchema", "table", "tbltp8DGLhqbUmjK1"), ("BaseSchema", "table", "Apartments"), ("TableSchema", "field", "fld1VnoyuotSTyxW1"), From 18d5b0ba820dfc57b415f890bfc754c320ac5785 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 28 Sep 2023 20:59:29 -0700 Subject: [PATCH 012/272] Field schema for aiText --- pyairtable/models/schema.py | 31 +++++++++- pyairtable/orm/fields.py | 57 +++++++++++++++++++ tests/integration/test_integration_orm.py | 2 +- .../field_schema/AITextFieldSchema.json | 13 +++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 tests/sample_data/field_schema/AITextFieldSchema.json diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index dc2ba887..40a07b7f 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -217,6 +217,10 @@ class NestedId(AirtableModel): id: str +class NestedFieldId(AirtableModel): + field_id: str + + class UserInfo(AirtableModel): id: str name: str @@ -254,6 +258,23 @@ class GroupMember(AirtableModel): # FieldSchema is a union of all available *FieldSchema classes. +class AITextFieldConfig(AirtableModel): + """ + Field configuration for `AI text `__. + """ + + type: Literal["aiText"] + options: "AITextFieldOptions" + + +class AITextFieldOptions(AirtableModel): + prompt: Optional[List[Union[str, "AITextFieldOptions.PromptField"]]] + referenced_field_ids: Optional[List[str]] + + class PromptField(AirtableModel): + field: NestedFieldId + + class AutoNumberFieldConfig(AirtableModel): """ Field configuration for `Auto number `__. @@ -683,6 +704,7 @@ class _FieldSchemaBase(AirtableModel): FieldConfig: TypeAlias = Union[ + AITextFieldConfig, AutoNumberFieldConfig, BarcodeFieldConfig, ButtonFieldConfig, @@ -719,6 +741,12 @@ class _FieldSchemaBase(AirtableModel): ] +class AITextFieldSchema(_FieldSchemaBase, AITextFieldConfig): + """ + Field schema for `AI text `__. + """ + + class AutoNumberFieldSchema(_FieldSchemaBase, AutoNumberFieldConfig): """ Field schema for `Auto number `__. @@ -919,6 +947,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): FieldSchema: TypeAlias = Union[ + AITextFieldSchema, AutoNumberFieldSchema, BarcodeFieldSchema, ButtonFieldSchema, @@ -953,7 +982,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): UrlFieldSchema, UnknownFieldSchema, ] -# [[[end]]] (checksum: 656f8c8bca467689a3f404ec9f1058fe) +# [[[end]]] (checksum: afb669896323650954a082cb4b079c16) # fmt: on diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index b63e710f..afdc13a9 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -881,3 +881,60 @@ class UrlField(TextField): cls: {key for (key, val) in FIELD_TYPES_TO_CLASSES.items() if val == cls} for cls in ALL_FIELDS } + + +# Auto-generate __all__ to explicitly exclude any imported values +r"""[[[cog]]] +import re + +with open(cog.inFile) as fp: + src = fp.read() + +classes = re.findall(r"class ([A-Z]\w+Field)", src) +constants = re.findall(r"^([A-Z][A-Z_+]) = ", src) +extras = ["LinkSelf"] +names = sorted(classes + constants + extras) + +cog.outl("\n\n__all__ = [") +for name in names: + cog.outl(f' "{name}",') +cog.outl("]") +[[[out]]]""" + + +__all__ = [ + "AITextField", + "AttachmentsField", + "AutoNumberField", + "BarcodeField", + "ButtonField", + "CheckboxField", + "CollaboratorField", + "CountField", + "CreatedByField", + "CreatedTimeField", + "CurrencyField", + "DateField", + "DatetimeField", + "DurationField", + "EmailField", + "ExternalSyncSourceField", + "FloatField", + "IntegerField", + "LastModifiedByField", + "LastModifiedTimeField", + "LinkField", + "LinkSelf", + "LookupField", + "MultipleCollaboratorsField", + "MultipleSelectField", + "NumberField", + "PercentField", + "PhoneNumberField", + "RatingField", + "RichTextField", + "SelectField", + "TextField", + "UrlField", +] +# [[[end]]] (checksum: 4722c0951e598ac999d3c16ebd3d8c1c) diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 1d064ee7..6300f34e 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -180,7 +180,7 @@ def test_every_field(Everything): type(field) for field in vars(Everything).values() if isinstance(field, f.Field) } for field_class in f.ALL_FIELDS: - if field_class in {f.ExternalSyncSourceField}: + if field_class in {f.ExternalSyncSourceField, f.AITextField}: continue assert field_class in classes_used diff --git a/tests/sample_data/field_schema/AITextFieldSchema.json b/tests/sample_data/field_schema/AITextFieldSchema.json new file mode 100644 index 00000000..76285c06 --- /dev/null +++ b/tests/sample_data/field_schema/AITextFieldSchema.json @@ -0,0 +1,13 @@ +{ + "type": "aiText", + "id": "fld8cfZQtRNaiText", + "name": "AI Text", + "options": { + "prompt": [ + { + "field": {"fieldId": "fldEHLmq3SvZCvgOT"} + } + ], + "referencedFieldIds": ["fldEHLmq3SvZCvgOT"] + } +} From 66079022ddabd3a1172209d42daaa6133b595886 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 13 Oct 2023 11:08:39 -0700 Subject: [PATCH 013/272] Base.shares() --- docs/source/metadata.rst | 3 +++ pyairtable/api/base.py | 21 +++++++++++++++- pyairtable/models/schema.py | 17 +++++++++++++ tests/sample_data/BaseShares.json | 41 +++++++++++++++++++++++++++++++ tests/test_api_base.py | 9 ++++++- 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/sample_data/BaseShares.json diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 22a46d2f..f7eb74ed 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -26,6 +26,9 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` .. automethod:: pyairtable.Base.tables :noindex: +.. automethod:: pyairtable.Base.shares + :noindex: + .. automethod:: pyairtable.Table.schema :noindex: diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index fa5fb061..67bf74ae 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -3,7 +3,12 @@ import pyairtable.api.api import pyairtable.api.table -from pyairtable.models.schema import BaseInfo, BaseSchema, PermissionLevel +from pyairtable.models.schema import ( + BaseInfo, + BaseSchema, + BaseShare, + PermissionLevel, +) from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, @@ -30,6 +35,7 @@ class Base: # Cached metadata to reduce API calls _info: Optional[BaseInfo] = None _schema: Optional[BaseSchema] = None + _shares: Optional[List[BaseShare]] = None def __init__( self, @@ -294,3 +300,16 @@ def info(self, *, force: bool = False) -> "BaseInfo": data = self.api.request("GET", self.meta_url(), params=params) self._info = BaseInfo.parse_obj(data) return self._info + + @enterprise_only + def shares(self, *, force: bool = False) -> List[BaseShare]: + """ + Retrieves `base shares `__. + + Args: + force: |kwarg_force_metadata| + """ + if force or not self._shares: + data = self.api.request("GET", self.meta_url("shares")) + self._shares = [BaseShare.parse_obj(share) for share in data["shares"]] + return self._shares diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 40a07b7f..0b249a74 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -84,6 +84,23 @@ class InviteLinks(AirtableModel): workspace_invite_links: List["InviteLink"] = _FL() +class BaseShare(AirtableModel): + """ + See https://airtable.com/developers/web/api/list-shares + """ + + state: str + created_by_user_id: str + created_time: str + share_id: str + type: str + is_password_protected: bool + block_installation_id: Optional[str] = None + restricted_to_email_domains: List[str] = _FL() + view_id: Optional[str] = None + effective_email_domain_allow_list: List[str] = _FL() + + class BaseSchema(AirtableModel): """ See https://airtable.com/developers/web/api/get-base-schema diff --git a/tests/sample_data/BaseShares.json b/tests/sample_data/BaseShares.json new file mode 100644 index 00000000..20b67ebb --- /dev/null +++ b/tests/sample_data/BaseShares.json @@ -0,0 +1,41 @@ +{ + "shares": [ + { + "createdByUserId": "usrL2PNC5o3H4lBEi", + "createdTime": "2019-01-01T00:00:00.000Z", + "effectiveEmailDomainAllowList": [ + "foobar.com" + ], + "isPasswordProtected": true, + "restrictedToEmailDomains": [ + "foobar.com" + ], + "shareId": "shr9SpczJvQpfAzSp", + "shareTokenPrefix": "shr9Spcz", + "state": "enabled", + "type": "base" + }, + { + "createdByUserId": "usrL2PNC5o3H4lBEi", + "createdTime": "2019-01-01T00:00:00.000Z", + "isPasswordProtected": false, + "restrictedToEmailDomains": [], + "shareId": "shrMg5vs9SpczJvQp", + "shareTokenPrefix": "shrMg5vs", + "state": "disabled", + "type": "view", + "viewId": "viwQpsuEDqHFqegkp" + }, + { + "blockInstallationId": "bliXyN0Q6zfajnDOG", + "createdByUserId": "usrL2PNC5o3H4lBEi", + "createdTime": "2019-01-01T00:00:00.000Z", + "isPasswordProtected": false, + "restrictedToEmailDomains": [], + "shareId": "shrjjKdhMg5vs9Spc", + "shareTokenPrefix": "shrjjKdh", + "state": "disabled", + "type": "blockInstallation" + } + ] +} diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 00c2d1e5..e6157470 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -103,13 +103,20 @@ def test_tables(base: Base, requests_mock, sample_json): assert result["tblK6MZHez0ZvBChZ"].name == "Districts" -def test_collaborators(base: Base, requests_mock, sample_json): +def test_info(base: Base, requests_mock, sample_json): requests_mock.get(base.meta_url(), json=sample_json("BaseInfo")) result = base.info() assert result.individual_collaborators.via_base[0].email == "foo@bam.com" assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ" +def test_shares(base: Base, requests_mock, sample_json): + requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) + result = base.shares() + assert result[0].state == "enabled" + assert result[1].effective_email_domain_allow_list == [] + + def test_webhooks(base: Base, requests_mock, sample_json): m = requests_mock.get( base.webhooks_url, From 8d4227a82913cfe95eff29730dc70dd59737577c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 13 Oct 2023 11:09:09 -0700 Subject: [PATCH 014/272] Base.tables() should return list, not dict --- docs/source/_substitutions.rst | 3 ++- docs/source/orm.rst | 2 +- pyairtable/api/base.py | 27 +++++++++++---------------- tests/test_api_base.py | 6 +++--- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index 3505d439..cce1bd9e 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -67,7 +67,8 @@ .. |kwarg_validate_metadata| replace:: If ``False``, will create an object without validating the ID/name provided. - If ``True``, will fetch information from the metadata API and validate the ID/name exists. + If ``True``, will fetch information from the metadata API and validate the ID/name exists, + raising ``KeyError`` if it does not. .. |warn| unicode:: U+26A0 .. WARNING SIGN diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 4edcfb21..86a97ed8 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -173,7 +173,7 @@ read `Field types and cell values `__, `Long text `__ * - :class:`~pyairtable.orm.fields.UrlField` - `Url `__ -.. [[[end]]] (checksum: 4fd61735d7852ef40481dc626d9cfb73) +.. [[[end]]] (checksum: 01c5696293e7571ac8250c4e8a2453e8) Formulas, Rollups, and Lookups diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 67bf74ae..888ea16b 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -3,12 +3,7 @@ import pyairtable.api.api import pyairtable.api.table -from pyairtable.models.schema import ( - BaseInfo, - BaseSchema, - BaseShare, - PermissionLevel, -) +from pyairtable.models.schema import BaseInfo, BaseSchema, BaseShare, PermissionLevel from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, @@ -116,24 +111,24 @@ def table( return pyairtable.api.table.Table(None, self, schema) return pyairtable.api.table.Table(None, self, id_or_name) - def tables(self, *, force: bool = False) -> Dict[str, "pyairtable.api.table.Table"]: + def tables(self, *, force: bool = False) -> List["pyairtable.api.table.Table"]: """ - Retrieves the base's schema and returns a mapping of IDs to :class:`Table` instances. + Retrieves the base's schema and returns a list of :class:`Table` instances. Args: force: |kwarg_force_metadata| Usage: >>> base.tables() - { - 'tbltp8DGLhqbUmjK1':
, - 'tblK6MZHez0ZvBChZ':
- } + [ +
, +
+ ] """ - return { - info.id: pyairtable.api.table.Table(None, self, info) - for info in self.schema(force=force).tables - } + return [ + pyairtable.api.table.Table(None, self, table_schema) + for table_schema in self.schema(force=force).tables + ] def create_table( self, diff --git a/tests/test_api_base.py b/tests/test_api_base.py index e6157470..ab99f93c 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -94,13 +94,13 @@ def test_table_validate(base: Base, requests_mock, sample_json): def test_tables(base: Base, requests_mock, sample_json): """ - Test that Base.tables() returns a dict of validated Base instances. + Test that Base.tables() returns a list of validated Base instances. """ requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) result = base.tables() assert len(result) == 2 - assert result["tbltp8DGLhqbUmjK1"].name == "Apartments" - assert result["tblK6MZHez0ZvBChZ"].name == "Districts" + assert result[0].name == "Apartments" + assert result[1].name == "Districts" def test_info(base: Base, requests_mock, sample_json): From 777cd48a71be1006900a7f2c9108630584b51b00 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 13 Oct 2023 23:18:30 -0700 Subject: [PATCH 015/272] FieldSchema.save(), TableSchema.save() This adds support for saving changes to table and field names/descriptions. This required a significant rethink of the way we construct URLs for `SerializableModel`. It does makea breaking change to the `.from_api()` method, but that method does not appear to be [in use](https://github.com/search?q=pyairtable+from_api&type=code), so I think this is reasonable without a major version bump. --- docs/source/metadata.rst | 13 ++ pyairtable/api/base.py | 8 +- pyairtable/api/table.py | 26 +-- pyairtable/models/_base.py | 150 ++++++++++++++---- pyairtable/models/comment.py | 6 +- pyairtable/models/schema.py | 18 ++- pyairtable/models/webhook.py | 6 +- .../test_integration_enterprise.py | 101 ++++++++++++ tests/test_api_table.py | 1 + tests/test_models.py | 26 ++- tests/test_models_comment.py | 4 +- tests/test_models_webhook.py | 6 +- 12 files changed, 301 insertions(+), 64 deletions(-) create mode 100644 tests/integration/test_integration_enterprise.py diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index f7eb74ed..82c17839 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -53,6 +53,19 @@ call them directly. .. automethod:: pyairtable.Table.create_field :noindex: +To rename a table or field, you can modify its schema object directly +and call ``save()``: + +.. code-block:: python + + >>> schema = table.schema() + >>> schema.name = "Renamed" + >>> schema.save() + >>> field = schema.field("Name") + >>> field.name = "Label" + >>> field.description = "The primary field on the table" + >>> field.save() + Enterprise information ----------------------------- diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 888ea16b..0b8909d4 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -181,7 +181,7 @@ def schema(self, *, force: bool = False) -> BaseSchema: url = self.meta_url("tables") params = {"include": ["visibleFieldIds"]} data = self.api.request("GET", url, params=params) - self._schema = BaseSchema.parse_obj(data) + self._schema = BaseSchema.from_api(data, self.api, context=self) return self._schema @property @@ -211,11 +211,7 @@ def webhooks(self) -> List[Webhook]: """ response = self.api.request("GET", self.webhooks_url) return [ - Webhook.from_api( - api=self.api, - url=f"{self.webhooks_url}/{data['id']}", - obj=data, - ) + Webhook.from_api(data, self.api, context=self) for data in response["webhooks"] ] diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 5eac3209..35e6a378 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -569,9 +569,7 @@ def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]: url = self.record_url(record_id, "comments") return [ pyairtable.models.Comment.from_api( - api=self.api, - url=self.record_url(record_id, "comments", comment["id"]), - obj=comment, + comment, self.api, context={"record_url": self.record_url(record_id)} ) for page in self.api.iterate_requests("GET", url) for comment in page["comments"] @@ -600,9 +598,7 @@ def add_comment( url = self.record_url(record_id, "comments") response = self.api.request("POST", url, json={"text": text}) return pyairtable.models.Comment.from_api( - api=self.api, - url=self.record_url(record_id, "comments", response["id"]), - obj=response, + response, self.api, context={"record_url": self.record_url(record_id)} ) def schema(self, *, force: bool = False) -> TableSchema: @@ -635,7 +631,7 @@ def schema(self, *, force: bool = False) -> TableSchema: def create_field( self, name: str, - field_type: str, + type: str, description: Optional[str] = None, options: Optional[Dict[str, Any]] = None, ) -> FieldSchema: @@ -649,13 +645,25 @@ 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": field_type} + request: Dict[str, Any] = {"name": name, "type": type} if description: request["description"] = description if options: request["options"] = options response = self.api.request("POST", self.meta_url("fields"), json=request) - return parse_field_schema(response) + # This hopscotch ensures that the FieldSchema object we return has an API and a URL, + # and that developers don't need to reload our schema to be able to access it. + field_schema = parse_field_schema(response) + field_schema.set_api( + self.api, + context={ + "base": self.base, + "table_schema": self._schema or self, + }, + ) + if self._schema: + self._schema.fields.append(field_schema) + return field_schema # These are at the bottom of the module to avoid circular imports diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 23131513..c95278e1 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, ClassVar, Iterable, Mapping, Optional, Set, Type, Union +from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union import inflection from typing_extensions import Self as SelfType @@ -34,6 +34,87 @@ def parse_obj(cls, obj: Any) -> SelfType: instance._raw = obj return instance + @classmethod + def from_api( + cls, + obj: Any, + api: "pyairtable.api.api.Api", + *, + context: Optional[Any] = None, + ) -> SelfType: + """ + Constructs an instance which is able to update itself using an + :class:`~pyairtable.Api`. + + Args: + obj: The JSON data structure used to construct the instance. + Will be passed to `parse_obj `_. + api: The connection to use for saving updates. + context: An object, sequence of objects, or mapping of names to objects + which will be used as arguments to ``str.format()`` when constructing + the URL for a :class:`~pyairtable.models._base.SerializableModel`. + """ + instance = cls.parse_obj(obj) + cascade_api(instance, api, context=context) + return instance + + +def _context_name(obj: Any) -> str: + return inflection.underscore(type(obj).__name__) + + +def cascade_api( + obj: Any, + api: "pyairtable.api.api.Api", + *, + context: Optional[Any] = None, +) -> None: + """ + Ensure all nested objects have access to the given Api instance, + and trigger them to configure their URLs accordingly. + + Args: + api: The instance of the API to set. + context: A mapping of class names to instances of that class. + """ + if context is None: + context = {} + # context=[Foo(), Bar()] is short for context={"foo": Foo(), "bar": Bar()} + if isinstance(context, (list, tuple, set)): + context = {_context_name(ctx_obj): ctx_obj for ctx_obj in context} + # context=Foo() is short for context={"foo": Foo()} + if context and not isinstance(context, dict): + context = {_context_name(context): context} + + # Ensure we don't get stuck in infinite loops + visited: Set[int] = context.setdefault("__visited__", set()) + if id(obj) in visited: + return + visited.add(id(obj)) + + # Iterate over containers and cascade API context down to contained models. + if isinstance(obj, (list, tuple, set)): + for value in obj: + cascade_api(value, api, context=context) + if isinstance(obj, dict): + for value in obj.values(): + cascade_api(value, api, context=context) + if not isinstance(obj, AirtableModel): + return + + # If we get this far, we're dealing with a model, so add it to the context. + # If it's a ModelNamedThis, the key will be model_named_this. + context = {**context, _context_name(obj): obj} + + # This is what we came here for + if isinstance(obj, SerializableModel): + obj.set_api(api, context=context) + + # Find and apply API/context to nested models in every Pydantic field. + for field_name in type(obj).__fields__: + if field_value := getattr(obj, field_name, None): + cascade_api(field_value, api, context=context) + class SerializableModel(AirtableModel): """ @@ -45,43 +126,37 @@ class SerializableModel(AirtableModel): * ``readonly=``: field names that should not be written to API on ``save()``. * ``allow_update=``: boolean indicating whether to allow ``save()`` (default: true) * ``allow_delete=``: boolean indicating whether to allow ``delete()`` (default: true) + * ``save_null_values=``: boolean indicating whether ``save()`` should write nulls (default: true) + * ``url=``: format string for building the URL to be used when saving changes to this model. """ - __writable: ClassVar[Optional[Iterable[str]]] - __readonly: ClassVar[Optional[Iterable[str]]] - __allow_update: ClassVar[bool] - __allow_delete: ClassVar[bool] + __writable: ClassVar[Optional[Iterable[str]]] = None + __readonly: ClassVar[Optional[Iterable[str]]] = None + __allow_update: ClassVar[bool] = True + __allow_delete: ClassVar[bool] = True + __save_none: ClassVar[bool] = True + __url_pattern: ClassVar[str] = "" def __init_subclass__(cls, **kwargs: Any) -> None: - # These are private to SerializableModel if "writable" in kwargs and "readonly" in kwargs: raise ValueError("incompatible kwargs 'writable' and 'readonly'") - cls.__writable = kwargs.pop("writable", None) - cls.__readonly = kwargs.pop("readonly", None) - cls.__allow_update = bool(kwargs.pop("allow_update", True)) - cls.__allow_delete = bool(kwargs.pop("allow_delete", True)) + cls.__writable = kwargs.pop("writable", cls.__writable) + cls.__readonly = kwargs.pop("readonly", cls.__readonly) + cls.__allow_update = bool(kwargs.pop("allow_update", cls.__allow_update)) + cls.__allow_delete = bool(kwargs.pop("allow_delete", cls.__allow_delete)) + cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none)) + cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) super().__init_subclass__(**kwargs) _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() _url: str = pydantic.PrivateAttr() _deleted: bool = pydantic.PrivateAttr(default=False) - @classmethod - def from_api(cls, api: "pyairtable.api.api.Api", url: str, obj: Any) -> SelfType: - """ - Constructs an instance which is able to update itself using an - :class:`~pyairtable.Api`. - - Args: - api: The connection to use for saving updates. - url: The URL which can receive PATCH or DELETE requests for this object. - obj: The JSON data structure used to construct the instance. - Will be passed to `parse_obj `_. - """ - parsed = cls.parse_obj(obj) - parsed._api = api - parsed._url = url - return parsed + def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: + self._api = api + self._url = self.__url_pattern.format(**context, self=self) + if self._url and not self._url.startswith("http"): + self._url = api.build_url(self._url) def save(self) -> None: """ @@ -93,19 +168,34 @@ def save(self) -> None: raise NotImplementedError(f"{self.__class__.__name__}.save() not allowed") if self._deleted: raise RuntimeError("save() called after delete()") + if not self._url: + raise RuntimeError("save() called with no URL specified") include = set(self.__writable) if self.__writable else None exclude = set(self.__readonly) if self.__readonly else None - data = self.dict(by_alias=True, include=include, exclude=exclude) + data = self.dict( + by_alias=True, + include=include, + exclude=exclude, + exclude_none=(not self.__save_none), + ) response = self._api.request("PATCH", self._url, json=data) - copyable = self.parse_obj(response) - self.__dict__.update(copyable.__dict__) + copyable = type(self).parse_obj(response) + self.__dict__.update( + { + key: value + for (key, value) in copyable.__dict__.items() + if key in type(self).__fields__ + } + ) def delete(self) -> None: """ - Delete the record on the server and marks this instance as deleted. + Delete the record on the server and mark this instance as deleted. """ if not self.__allow_delete: raise NotImplementedError(f"{self.__class__.__name__}.delete() not allowed") + if not self._url: + raise RuntimeError("delete() called with no URL specified") self._api.request("DELETE", self._url) self._deleted = True diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 1e7d3389..d04ffe48 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -4,7 +4,11 @@ from .collaborator import Collaborator -class Comment(SerializableModel, writable=["text"]): +class Comment( + SerializableModel, + writable=["text"], + url="{record_url}/comments/{self.id}", +): """ A record comment that has been retrieved from the Airtable API. diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0b249a74..2f56a39e 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -5,7 +5,7 @@ from pyairtable._compat import pydantic -from ._base import AirtableModel, update_forward_refs +from ._base import AirtableModel, SerializableModel, update_forward_refs PermissionLevel: TypeAlias = Literal[ "none", "read", "comment", "edit", "create", "owner" @@ -115,7 +115,13 @@ def table(self, id_or_name: str) -> "TableSchema": return _find(self.tables, id_or_name) -class TableSchema(AirtableModel): +class TableSchema( + SerializableModel, + allow_delete=False, + save_null_values=False, + writable=["name", "description"], + url="meta/bases/{base.id}/tables/{self.id}", +): """ See https://airtable.com/developers/web/api/get-base-schema """ @@ -676,7 +682,13 @@ class UnknownFieldConfig(AirtableModel): options: Optional[Dict[str, Any]] -class _FieldSchemaBase(AirtableModel): +class _FieldSchemaBase( + SerializableModel, + allow_delete=False, + save_null_values=False, + writable=["name", "description"], + url="meta/bases/{base.id}/tables/{table_schema.id}/fields/{self.id}", +): id: str name: str description: Optional[str] diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 61a04e99..c23369c8 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -15,7 +15,11 @@ FL: Callable[[], Any] = partial(pydantic.Field, default_factory=list) -class Webhook(SerializableModel, allow_update=False): +class Webhook( + SerializableModel, + allow_update=False, + url="bases/{base.id}/webhooks/{self.id}", +): """ A webhook that has been retrieved from the Airtable API. diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py new file mode 100644 index 00000000..87cce7e0 --- /dev/null +++ b/tests/integration/test_integration_enterprise.py @@ -0,0 +1,101 @@ +import uuid + +import pytest +from requests import HTTPError + +import pyairtable + +pytestmark = [pytest.mark.integration] + + +@pytest.fixture +def workspace_id(): + return "wsp0HnyXmNnKzc5ng" + + +@pytest.fixture +def workspace(api: pyairtable.Api, workspace_id): + return api.workspace(workspace_id) + + +@pytest.fixture(autouse=True) +def confirm_enterprise_plan(workspace: pyairtable.Workspace): + try: + workspace.info() + except HTTPError: + pytest.skip("This test requires creator access to an enterprise workspace") + + +@pytest.fixture +def blank_base(workspace: pyairtable.Workspace): + base = workspace.create_base( + f"Test {uuid.uuid1().hex}", + [{"name": "One", "fields": [{"type": "singleLineText", "name": "Label"}]}], + ) + try: + yield base + finally: + workspace.api.delete_base(base) + + +def test_create_table(blank_base: pyairtable.Base): + """ + Test that we can create a new table on an existing base. + """ + table = blank_base.create_table("Two", [{"type": "singleLineText", "name": "Name"}]) + assert table.schema().field("Name").type == "singleLineText" + + +def test_update_table(blank_base: pyairtable.Base): + """ + Test that we can modify a table's name and description. + """ + new_name = f"Renamed {uuid.uuid1().hex[-6:]}" + schema = blank_base.schema().tables[0] + schema.name = new_name + schema.save() + assert blank_base.schema(force=True).tables[0].name == new_name + schema.description = "Renamed" + schema.save() + assert blank_base.schema(force=True).tables[0].description == "Renamed" + + +def test_create_field(blank_base: pyairtable.Base): + """ + Test that we can create a new field on an existing table. + """ + table = blank_base.tables()[0] + assert len(table.schema().fields) == 1 + fld = table.create_field( + "Status", + type="singleSelect", + options={ + "choices": [ + {"name": "Todo"}, + {"name": "In Progress"}, + {"name": "Done"}, + ] + }, + ) + # Ensure we don't need to reload the schema to see this new field + assert table._schema.field(fld.id).name == "Status" + + +def test_update_field(blank_base: pyairtable.Base): + """ + Test that we can modify a field's name and description. + """ + + def reload_field(): + return blank_base.schema(force=True).tables[0].fields[0] + + field = reload_field() + + new_name = f"Renamed {uuid.uuid1().hex[-6:]}" + field.name = new_name + field.save() + assert reload_field().name == new_name + + field.description = "Renamed" + field.save() + assert reload_field().description == "Renamed" diff --git a/tests/test_api_table.py b/tests/test_api_table.py index dbda85e9..a102080e 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -405,6 +405,7 @@ def test_create_field(table, requests_mock, sample_json): "description": "field description", "options": {"choices": choices}, } + assert f._url.endswith(f"/{table.base.id}/tables/{table.name}/fields/{f.id}") # Helpers diff --git a/tests/test_models.py b/tests/test_models.py index f8b06f8e..45e7d950 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -15,12 +15,14 @@ def raw_data(): @pytest.fixture def create_instance(api, raw_data): def _creates_instance(**kwargs): + kwargs.setdefault("url", "https://example.com/{self.foo}/{self.bar}/{self.baz}") + class Subclass(SerializableModel, **kwargs): foo: int bar: int baz: int - return Subclass.from_api(api, "https://www.example.com", raw_data) + return Subclass.from_api(raw_data, api) return _creates_instance @@ -37,15 +39,25 @@ def test_raw(raw_data): assert obj._raw == raw_data -def test_from_api(raw_data): +@pytest.mark.parametrize("prefix", ["https://api.airtable.com/v0/prefix", "prefix"]) +def test_from_api(raw_data, prefix, api): """ - Test that SerializableModel.from_api persists its parameters correctly. + Test that SerializableModel.from_api persists its parameters correctly, + and that if `url=` is passed to the subclass, we'll always get a valid URL. """ - url = "https://www.example.com" - obj = SerializableModel.from_api("api", url, raw_data) - assert obj._api == "api" - assert obj._url == url + + class Dummy(SerializableModel, url="{prefix}/foo={self.foo}/bar={self.bar}"): + foo: int + bar: int + + obj = Dummy.from_api(raw_data, api, context={"prefix": prefix}) + assert obj._api == api assert obj._raw == raw_data + assert obj._url == "https://api.airtable.com/v0/prefix/foo=1/bar=2" + assert obj.foo == 1 + assert obj.bar == 2 + assert not hasattr(obj, "baz") + assert obj._raw["baz"] == 3 def test_save(requests_mock, create_instance): diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 45564c6f..9b5b9799 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -32,8 +32,8 @@ def comment_json(): @pytest.fixture def comment(comment_json, table): - url = table.record_url(RECORD_ID, "comments", comment_json["id"]) - return Comment.from_api(table.api, url, comment_json) + record_url = table.record_url(RECORD_ID) + return Comment.from_api(comment_json, table.api, context={"record_url": record_url}) @pytest.fixture diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py index 5a83a76b..7c1bf29b 100644 --- a/tests/test_models_webhook.py +++ b/tests/test_models_webhook.py @@ -12,11 +12,7 @@ @pytest.fixture def webhook(sample_json, base, api): webhook_json = sample_json("Webhook") - return Webhook.from_api( - api=api, - url=f"{base.webhooks_url}/{webhook_json['id']}", - obj=webhook_json, - ) + return Webhook.from_api(webhook_json, api, context=base) @pytest.fixture From 05dd6d2eba6616e860364c3c49e48453a229887f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 18 Oct 2023 23:15:11 -0700 Subject: [PATCH 016/272] Base.delete(), Workspace.delete() --- docs/source/metadata.rst | 56 ++++++++++++------- pyairtable/_compat.py | 2 +- pyairtable/api/api.py | 12 ---- pyairtable/api/base.py | 15 ++++- pyairtable/api/workspace.py | 11 ++++ pyairtable/models/_base.py | 8 +-- pyairtable/models/schema.py | 6 +- .../test_integration_enterprise.py | 2 +- tests/test_api_api.py | 21 ------- tests/test_api_base.py | 20 +++++++ tests/test_api_table.py | 53 ++++++++++++++---- tests/test_api_workspace.py | 6 ++ tests/test_models.py | 20 +++++++ 13 files changed, 160 insertions(+), 72 deletions(-) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 82c17839..a00dadb0 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -8,6 +8,9 @@ Metadata The Airtable API gives you the ability to list all of your bases, tables, fields, and views. pyAirtable allows you to inspect and interact with the metadata in your bases. +There may be parts of the pyAirtable API which are not supported below; +you can always use :meth:`Api.request ` to call them directly. + Reading schemas ----------------------------- @@ -33,18 +36,36 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` :noindex: -Modifying schemas +Enterprise information ----------------------------- -The following methods allow you to modify parts of the Airtable schema. -There may be parts of the pyAirtable API which are not supported below; -you can always use :meth:`Api.request ` to -call them directly. +pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. +The following methods are only available on an `Enterprise plan `__. +If you call one of them against a base that is not part of an enterprise workspace, Airtable will +return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan. + +.. automethod:: pyairtable.Api.enterprise + :noindex: + +.. automethod:: pyairtable.Base.info + :noindex: + +.. automethod:: pyairtable.Workspace.info + :noindex: + +.. automethod:: pyairtable.Enterprise.info + :noindex: + + +Modifying schema elements +----------------------------- + +The following methods allow creating bases, tables, or fields: .. automethod:: pyairtable.Api.create_base :noindex: -.. automethod:: pyairtable.Api.delete_base +.. automethod:: pyairtable.Workspace.create_base :noindex: .. automethod:: pyairtable.Base.create_table @@ -53,8 +74,10 @@ call them directly. .. automethod:: pyairtable.Table.create_field :noindex: -To rename a table or field, you can modify its schema object directly -and call ``save()``: +To modify a table or field, you can modify its schema object directly +and call ``save()``, as below. You can only rename a field or modify +its description; the Airtable API does not permit changing its type +or other field options. .. code-block:: python @@ -67,22 +90,17 @@ and call ``save()``: >>> field.save() -Enterprise information +Deleting schema elements ----------------------------- -pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. -The following methods are only available on an `Enterprise plan `__. -If you call one of them against a base that is not part of an enterprise workspace, Airtable will -return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan. - -.. automethod:: pyairtable.Api.enterprise - :noindex: +The Airtable API does not allow deleting tables or fields, but it does allow +deleting workspaces, bases, and views. pyAirtable exposes those via these methods: -.. automethod:: pyairtable.Base.info +.. automethod:: pyairtable.Workspace.delete :noindex: -.. automethod:: pyairtable.Workspace.info +.. automethod:: pyairtable.Base.delete :noindex: -.. automethod:: pyairtable.Enterprise.info +.. automethod:: pyairtable.models.schema.ViewSchema.delete :noindex: diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py index dbc06d85..b59654b3 100644 --- a/pyairtable/_compat.py +++ b/pyairtable/_compat.py @@ -6,7 +6,7 @@ # Pydantic v2 broke a bunch of stuff. Luckily they provide a built-in v1. try: import pydantic.v1 as pydantic - except ImportError: + except ImportError: # pragma: no cover import pydantic __all__ = ["pydantic"] diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index ef9bdb71..6a483d4b 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -317,15 +317,3 @@ def enterprise(self, enterprise_account_id: str) -> Enterprise: Returns an object representing an enterprise account. """ return Enterprise(self, enterprise_account_id) - - @enterprise_only - def delete_base(self, base: Union[str, "pyairtable.api.base.Base"]) -> None: - """ - Deletes the base. - - Args: - base: Either a base ID or a :class:`~pyairtable.Base` instance. - """ - if isinstance(base, str): - base = self.base(base) - self.request("DELETE", base.meta_url()) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 0b8909d4..6aa28fe0 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -141,8 +141,8 @@ def create_table( Args: name: The unique table name. - fields: A list of ``dict`` objects that conform to Airtable's - `Field model `__. + fields: A list of ``dict`` objects that conform to the + `Airtable field model `__. description: The table description. Must be no longer than 20k characters. """ url = self.meta_url("tables") @@ -304,3 +304,14 @@ def shares(self, *, force: bool = False) -> List[BaseShare]: data = self.api.request("GET", self.meta_url("shares")) self._shares = [BaseShare.parse_obj(share) for share in data["shares"]] return self._shares + + @enterprise_only + def delete(self) -> None: + """ + Deletes the base. + + Usage: + >>> base = api.base("appMxESAta6clCCwF") + >>> base.delete() + """ + self.api.request("DELETE", self.meta_url()) diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index dc2b3f0f..93d6bac3 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -77,6 +77,17 @@ def name(self) -> str: """ return self.info().name + @enterprise_only + def delete(self) -> None: + """ + Deletes the workspace. + + Usage: + >>> ws = api.workspace("wspmhESAta6clCCwF") + >>> ws.delete() + """ + self.api.request("DELETE", self.url) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index c95278e1..800b1e8b 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -79,9 +79,6 @@ def cascade_api( """ if context is None: context = {} - # context=[Foo(), Bar()] is short for context={"foo": Foo(), "bar": Bar()} - if isinstance(context, (list, tuple, set)): - context = {_context_name(ctx_obj): ctx_obj for ctx_obj in context} # context=Foo() is short for context={"foo": Foo()} if context and not isinstance(context, dict): context = {_context_name(context): context} @@ -149,7 +146,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() - _url: str = pydantic.PrivateAttr() + _url: str = pydantic.PrivateAttr(default="") _deleted: bool = pydantic.PrivateAttr(default=False) def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: @@ -160,7 +157,8 @@ def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> Non def save(self) -> None: """ - Save any changes made to the instance's writable fields. + Save any changes made to the instance's writable fields and update the + instance with any refreshed values returned from the API. Will raise ``RuntimeError`` if the record has been deleted. """ diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 2f56a39e..cfab17b6 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -23,6 +23,8 @@ def _find(collection: List[_T], id_or_name: str) -> _T: items_by_name: Dict[str, _T] = {} for item in collection: + if getattr(item, "deleted", None): + continue if item.id == id_or_name: return item items_by_name[item.name] = item @@ -146,7 +148,9 @@ def view(self, id_or_name: str) -> "ViewSchema": return _find(self.views, id_or_name) -class ViewSchema(AirtableModel): +class ViewSchema( + SerializableModel, allow_update=False, url="meta/bases/{base.id}/views/{self.id}" +): """ See https://airtable.com/developers/web/api/get-view-metadata """ diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py index 87cce7e0..14bff295 100644 --- a/tests/integration/test_integration_enterprise.py +++ b/tests/integration/test_integration_enterprise.py @@ -35,7 +35,7 @@ def blank_base(workspace: pyairtable.Workspace): try: yield base finally: - workspace.api.delete_base(base) + base.delete() def test_create_table(blank_base: pyairtable.Base): diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 817dbf85..d377fdaa 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -1,8 +1,5 @@ from unittest import mock -import pytest -from requests import HTTPError - from pyairtable import Api, Base, Table # noqa @@ -117,21 +114,3 @@ def test_create_base(api): api.create_base("wspFake", "Fake Name", []) m.assert_called_once_with("Fake Name", []) - - -def test_delete_base(api, base, requests_mock): - """ - Test that Api.delete_base accepts either a Base or an ID. - """ - m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True}) - api.delete_base(base) - assert m.call_count == 1 - api.delete_base(base.id) - assert m.call_count == 2 - - -def test_delete_base__enterprise_only_table(api, base, requests_mock): - requests_mock.delete(base.meta_url(), status_code=404) - with pytest.raises(HTTPError) as excinfo: - api.delete_base(base.id) - assert "Api.delete_base() requires an enterprise billing plan" in str(excinfo) diff --git a/tests/test_api_base.py b/tests/test_api_base.py index ab99f93c..45769295 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -1,6 +1,7 @@ import datetime import pytest +from requests import HTTPError from pyairtable import Base, Table from pyairtable.testing import fake_id @@ -217,3 +218,22 @@ def test_create_table(base, requests_mock, sample_json): "description": "Description", "fields": [{"name": "Whatever"}], } + + +def test_delete(base, requests_mock): + """ + Test that Base.delete() hits the right endpoint. + """ + m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True}) + base.delete() + assert m.call_count == 1 + + +def test_delete__enterprise_only_table(api, base, requests_mock): + """ + Test that Base.delete() explains why it might be getting a 404. + """ + requests_mock.delete(base.meta_url(), status_code=404) + with pytest.raises(HTTPError) as excinfo: + base.delete() + assert "Base.delete() requires an enterprise billing plan" in str(excinfo) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index a102080e..95d41167 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -15,6 +15,16 @@ def table_schema(sample_json) -> TableSchema: return TableSchema.parse_obj(sample_json("TableSchema")) +@pytest.fixture +def mock_schema(table, requests_mock, sample_json): + table_schema = sample_json("TableSchema") + table_schema["id"] = table.name = fake_id("tbl") + return requests_mock.get( + table.base.meta_url("tables") + "?include=visibleFieldIds", + json={"tables": [table_schema]}, + ) + + def test_constructor(base: Base): """ Test the constructor. @@ -382,30 +392,53 @@ def test_batch_delete(table: Table, container, mock_records): assert resp == expected -def test_create_field(table, requests_mock, sample_json): +def test_create_field(table, mock_schema, requests_mock, sample_json): """ Tests the API for creating a field (but without actually performing the operation). """ - table.name = fake_id("tbl") # so that the .id property doesn't request schema - field_schema = sample_json("field_schema/SingleSelectFieldSchema") + mock_create = requests_mock.post( + table.meta_url("fields"), + json=sample_json("field_schema/SingleSelectFieldSchema"), + ) + + # Ensure we have pre-loaded our schema + table.schema() + assert mock_schema.call_count == 1 + + # Create the field choices = ["Todo", "In progress", "Done"] - m = requests_mock.post(table.meta_url("fields"), json=field_schema) - f = table.create_field( + fld = table.create_field( "Status", "singleSelect", description="field description", options={"choices": choices}, ) - assert f.id == "fldqCjrs1UhXgHUIc" - assert {c.name for c in f.options.choices} == set(choices) - assert m.call_count == 1 - assert m.request_history[-1].json() == { + assert mock_create.call_count == 1 + assert mock_create.request_history[-1].json() == { "name": "Status", "type": "singleSelect", "description": "field description", "options": {"choices": choices}, } - assert f._url.endswith(f"/{table.base.id}/tables/{table.name}/fields/{f.id}") + + # Test the result we got back + assert fld.id == "fldqCjrs1UhXgHUIc" + assert fld.name == "Status" + assert {c.name for c in fld.options.choices} == set(choices) + + # Test that we constructed the URL correctly + assert fld._url.endswith(f"/{table.base.id}/tables/{table.name}/fields/{fld.id}") + + # Test that the schema has been updated without a second API call + assert table._schema.field(fld.id).name == "Status" + assert mock_schema.call_count == 1 + + +def test_delete_view(table, mock_schema, requests_mock): + view = table.schema().view("Grid view") + m = requests_mock.delete(view._url) + view.delete() + assert m.call_count == 1 # Helpers diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index 802561e0..63dabff5 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -40,3 +40,9 @@ def test_create_base(workspace, requests_mock, sample_json): base = workspace.create_base("Base Name", []) assert isinstance(base, Base) assert base.id == "appLkNDICXNqxSDhG" + + +def test_delete(workspace, requests_mock): + m = requests_mock.delete(workspace.url, json={"id": workspace.id, "deleted": True}) + workspace.delete() + assert m.call_count == 1 diff --git a/tests/test_models.py b/tests/test_models.py index 45e7d950..52313c13 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -81,6 +81,16 @@ def test_save_not_allowed(create_instance): obj.save() +def test_save_without_url(create_instance): + """ + Test that if we do not provide context for computing a URL when an instance + is created, we won't be able to save it later. + """ + obj = create_instance(url="") + with pytest.raises(RuntimeError): + obj.save() + + def test_delete(requests_mock, create_instance): obj = create_instance() m = requests_mock.delete(obj._url) @@ -97,6 +107,16 @@ def test_delete_not_allowed(create_instance): obj.delete() +def test_delete_without_url(create_instance): + """ + Test that if we do not provide context for computing a URL when an instance + is created, we won't be able to delete it later. + """ + obj = create_instance(url="") + with pytest.raises(RuntimeError): + obj.delete() + + def test_writable(create_instance): obj = create_instance(writable=["foo"]) obj.foo = 0 From 24719d6a5743e69809f93b768860d0c1e278d13c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 21 Oct 2023 09:19:41 -0700 Subject: [PATCH 017/272] Workspace.move_base() --- docs/source/metadata.rst | 3 +++ pyairtable/api/workspace.py | 25 +++++++++++++++++++++++- tests/conftest.py | 9 +++++++-- tests/test_api_workspace.py | 39 +++++++++++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index a00dadb0..176ee0bf 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -68,6 +68,9 @@ The following methods allow creating bases, tables, or fields: .. automethod:: pyairtable.Workspace.create_base :noindex: +.. automethod:: pyairtable.Workspace.move_base + :noindex: + .. automethod:: pyairtable.Base.create_table :noindex: diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 93d6bac3..363c9905 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional, Sequence, Union from pyairtable.models.schema import WorkspaceInfo from pyairtable.utils import enterprise_only @@ -88,6 +88,29 @@ def delete(self) -> None: """ self.api.request("DELETE", self.url) + @enterprise_only + def move_base( + self, + base: Union[str, "pyairtable.api.base.Base"], + target: Union[str, "Workspace"], + index: Optional[int] = None, + ) -> None: + """ + Moves the given base to a new workspace. + + Usage: + >>> ws = api.workspace("wspmhESAta6clCCwF") + >>> base = api.workspace("appCwFmhESAta6clC") + >>> workspace.move_base(base, "wspSomeOtherPlace", index=0) + """ + base_id = base if isinstance(base, str) else base.id + target_id = target if isinstance(target, str) else target.id + payload: Dict[str, Any] = {"baseId": base_id, "targetWorkspaceId": target_id} + if index is not None: + payload["targetIndex"] = index + url = self.url + "/moveBase" + self.api.request("POST", url, json=payload) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/tests/conftest.py b/tests/conftest.py index 5bf913c3..7bd9593f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,9 +39,14 @@ def api(constants) -> Api: return Api(constants["API_KEY"]) +@pytest.fixture +def base_id(constants) -> str: + return constants["BASE_ID"] + + @pytest.fixture() -def base(api: Api, constants) -> Base: - return api.base(constants["BASE_ID"]) +def base(api: Api, base_id) -> Base: + return api.base(base_id) @pytest.fixture() diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index 63dabff5..69114e2c 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -5,8 +5,13 @@ @pytest.fixture -def workspace(api): - return Workspace(api, "wspFakeWorkspaceId") +def workspace_id(): + return "wspFakeWorkspaceId" + + +@pytest.fixture +def workspace(api, workspace_id): + return Workspace(api, workspace_id) @pytest.fixture @@ -46,3 +51,33 @@ def test_delete(workspace, requests_mock): m = requests_mock.delete(workspace.url, json={"id": workspace.id, "deleted": True}) workspace.delete() assert m.call_count == 1 + + +@pytest.mark.parametrize("workspace_param", ["workspace", "workspace_id"]) +@pytest.mark.parametrize("base_param", ["base", "base_id"]) +@pytest.mark.parametrize( + "kwargs,expected", + [ + ({}, {}), + ({"index": 8}, {"targetIndex": 8}), + ], +) +def test_move_base( + workspace, + workspace_id, + workspace_param, + base, + base_id, + base_param, + kwargs, + expected, + requests_mock, +): + m = requests_mock.post(workspace.url + "/moveBase") + workspace.move_base(locals()[base_param], locals()[workspace_param], **kwargs) + assert m.call_count == 1 + assert m.request_history[-1].json() == { + "baseId": base_id, + "targetWorkspaceId": workspace_id, + **expected, + } From e91b14f503bf831f4eb90af50917bd63ca023b8c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 24 Oct 2023 22:32:13 -0700 Subject: [PATCH 018/272] Schema docstrings --- pyairtable/models/schema.py | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index cfab17b6..b3f25dd4 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -34,6 +34,8 @@ def _find(collection: List[_T], id_or_name: str) -> _T: class Bases(AirtableModel): """ + The list of bases visible to the API token. + See https://airtable.com/developers/web/api/list-bases """ @@ -53,6 +55,8 @@ class Info(AirtableModel): class BaseInfo(AirtableModel): """ + Detailed information about who can access a base. + See https://airtable.com/developers/web/api/get-base-collaborators """ @@ -88,6 +92,8 @@ class InviteLinks(AirtableModel): class BaseShare(AirtableModel): """ + Detailed information about a shared view. + See https://airtable.com/developers/web/api/list-shares """ @@ -105,6 +111,8 @@ class BaseShare(AirtableModel): class BaseSchema(AirtableModel): """ + Schema of all tables within the base. + See https://airtable.com/developers/web/api/get-base-schema """ @@ -125,6 +133,17 @@ class TableSchema( url="meta/bases/{base.id}/tables/{self.id}", ): """ + Metadata for a table. + + Usage: + >>> schema = base.table("Table Name").schema() + >>> schema.id + 'tbl6clmhESAtaCCwF' + >>> schema.fields + [FieldSchema(...), ...] + >>> schema.views + [ViewSchema(...), ...] + See https://airtable.com/developers/web/api/get-base-schema """ @@ -152,6 +171,16 @@ class ViewSchema( SerializableModel, allow_update=False, url="meta/bases/{base.id}/views/{self.id}" ): """ + Metadata for a view. + + Usage: + >>> vw = table.schema().view("View name") + >>> vw.name + 'View name' + >>> vw.type + 'grid' + >>> vw.delete() + See https://airtable.com/developers/web/api/get-view-metadata """ @@ -201,6 +230,13 @@ class BaseInviteLink(InviteLink): class EnterpriseInfo(AirtableModel): + """ + Information about groups, users, workspaces, and email domains + associated with an enterprise account. + + See https://airtable.com/developers/web/api/get-enterprise + """ + id: str created_time: str group_ids: List[str] @@ -214,6 +250,12 @@ class EmailDomain(AirtableModel): class WorkspaceInfo(AirtableModel): + """ + Detailed information about who can access a workspace. + + See https://airtable.com/developers/web/api/get-workspace-collaborators + """ + id: str name: str created_time: str @@ -249,6 +291,12 @@ class NestedFieldId(AirtableModel): class UserInfo(AirtableModel): + """ + Detailed information about a user. + + See https://airtable.com/developers/web/api/get-user-by-id + """ + id: str name: str email: str @@ -261,6 +309,12 @@ class UserInfo(AirtableModel): class GroupInfo(AirtableModel): + """ + Detailed information about a user group and its members. + + See https://airtable.com/developers/web/api/get-user-group + """ + id: str name: str enterprise_account_id: str From 643fec9e994ab4e38faac74b37897041e7d0442a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 24 Oct 2023 23:50:13 -0700 Subject: [PATCH 019/272] Base.info() -> Base.collaborators() --- docs/source/metadata.rst | 2 +- pyairtable/api/base.py | 19 ++++++++++++------- pyairtable/models/schema.py | 10 +++++----- .../integration/test_integration_metadata.py | 4 ++-- .../{BaseInfo.json => BaseCollaborators.json} | 0 tests/test_api_base.py | 8 ++++---- tests/test_models_schema.py | 2 +- 7 files changed, 25 insertions(+), 20 deletions(-) rename tests/sample_data/{BaseInfo.json => BaseCollaborators.json} (100%) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 176ee0bf..493163e8 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -47,7 +47,7 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check .. automethod:: pyairtable.Api.enterprise :noindex: -.. automethod:: pyairtable.Base.info +.. automethod:: pyairtable.Base.collaborators :noindex: .. automethod:: pyairtable.Workspace.info diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 6aa28fe0..2d942cd7 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -3,7 +3,12 @@ import pyairtable.api.api import pyairtable.api.table -from pyairtable.models.schema import BaseInfo, BaseSchema, BaseShare, PermissionLevel +from pyairtable.models.schema import ( + BaseCollaborators, + BaseSchema, + BaseShare, + PermissionLevel, +) from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, @@ -28,7 +33,7 @@ class Base: permission_level: Optional[PermissionLevel] # Cached metadata to reduce API calls - _info: Optional[BaseInfo] = None + _collaborators: Optional[BaseCollaborators] = None _schema: Optional[BaseSchema] = None _shares: Optional[List[BaseShare]] = None @@ -76,8 +81,8 @@ def name(self) -> Optional[str]: The name of the base, if provided to the constructor or available in cached base information. """ - if self._info: - return self._info.name + if self._collaborators: + return self._collaborators.name return self._name def __repr__(self) -> str: @@ -279,7 +284,7 @@ def add_webhook( return CreateWebhookResponse.parse_obj(response) @enterprise_only - def info(self, *, force: bool = False) -> "BaseInfo": + def collaborators(self, *, force: bool = False) -> "BaseCollaborators": """ Retrieves `base collaborators `__. @@ -289,8 +294,8 @@ def info(self, *, force: bool = False) -> "BaseInfo": if force or not self._info: params = {"include": ["collaborators", "inviteLinks", "interfaces"]} data = self.api.request("GET", self.meta_url(), params=params) - self._info = BaseInfo.parse_obj(data) - return self._info + self._collaborators = BaseCollaborators.parse_obj(data) + return self._collaborators @enterprise_only def shares(self, *, force: bool = False) -> List[BaseShare]: diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index b3f25dd4..deb4ce26 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -53,7 +53,7 @@ class Info(AirtableModel): permission_level: PermissionLevel -class BaseInfo(AirtableModel): +class BaseCollaborators(AirtableModel): """ Detailed information about who can access a base. @@ -64,10 +64,10 @@ class BaseInfo(AirtableModel): name: str permission_level: PermissionLevel workspace_id: str - interfaces: Dict[str, "BaseInfo.InterfaceCollaborators"] = _FD() - group_collaborators: Optional["BaseInfo.GroupCollaborators"] - individual_collaborators: Optional["BaseInfo.IndividualCollaborators"] - invite_links: Optional["BaseInfo.InviteLinks"] + interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD() + group_collaborators: Optional["BaseCollaborators.GroupCollaborators"] + individual_collaborators: Optional["BaseCollaborators.IndividualCollaborators"] + invite_links: Optional["BaseCollaborators.InviteLinks"] class InterfaceCollaborators(AirtableModel): created_time: str diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py index 0700172d..570c4b42 100644 --- a/tests/integration/test_integration_metadata.py +++ b/tests/integration/test_integration_metadata.py @@ -21,9 +21,9 @@ def test_api_base(api: Api, base_id: str, base_name: str): def test_base_info(base: Base): with pytest.raises( requests.HTTPError, - match=r"Base.info\(\) requires an enterprise billing plan", + match=r"Base.collaborators\(\) requires an enterprise billing plan", ): - base.info() + base.collaborators() def test_base_schema(base: Base, table_name: str): diff --git a/tests/sample_data/BaseInfo.json b/tests/sample_data/BaseCollaborators.json similarity index 100% rename from tests/sample_data/BaseInfo.json rename to tests/sample_data/BaseCollaborators.json diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 45769295..a9e3ab31 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -104,9 +104,9 @@ def test_tables(base: Base, requests_mock, sample_json): assert result[1].name == "Districts" -def test_info(base: Base, requests_mock, sample_json): - requests_mock.get(base.meta_url(), json=sample_json("BaseInfo")) - result = base.info() +def test_collaborators(base: Base, requests_mock, sample_json): + requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators")) + result = base.collaborators() assert result.individual_collaborators.via_base[0].email == "foo@bam.com" assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ" @@ -189,7 +189,7 @@ def test_name(api, base, requests_mock): assert api.base(base.id).name is None assert base.name is None - assert base.info().name == "Mocked Base Name" + assert base.collaborators().name == "Mocked Base Name" assert base.name == "Mocked Base Name" # Test behavior with older constructor pattern diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 12ad2cc8..ae505b42 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -9,7 +9,7 @@ "clsname", [ "Bases", - "BaseInfo", + "BaseCollaborators", "BaseSchema", "TableSchema", "ViewSchema", From d84e456a56a51ac73ce747bedf2393027baad335 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 25 Oct 2023 00:01:32 -0700 Subject: [PATCH 020/272] @cache_unless_forced --- docs/source/_substitutions.rst | 4 +-- docs/source/metadata.rst | 6 ++-- pyairtable/api/api.py | 52 ++++++++++++++++---------------- pyairtable/api/base.py | 44 ++++++++++----------------- pyairtable/api/enterprise.py | 16 ++++------ pyairtable/api/workspace.py | 16 ++++------ pyairtable/utils.py | 55 +++++++++++++++++++++++++++++----- 7 files changed, 106 insertions(+), 87 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index cce1bd9e..fc65137a 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -62,8 +62,8 @@ key is the field id. This defaults to `false`, which returns field objects where the key is the field name. .. |kwarg_force_metadata| replace:: - If ``False``, will only fetch information from the API if it has not been cached. - If ``True``, will always fetch information from the API, overwriting any cached values. + By default, this method will only fetch information from the API if it has not been cached. + If called with ``force=True`` it will always call the API, and will overwrite any cached values. .. |kwarg_validate_metadata| replace:: If ``False``, will create an object without validating the ID/name provided. diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 493163e8..ae518c0a 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -29,9 +29,6 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` .. automethod:: pyairtable.Base.tables :noindex: -.. automethod:: pyairtable.Base.shares - :noindex: - .. automethod:: pyairtable.Table.schema :noindex: @@ -50,6 +47,9 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check .. automethod:: pyairtable.Base.collaborators :noindex: +.. automethod:: pyairtable.Base.shares + :noindex: + .. automethod:: pyairtable.Workspace.info :noindex: diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 6a483d4b..1c0e7a64 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -5,15 +5,13 @@ from requests.sessions import Session from typing_extensions import TypeAlias -import pyairtable.api.base -import pyairtable.api.table from pyairtable.api import retrying from pyairtable.api.enterprise import Enterprise from pyairtable.api.params import options_to_json_and_params, options_to_params from pyairtable.api.types import UserAndScopesDict, assert_typed_dict from pyairtable.api.workspace import Workspace from pyairtable.models.schema import Bases -from pyairtable.utils import chunked, enterprise_only +from pyairtable.utils import cache_unless_forced, chunked, enterprise_only T = TypeVar("T") TimeoutTuple: TypeAlias = Tuple[int, int] @@ -122,14 +120,12 @@ def base( return self.bases(force=True)[base_id] return pyairtable.api.base.Base(self, base_id) - def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: + @cache_unless_forced + def bases(self) -> Dict[str, "pyairtable.api.base.Base"]: """ Retrieves a list of all bases from the API and caches it, returning a mapping of IDs to :class:`Base` instances. - Args: - force: |kwarg_force_metadata| - Usage: >>> api.bases() { @@ -137,27 +133,25 @@ def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"] 'appLkNDICXNqxSDhG': } """ - if force or not self._bases: - url = self.build_url("meta/bases") - self._base_info = Bases.parse_obj( - { - "bases": [ - base_info - for page in self.iterate_requests("GET", url) - for base_info in page["bases"] - ] - } - ) - self._bases = { - info.id: pyairtable.api.base.Base( - self, - info.id, - name=info.name, - permission_level=info.permission_level, - ) - for info in self._base_info.bases + url = self.build_url("meta/bases") + collection = Bases.parse_obj( + { + "bases": [ + base_info + for page in self.iterate_requests("GET", url) + for base_info in page["bases"] + ] } - return dict(self._bases) + ) + return { + info.id: pyairtable.api.base.Base( + self, + info.id, + name=info.name, + permission_level=info.permission_level, + ) + for info in collection.bases + } def create_base( self, @@ -317,3 +311,7 @@ def enterprise(self, enterprise_account_id: str) -> Enterprise: Returns an object representing an enterprise account. """ return Enterprise(self, enterprise_account_id) + + +import pyairtable.api.base # noqa +import pyairtable.api.table # noqa diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 2d942cd7..7aa5fb1b 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -15,7 +15,7 @@ Webhook, WebhookSpecification, ) -from pyairtable.utils import enterprise_only +from pyairtable.utils import cache_unless_forced, enterprise_only class Base: @@ -167,13 +167,11 @@ def meta_url(self, *components: Any) -> str: """ return self.api.build_url("meta/bases", self.id, *components) - def schema(self, *, force: bool = False) -> BaseSchema: + @cache_unless_forced + def schema(self) -> BaseSchema: """ Retrieves the schema of all tables in the base and caches it. - Args: - force: |kwarg_force_metadata| - Usage: >>> base.schema().tables [TableSchema(...), TableSchema(...), ...] @@ -182,12 +180,10 @@ def schema(self, *, force: bool = False) -> BaseSchema: >>> base.schema().table("My Table") TableSchema(id="...", name="My Table", ...) """ - if force or not self._schema: - url = self.meta_url("tables") - params = {"include": ["visibleFieldIds"]} - data = self.api.request("GET", url, params=params) - self._schema = BaseSchema.from_api(data, self.api, context=self) - return self._schema + url = self.meta_url("tables") + params = {"include": ["visibleFieldIds"]} + data = self.api.request("GET", url, params=params) + return BaseSchema.from_api(data, self.api, context=self) @property def webhooks_url(self) -> str: @@ -284,31 +280,23 @@ def add_webhook( return CreateWebhookResponse.parse_obj(response) @enterprise_only - def collaborators(self, *, force: bool = False) -> "BaseCollaborators": + @cache_unless_forced + def collaborators(self) -> "BaseCollaborators": """ Retrieves `base collaborators `__. - - Args: - force: |kwarg_force_metadata| """ - if force or not self._info: - params = {"include": ["collaborators", "inviteLinks", "interfaces"]} - data = self.api.request("GET", self.meta_url(), params=params) - self._collaborators = BaseCollaborators.parse_obj(data) - return self._collaborators + params = {"include": ["collaborators", "inviteLinks", "interfaces"]} + data = self.api.request("GET", self.meta_url(), params=params) + return BaseCollaborators.parse_obj(data) @enterprise_only - def shares(self, *, force: bool = False) -> List[BaseShare]: + @cache_unless_forced + def shares(self) -> List[BaseShare]: """ Retrieves `base shares `__. - - Args: - force: |kwarg_force_metadata| """ - if force or not self._shares: - data = self.api.request("GET", self.meta_url("shares")) - self._shares = [BaseShare.parse_obj(share) for share in data["shares"]] - return self._shares + data = self.api.request("GET", self.meta_url("shares")) + return [BaseShare.parse_obj(share) for share in data["shares"]] @enterprise_only def delete(self) -> None: diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 1e9c4e14..588d2396 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,7 +1,7 @@ from typing import Dict, Iterable, List, Optional from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo -from pyairtable.utils import enterprise_only, is_user_id +from pyairtable.utils import cache_unless_forced, enterprise_only, is_user_id @enterprise_only @@ -23,18 +23,14 @@ def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): def url(self) -> str: return self.api.build_url("meta/enterpriseAccounts", self.id) - def info(self, *, force: bool = False) -> EnterpriseInfo: + @cache_unless_forced + def info(self) -> EnterpriseInfo: """ Retrieves basic information about the enterprise, caching the result. - - Args: - force: |kwarg_force_metadata| """ - if force or not self._info: - params = {"include": ["collaborators", "inviteLinks"]} - payload = self.api.request("GET", self.url, params=params) - self._info = EnterpriseInfo.parse_obj(payload) - return self._info + params = {"include": ["collaborators", "inviteLinks"]} + payload = self.api.request("GET", self.url, params=params) + return EnterpriseInfo.parse_obj(payload) def group(self, group_id: str) -> GroupInfo: url = self.api.build_url(f"meta/groups/{group_id}") diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 363c9905..893ae8c7 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -1,7 +1,7 @@ from typing import Any, Dict, List, Optional, Sequence, Union from pyairtable.models.schema import WorkspaceInfo -from pyairtable.utils import enterprise_only +from pyairtable.utils import cache_unless_forced, enterprise_only class Workspace: @@ -48,19 +48,15 @@ def create_base( # Everything below here requires .info() and is therefore Enterprise-only @enterprise_only - def info(self, *, force: bool = False) -> WorkspaceInfo: + @cache_unless_forced + def info(self) -> WorkspaceInfo: """ Retrieves basic information, collaborators, and invites for the given workspace, caching the result. - - Args: - force: |kwarg_force_metadata| """ - if force or not self._info: - params = {"include": ["collaborators", "inviteLinks"]} - payload = self.api.request("GET", self.url, params=params) - self._info = WorkspaceInfo.parse_obj(payload) - return self._info + params = {"include": ["collaborators", "inviteLinks"]} + payload = self.api.request("GET", self.url, params=params) + return WorkspaceInfo.parse_obj(payload) @enterprise_only def bases(self) -> List["pyairtable.api.base.Base"]: diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 2a4a1622..f44a425c 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -3,14 +3,15 @@ import textwrap from datetime import date, datetime from functools import partial, wraps -from typing import Any, Callable, Iterator, Sequence, TypeVar, Union, cast +from typing import Any, Callable, Generic, Iterator, Sequence, TypeVar, Union, cast import requests -from typing_extensions import ParamSpec +from typing_extensions import ParamSpec, Protocol from pyairtable.api.types import CreateAttachmentDict P = ParamSpec("P") +R = TypeVar("R", covariant=True) T = TypeVar("T") @@ -135,14 +136,14 @@ def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F: we will annotate the error with a helpful note to the user. """ - if modify_docstring and (doc := wrapped.__doc__): - wrapped.__doc__ = _prepend_docstring_text(doc, "|enterprise_only|") + if modify_docstring: + _prepend_docstring_text(wrapped, "|enterprise_only|") # Allow putting the decorator on a class if inspect.isclass(wrapped): for name, obj in vars(wrapped).items(): if inspect.isfunction(obj): - setattr(wrapped, name, enterprise_only(obj, modify_docstring=False)) + setattr(wrapped, name, enterprise_only(obj)) return cast(F, wrapped) @wraps(wrapped) @@ -160,8 +161,48 @@ def _decorated(*args: Any, **kwargs: Any) -> Any: return _decorated # type: ignore[return-value] -def _prepend_docstring_text(doc: str, text: str) -> str: +def _prepend_docstring_text(obj: Any, text: str) -> None: + if not (doc := obj.__doc__): + return doc = doc.lstrip("\n") if has_leading_spaces := re.match(r"^\s+", doc): text = textwrap.indent(text, has_leading_spaces[0]) - return f"{text}\n\n{doc}" + obj.__doc__ = f"{text}\n\n{doc}" + + +def _append_docstring_text(obj: Any, text: str) -> None: + if not (doc := obj.__doc__): + return + doc = doc.rstrip("\n") + if has_leading_spaces := re.match(r"^\s+", doc): + text = textwrap.indent(text, has_leading_spaces[0]) + obj.__doc__ = f"{doc}\n\n{text}" + + +class FetchMethod(Protocol, Generic[R]): + def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: + ... + + def __call__(self_, self: Any, *, force: bool = False) -> R: + ... + + +def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: + """ + Wraps a method (e.g. ``Base.shares()``) in a decorator that will save + a memoized version of the return value for future reuse, but will also + allow callers to pass ``force=True`` to recompute the memoized version. + """ + + attr = f"_{func.__name__}" + + @wraps(func) + def _inner(self: Any, *, force: bool = False) -> R: + if force or not getattr(self, attr, None): + setattr(self, attr, func(self)) + return cast(R, getattr(self, attr)) + + _inner.__annotations__["force"] = bool + _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|") + + return _inner From b5ee86782b7f6810143de301ddd5c568dc8d78c9 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 26 Oct 2023 20:48:15 -0700 Subject: [PATCH 021/272] Split apart SerializableModel to clean up docs --- docs/source/api.rst | 1 + pyairtable/models/_base.py | 116 ++++++++++++++++++++++------------- pyairtable/models/comment.py | 52 ++++++++-------- pyairtable/models/schema.py | 12 ++-- pyairtable/models/webhook.py | 8 +-- tests/test_models.py | 21 +++++-- 6 files changed, 121 insertions(+), 89 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 7f0010bf..8815c7de 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -46,6 +46,7 @@ API: pyairtable.models .. automodule:: pyairtable.models :members: + :inherited-members: AirtableModel API: pyairtable.models.schema diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 800b1e8b..3cdf6692 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -5,6 +5,7 @@ from typing_extensions import Self as SelfType from pyairtable._compat import pydantic +from pyairtable.utils import _append_docstring_text class AirtableModel(pydantic.BaseModel): @@ -52,7 +53,7 @@ def from_api( api: The connection to use for saving updates. context: An object, sequence of objects, or mapping of names to objects which will be used as arguments to ``str.format()`` when constructing - the URL for a :class:`~pyairtable.models._base.SerializableModel`. + the URL for a :class:`~pyairtable.models._base.RestfulModel`. """ instance = cls.parse_obj(obj) cascade_api(instance, api, context=context) @@ -104,7 +105,7 @@ def cascade_api( context = {**context, _context_name(obj): obj} # This is what we came here for - if isinstance(obj, SerializableModel): + if isinstance(obj, RestfulModel): obj.set_api(api, context=context) # Find and apply API/context to nested models in every Pydantic field. @@ -113,48 +114,95 @@ def cascade_api( cascade_api(field_value, api, context=context) -class SerializableModel(AirtableModel): +class RestfulModel(AirtableModel): """ - Base model for any data structures that can be saved back to the API. + Base model for any data structures that wrap around a REST API endpoint. + + Subclasses can pass a number of keyword arguments to control serialization behavior: + + * ``url=``: format string for building the URL to be used when saving changes to this model. + """ + + __url_pattern: ClassVar[str] = "" + + _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() + _url: str = pydantic.PrivateAttr(default="") + + def __init_subclass__(cls, **kwargs: Any) -> None: + cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) + super().__init_subclass__() + + def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: + """ + Sets a link to the API and builds the REST URL used for this resource. + + :meta private: + """ + self._api = api + self._url = self.__url_pattern.format(**context, self=self) + if self._url and not self._url.startswith("http"): + self._url = api.build_url(self._url) + + +class CanDeleteModel(RestfulModel): + """ + Mix-in for RestfulModel that allows a model to be deleted. + """ + + _deleted: bool = pydantic.PrivateAttr(default=False) + + @property + def deleted(self) -> bool: + """ + Indicates whether the record has been deleted since being returned from the API. + """ + return self._deleted + + def delete(self) -> None: + """ + Delete the record on the server and mark this instance as deleted. + """ + if not self._url: + raise RuntimeError("delete() called with no URL specified") + self._api.request("DELETE", self._url) + self._deleted = True + + +class CanUpdateModel(RestfulModel): + """ + Mix-in for RestfulModel that allows a model to be modified and saved. Subclasses can pass a number of keyword arguments to control serialization behavior: * ``writable=``: field names that should be written to API on ``save()``. * ``readonly=``: field names that should not be written to API on ``save()``. - * ``allow_update=``: boolean indicating whether to allow ``save()`` (default: true) - * ``allow_delete=``: boolean indicating whether to allow ``delete()`` (default: true) * ``save_null_values=``: boolean indicating whether ``save()`` should write nulls (default: true) - * ``url=``: format string for building the URL to be used when saving changes to this model. """ __writable: ClassVar[Optional[Iterable[str]]] = None __readonly: ClassVar[Optional[Iterable[str]]] = None - __allow_update: ClassVar[bool] = True - __allow_delete: ClassVar[bool] = True __save_none: ClassVar[bool] = True - __url_pattern: ClassVar[str] = "" def __init_subclass__(cls, **kwargs: Any) -> None: if "writable" in kwargs and "readonly" in kwargs: raise ValueError("incompatible kwargs 'writable' and 'readonly'") cls.__writable = kwargs.pop("writable", cls.__writable) cls.__readonly = kwargs.pop("readonly", cls.__readonly) - cls.__allow_update = bool(kwargs.pop("allow_update", cls.__allow_update)) - cls.__allow_delete = bool(kwargs.pop("allow_delete", cls.__allow_delete)) cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none)) - cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) + if cls.__writable: + _append_docstring_text( + cls, + "The following fields can be modified and saved: " + + ", ".join(f"``{field}``" for field in cls.__writable), + ) + if cls.__readonly: + _append_docstring_text( + cls, + "The following fields are read-only and cannot be modified:\n" + + ", ".join(f"``{field}``" for field in cls.__readonly), + ) super().__init_subclass__(**kwargs) - _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() - _url: str = pydantic.PrivateAttr(default="") - _deleted: bool = pydantic.PrivateAttr(default=False) - - def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: - self._api = api - self._url = self.__url_pattern.format(**context, self=self) - if self._url and not self._url.startswith("http"): - self._url = api.build_url(self._url) - def save(self) -> None: """ Save any changes made to the instance's writable fields and update the @@ -162,9 +210,7 @@ def save(self) -> None: Will raise ``RuntimeError`` if the record has been deleted. """ - if not self.__allow_update: - raise NotImplementedError(f"{self.__class__.__name__}.save() not allowed") - if self._deleted: + if getattr(self, "_deleted", None): raise RuntimeError("save() called after delete()") if not self._url: raise RuntimeError("save() called with no URL specified") @@ -186,24 +232,6 @@ def save(self) -> None: } ) - def delete(self) -> None: - """ - Delete the record on the server and mark this instance as deleted. - """ - if not self.__allow_delete: - raise NotImplementedError(f"{self.__class__.__name__}.delete() not allowed") - if not self._url: - raise RuntimeError("delete() called with no URL specified") - self._api.request("DELETE", self._url) - self._deleted = True - - @property - def deleted(self) -> bool: - """ - Indicates whether the record has been deleted since being returned from the API. - """ - return self._deleted - def __setattr__(self, name: str, value: Any) -> None: # Prevents implementers from changing values on readonly or non-writable fields. # Mypy can't tell that we are using pydantic v1. diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index d04ffe48..4d27a917 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,11 +1,12 @@ from typing import Dict, Optional -from ._base import AirtableModel, SerializableModel, update_forward_refs +from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs from .collaborator import Collaborator class Comment( - SerializableModel, + CanUpdateModel, + CanDeleteModel, writable=["text"], url="{record_url}/comments/{self.id}", ): @@ -56,31 +57,32 @@ class Comment( author: Collaborator #: Users or groups that were mentioned in the text. - mentioned: Optional[Dict[str, "Comment.Mentioned"]] - - class Mentioned(AirtableModel): - """ - A user or group that was mentioned within a comment. - Stored as a ``dict`` that is keyed by ID. - - >>> comment = table.add_comment(record_id, "Hello, @[usrVMNxslc6jG0Xed]!") - >>> comment.mentioned - { - "usrVMNxslc6jG0Xed": Mentioned( - display_name='Alice', - email='alice@example.com', - id='usrVMNxslc6jG0Xed', - type='user' - ) - } + mentioned: Optional[Dict[str, "Mentioned"]] + - See `User mentioned `_ for more details. - """ +class Mentioned(AirtableModel): + """ + A user or group that was mentioned within a comment. + Stored as a ``dict`` that is keyed by ID. + + >>> comment = table.add_comment(record_id, "Hello, @[usrVMNxslc6jG0Xed]!") + >>> comment.mentioned + { + "usrVMNxslc6jG0Xed": Mentioned( + display_name='Alice', + email='alice@example.com', + id='usrVMNxslc6jG0Xed', + type='user' + ) + } - id: str - type: str - display_name: str - email: Optional[str] = None + See `User mentioned `_ for more details. + """ + + id: str + type: str + display_name: str + email: Optional[str] = None update_forward_refs(vars()) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index deb4ce26..ce4d0e5e 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -5,7 +5,7 @@ from pyairtable._compat import pydantic -from ._base import AirtableModel, SerializableModel, update_forward_refs +from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs PermissionLevel: TypeAlias = Literal[ "none", "read", "comment", "edit", "create", "owner" @@ -126,8 +126,7 @@ def table(self, id_or_name: str) -> "TableSchema": class TableSchema( - SerializableModel, - allow_delete=False, + CanUpdateModel, save_null_values=False, writable=["name", "description"], url="meta/bases/{base.id}/tables/{self.id}", @@ -167,9 +166,7 @@ def view(self, id_or_name: str) -> "ViewSchema": return _find(self.views, id_or_name) -class ViewSchema( - SerializableModel, allow_update=False, url="meta/bases/{base.id}/views/{self.id}" -): +class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): """ Metadata for a view. @@ -741,8 +738,7 @@ class UnknownFieldConfig(AirtableModel): class _FieldSchemaBase( - SerializableModel, - allow_delete=False, + CanUpdateModel, save_null_values=False, writable=["name", "description"], url="meta/bases/{base.id}/tables/{table_schema.id}/fields/{self.id}", diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index c23369c8..ebd725f3 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -8,18 +8,14 @@ from pyairtable._compat import pydantic from pyairtable.api.types import RecordId -from ._base import AirtableModel, SerializableModel, update_forward_refs +from ._base import AirtableModel, CanDeleteModel, update_forward_refs # Shortcuts to avoid lots of line wrapping FD: Callable[[], Any] = partial(pydantic.Field, default_factory=dict) FL: Callable[[], Any] = partial(pydantic.Field, default_factory=list) -class Webhook( - SerializableModel, - allow_update=False, - url="bases/{base.id}/webhooks/{self.id}", -): +class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"): """ A webhook that has been retrieved from the Airtable API. diff --git a/tests/test_models.py b/tests/test_models.py index 52313c13..5a6a8857 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,8 @@ from pyairtable.models._base import ( AirtableModel, - SerializableModel, + CanDeleteModel, + CanUpdateModel, update_forward_refs, ) @@ -15,9 +16,17 @@ def raw_data(): @pytest.fixture def create_instance(api, raw_data): def _creates_instance(**kwargs): + # These kwargs used to be interpreted by __init_subclass__ but now that behavior + # is controlled by mixins. This weirdness is just to avoid redoing our tests. + base_classes = [] + if kwargs.pop("allow_update", True): + base_classes.append(CanUpdateModel) + if kwargs.pop("allow_delete", True): + base_classes.append(CanDeleteModel) + kwargs.setdefault("url", "https://example.com/{self.foo}/{self.bar}/{self.baz}") - class Subclass(SerializableModel, **kwargs): + class Subclass(*base_classes, **kwargs): foo: int bar: int baz: int @@ -42,11 +51,11 @@ def test_raw(raw_data): @pytest.mark.parametrize("prefix", ["https://api.airtable.com/v0/prefix", "prefix"]) def test_from_api(raw_data, prefix, api): """ - Test that SerializableModel.from_api persists its parameters correctly, + Test that CanUpdate.from_api persists its parameters correctly, and that if `url=` is passed to the subclass, we'll always get a valid URL. """ - class Dummy(SerializableModel, url="{prefix}/foo={self.foo}/bar={self.bar}"): + class Dummy(CanUpdateModel, url="{prefix}/foo={self.foo}/bar={self.bar}"): foo: int bar: int @@ -77,7 +86,7 @@ def test_save(requests_mock, create_instance): def test_save_not_allowed(create_instance): obj = create_instance(allow_update=False) - with pytest.raises(NotImplementedError): + with pytest.raises(AttributeError): obj.save() @@ -103,7 +112,7 @@ def test_delete(requests_mock, create_instance): def test_delete_not_allowed(create_instance): obj = create_instance(allow_delete=False) - with pytest.raises(NotImplementedError): + with pytest.raises(AttributeError): obj.delete() From 91188380b3f5c2b6b3071d959e9c5c2ce7ee9fdf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 26 Oct 2023 21:22:56 -0700 Subject: [PATCH 022/272] Workspace.info() -> Workspace.collaborators() --- docs/source/metadata.rst | 2 +- pyairtable/api/workspace.py | 15 ++++++++------- pyairtable/models/schema.py | 13 ++++++++----- tests/integration/test_integration_enterprise.py | 2 +- tests/test_api_workspace.py | 6 +++--- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index ae518c0a..5fd86515 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -50,7 +50,7 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check .. automethod:: pyairtable.Base.shares :noindex: -.. automethod:: pyairtable.Workspace.info +.. automethod:: pyairtable.Workspace.collaborators :noindex: .. automethod:: pyairtable.Enterprise.info diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 893ae8c7..51a33eed 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional, Sequence, Union -from pyairtable.models.schema import WorkspaceInfo +from pyairtable.models.schema import WorkspaceCollaborators from pyairtable.utils import cache_unless_forced, enterprise_only @@ -10,7 +10,7 @@ class Workspace: and its own set of collaborators. >>> ws = api.workspace("wspmhESAta6clCCwF") - >>> ws.info().name + >>> ws.collaborators().name 'my first workspace' >>> ws.create_base("Base Name", tables=[...]) @@ -18,10 +18,11 @@ class Workspace: Most workspace functionality is limited to users on Enterprise billing plans. """ + _collaborators: Optional[WorkspaceCollaborators] = None + def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): self.api = api self.id = workspace_id - self._info: Optional[WorkspaceInfo] = None @property def url(self) -> str: @@ -49,21 +50,21 @@ def create_base( @enterprise_only @cache_unless_forced - def info(self) -> WorkspaceInfo: + def collaborators(self) -> WorkspaceCollaborators: """ Retrieves basic information, collaborators, and invites for the given workspace, caching the result. """ params = {"include": ["collaborators", "inviteLinks"]} payload = self.api.request("GET", self.url, params=params) - return WorkspaceInfo.parse_obj(payload) + return WorkspaceCollaborators.parse_obj(payload) @enterprise_only def bases(self) -> List["pyairtable.api.base.Base"]: """ Retrieves all bases within the workspace. """ - return [self.api.base(base_id) for base_id in self.info().base_ids] + return [self.api.base(base_id) for base_id in self.collaborators().base_ids] @property @enterprise_only @@ -71,7 +72,7 @@ def name(self) -> str: """ The name of the workspace. """ - return self.info().name + return self.collaborators().name @enterprise_only def delete(self) -> None: diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index ce4d0e5e..c105ec3c 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -246,7 +246,7 @@ class EmailDomain(AirtableModel): is_sso_required: bool -class WorkspaceInfo(AirtableModel): +class WorkspaceCollaborators(AirtableModel): """ Detailed information about who can access a workspace. @@ -257,10 +257,13 @@ class WorkspaceInfo(AirtableModel): name: str created_time: str base_ids: List[str] - restrictions: "WorkspaceInfo.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip - group_collaborators: Optional["WorkspaceInfo.GroupCollaborators"] = None - individual_collaborators: Optional["WorkspaceInfo.IndividualCollaborators"] = None - invite_links: Optional["WorkspaceInfo.InviteLinks"] = None + # We really don't need black to wrap these lines of text. + # fmt: off + restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") + group_collaborators: Optional["WorkspaceCollaborators.GroupCollaborators"] = None + individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None + invite_links: Optional["WorkspaceCollaborators.InviteLinks"] = None + # fmt: on class Restrictions(AirtableModel): invite_creation: str = pydantic.Field(alias="inviteCreationRestriction") diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py index 14bff295..3fef91ea 100644 --- a/tests/integration/test_integration_enterprise.py +++ b/tests/integration/test_integration_enterprise.py @@ -21,7 +21,7 @@ def workspace(api: pyairtable.Api, workspace_id): @pytest.fixture(autouse=True) def confirm_enterprise_plan(workspace: pyairtable.Workspace): try: - workspace.info() + workspace.collaborators() except HTTPError: pytest.skip("This test requires creator access to an enterprise workspace") diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index 69114e2c..0668b5db 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -19,9 +19,9 @@ def mock_info(workspace, requests_mock, sample_json): return requests_mock.get(workspace.url, json=sample_json("Workspace")) -def test_info(workspace, mock_info): - assert workspace.info().id == "wspmhESAta6clCCwF" - assert workspace.info().name == "my first workspace" +def test_collaborators(workspace, mock_info): + assert workspace.collaborators().id == "wspmhESAta6clCCwF" + assert workspace.collaborators().name == "my first workspace" assert mock_info.call_count == 1 From 192dd86726b2b6a6d29d5e9435969b1514ea3e9c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 26 Oct 2023 22:08:51 -0700 Subject: [PATCH 023/272] Add new fields in UserInfo, UserGroup --- pyairtable/api/base.py | 8 +-- pyairtable/api/enterprise.py | 6 +- pyairtable/models/schema.py | 69 ++++++++++++++----- .../{Enterprise.json => EnterpriseInfo.json} | 0 .../sample_data/{User.json => UserInfo.json} | 4 +- ...space.json => WorkspaceCollaborators.json} | 0 tests/test_api_api.py | 2 +- tests/test_api_enterprise.py | 9 ++- tests/test_api_workspace.py | 2 +- tests/test_models_schema.py | 39 ++++++++++- 10 files changed, 106 insertions(+), 33 deletions(-) rename tests/sample_data/{Enterprise.json => EnterpriseInfo.json} (100%) rename tests/sample_data/{User.json => UserInfo.json} (93%) rename tests/sample_data/{Workspace.json => WorkspaceCollaborators.json} (100%) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 7aa5fb1b..0a0e4025 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -6,7 +6,7 @@ from pyairtable.models.schema import ( BaseCollaborators, BaseSchema, - BaseShare, + BaseShares, PermissionLevel, ) from pyairtable.models.webhook import ( @@ -35,7 +35,7 @@ class Base: # Cached metadata to reduce API calls _collaborators: Optional[BaseCollaborators] = None _schema: Optional[BaseSchema] = None - _shares: Optional[List[BaseShare]] = None + _shares: Optional[List[BaseShares.Info]] = None def __init__( self, @@ -291,12 +291,12 @@ def collaborators(self) -> "BaseCollaborators": @enterprise_only @cache_unless_forced - def shares(self) -> List[BaseShare]: + def shares(self) -> List[BaseShares.Info]: """ Retrieves `base shares `__. """ data = self.api.request("GET", self.meta_url("shares")) - return [BaseShare.parse_obj(share) for share in data["shares"]] + return BaseShares.parse_obj(data).shares @enterprise_only def delete(self) -> None: diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 588d2396..c8540b55 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,6 +1,6 @@ from typing import Dict, Iterable, List, Optional -from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo +from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import cache_unless_forced, enterprise_only, is_user_id @@ -32,9 +32,9 @@ def info(self) -> EnterpriseInfo: payload = self.api.request("GET", self.url, params=params) return EnterpriseInfo.parse_obj(payload) - def group(self, group_id: str) -> GroupInfo: + def group(self, group_id: str) -> UserGroup: url = self.api.build_url(f"meta/groups/{group_id}") - return GroupInfo.parse_obj(self.api.request("GET", url)) + return UserGroup.parse_obj(self.api.request("GET", url)) def user(self, id_or_email: str) -> UserInfo: """ diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index c105ec3c..47a1efc3 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -90,23 +90,26 @@ class InviteLinks(AirtableModel): workspace_invite_links: List["InviteLink"] = _FL() -class BaseShare(AirtableModel): +class BaseShares(AirtableModel): """ - Detailed information about a shared view. + Collection of shared views in a base. See https://airtable.com/developers/web/api/list-shares """ - state: str - created_by_user_id: str - created_time: str - share_id: str - type: str - is_password_protected: bool - block_installation_id: Optional[str] = None - restricted_to_email_domains: List[str] = _FL() - view_id: Optional[str] = None - effective_email_domain_allow_list: List[str] = _FL() + shares: List["BaseShares.Info"] + + class Info(AirtableModel): + state: str + created_by_user_id: str + created_time: str + share_id: str + type: str + is_password_protected: bool + block_installation_id: Optional[str] = None + restricted_to_email_domains: List[str] = _FL() + view_id: Optional[str] = None + effective_email_domain_allow_list: List[str] = _FL() class BaseSchema(AirtableModel): @@ -301,14 +304,45 @@ class UserInfo(AirtableModel): name: str email: str state: str + is_sso_required: bool + is_two_factor_auth_enabled: bool + last_activity_time: Optional[str] created_time: Optional[str] + enterprise_user_type: Optional[str] invited_to_airtable_by_user_id: Optional[str] - last_activity_time: Optional[str] - is_managed: bool + is_managed: bool = False + collaborations: Optional["Collaborations"] groups: List[NestedId] = pydantic.Field(default_factory=list) -class GroupInfo(AirtableModel): +class Collaborations(AirtableModel): + """ + The full set of collaborations granted to a user or user group. + + See https://airtable.com/developers/web/api/model/collaborations + """ + + base_collaborations: List["Collaborations.BaseCollaboration"] + interface_collaborations: List["Collaborations.InterfaceCollaboration"] + workspace_collaborations: List["Collaborations.WorkspaceCollaboration"] + + class BaseCollaboration(AirtableModel): + base_id: str + created_time: str + granted_by_user_id: str + permission_level: PermissionLevel + + class InterfaceCollaboration(BaseCollaboration): + interface_id: str + + class WorkspaceCollaboration(AirtableModel): + workspace_id: str + created_time: str + granted_by_user_id: str + permission_level: PermissionLevel + + +class UserGroup(AirtableModel): """ Detailed information about a user group and its members. @@ -320,9 +354,10 @@ class GroupInfo(AirtableModel): enterprise_account_id: str created_time: str updated_time: str - members: List["GroupInfo.GroupMember"] + members: List["UserGroup.Member"] + collaborations: Optional["Collaborations"] - class GroupMember(AirtableModel): + class Member(AirtableModel): user_id: str email: str first_name: str diff --git a/tests/sample_data/Enterprise.json b/tests/sample_data/EnterpriseInfo.json similarity index 100% rename from tests/sample_data/Enterprise.json rename to tests/sample_data/EnterpriseInfo.json diff --git a/tests/sample_data/User.json b/tests/sample_data/UserInfo.json similarity index 93% rename from tests/sample_data/User.json rename to tests/sample_data/UserInfo.json index d23ce89b..2097ffe3 100644 --- a/tests/sample_data/User.json +++ b/tests/sample_data/UserInfo.json @@ -39,7 +39,9 @@ "id": "usrL2PNC5o3H4lBEi", "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk", "isManaged": true, + "isSsoRequired": true, + "isTwoFactorAuthEnabled": false, "lastActivityTime": "2019-01-03T12:33:12.421Z", - "name": "Jane Doe", + "name": "foo baz", "state": "provisioned" } diff --git a/tests/sample_data/Workspace.json b/tests/sample_data/WorkspaceCollaborators.json similarity index 100% rename from tests/sample_data/Workspace.json rename to tests/sample_data/WorkspaceCollaborators.json diff --git a/tests/test_api_api.py b/tests/test_api_api.py index d377fdaa..8e7a3b7e 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -101,7 +101,7 @@ def test_workspace(api): def test_enterprise(api, requests_mock, sample_json): url = api.build_url("meta/enterpriseAccount/entUBq2RGdihxl3vU") - requests_mock.get(url, json=sample_json("Enterprise")) + requests_mock.get(url, json=sample_json("EnterpriseInfo")) enterprise = api.enterprise("entUBq2RGdihxl3vU") assert enterprise.id == "entUBq2RGdihxl3vU" diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index c99887eb..e62b065e 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -3,7 +3,7 @@ import pytest from pyairtable.api.enterprise import Enterprise -from pyairtable.models.schema import EnterpriseInfo, GroupInfo, UserInfo +from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo @pytest.fixture @@ -13,13 +13,13 @@ def enterprise(api): @pytest.fixture def enterprise_mocks(enterprise, requests_mock, sample_json): - user_json = sample_json("User") + user_json = sample_json("UserInfo") group_json = sample_json("UserGroup") m = Mock() m.user_id = user_json["id"] m.get_info = requests_mock.get( enterprise.url, - json=sample_json("Enterprise"), + json=sample_json("EnterpriseInfo"), ) m.get_user = requests_mock.get( f"{enterprise.url}/users/{m.user_id}", @@ -50,7 +50,6 @@ def test_info(enterprise, enterprise_mocks): def test_user(enterprise, enterprise_mocks): user = enterprise.user(enterprise_mocks.user_id) assert isinstance(user, UserInfo) - assert user.name == "Jane Doe" assert enterprise_mocks.get_user.call_count == 1 @@ -78,7 +77,7 @@ def test_users__invalid_value(enterprise, enterprise_mocks): def test_group(enterprise, enterprise_mocks): info = enterprise.group("ugp1mKGb3KXUyQfOZ") assert enterprise_mocks.get_group.call_count == 1 - assert isinstance(info, GroupInfo) + assert isinstance(info, UserGroup) assert info.id == "ugp1mKGb3KXUyQfOZ" assert info.name == "Group name" assert info.members[0].email == "foo@bar.com" diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index 0668b5db..ad66933e 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -16,7 +16,7 @@ def workspace(api, workspace_id): @pytest.fixture def mock_info(workspace, requests_mock, sample_json): - return requests_mock.get(workspace.url, json=sample_json("Workspace")) + return requests_mock.get(workspace.url, json=sample_json("WorkspaceCollaborators")) def test_collaborators(workspace, mock_info): diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index ae505b42..a8775213 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -1,4 +1,4 @@ -from operator import attrgetter +from operator import attrgetter, itemgetter import pytest @@ -42,3 +42,40 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json): cls = attrgetter(clsname)(pyairtable.models.schema) obj = cls.parse_obj(sample_json(clsname)) assert getattr(obj, method)(id_or_name) + + +@pytest.mark.parametrize( + "test_case", + { + "BaseCollaborators.individual_collaborators.via_base[0].permission_level": "create", + "BaseCollaborators.individual_collaborators.via_base[0].user_id": "usrsOEchC9xuwRgKk", + "BaseSchema.tables[0].fields[1].type": "multipleAttachments", + "BaseSchema.tables[0].fields[2].options.inverse_link_field_id": "fldWnCJlo2z6ttT8Y", + "BaseSchema.tables[0].name": "Apartments", + "BaseSchema.tables[0].views[0].type": "grid", + "BaseShares.shares[0].effective_email_domain_allow_list": ["foobar.com"], + "BaseShares.shares[2].state": "disabled", + "EnterpriseInfo.email_domains[0].email_domain": "foobar.com", + "EnterpriseInfo.email_domains[0].is_sso_required": True, + "UserGroup.collaborations.base_collaborations[0].base_id": "appLkNDICXNqxSDhG", + "UserGroup.members[1].user_id": "usrsOEchC9xuwRgKk", + "UserInfo.collaborations.interface_collaborations[0].interface_id": "pbdyGA3PsOziEHPDE", + "UserInfo.is_sso_required": True, + "UserInfo.is_two_factor_auth_enabled": False, + "UserInfo.name": "foo baz", + "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"], + "WorkspaceCollaborators.invite_links.base_invite_links[0].id": "invJiqaXmPqq6Ec87", + }.items(), + ids=itemgetter(0), +) +def test_deserialized_values(test_case, sample_json): + """ + Spot check that certain values get loaded correctly from JSON into Python. + This is not intended to be comprehensive, just another chance to catch regressions. + """ + clsname_attr, expected = test_case + clsname = clsname_attr.split(".")[0] + cls = attrgetter(clsname)(pyairtable.models.schema) + obj = cls.parse_obj(sample_json(clsname)) + val = eval(clsname_attr, None, {clsname: obj}) + assert val == expected From 488d257a1bde52794dfee4aad58e7d4a0fd23203 Mon Sep 17 00:00:00 2001 From: Valentin Bourgoin Date: Tue, 17 Oct 2023 12:21:30 +0200 Subject: [PATCH 024/272] Fix webhook signature header --- pyairtable/models/webhook.py | 2 +- tests/test_models_webhook.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 61a04e99..3476e6ae 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -233,7 +233,7 @@ def from_request( if isinstance(secret, str): secret = base64.decodebytes(secret.encode("ascii")) hmac = HMAC(secret, body.encode("ascii"), "sha256") - expected = "hmac-sha256-" + hmac.hexdigest() + expected = "hmac-sha256=" + hmac.hexdigest() if header != expected: raise ValueError("X-Airtable-Content-MAC header failed validation") return cls.parse_raw(body) diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py index 5a83a76b..157035d8 100644 --- a/tests/test_models_webhook.py +++ b/tests/test_models_webhook.py @@ -186,7 +186,7 @@ def test_notification_from_request(secret): "timestamp": "2022-02-01T21:25:05.663Z", } header = ( - "hmac-sha256-e26da696a90933647bddc83995c3e1e3bb1c3d8ce1ff61cb7469767d50b2b2d4" + "hmac-sha256=e26da696a90933647bddc83995c3e1e3bb1c3d8ce1ff61cb7469767d50b2b2d4" ) body = json.dumps(notification_json) From 6f114fced6b95307139db23b68c31309732551ca Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 31 Oct 2023 23:30:19 -0700 Subject: [PATCH 025/272] Fix one lingering mypy complaint --- pyairtable/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index f44a425c..988e8731 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -151,7 +151,7 @@ def _decorated(*args: Any, **kwargs: Any) -> Any: try: return wrapped(*args, **kwargs) except requests.exceptions.HTTPError as exc: - if exc.response.status_code == 404: + if exc.response is not None and exc.response.status_code == 404: exc.args = ( *exc.args, f"NOTE: {wrapped.__qualname__}() requires an enterprise billing plan.", From 98d07cb9223848453c187e266c0cae82c23c3942 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 2 Nov 2023 19:38:54 -0700 Subject: [PATCH 026/272] Clean up edge case affecting non-true values in @cache_unless_forced --- pyairtable/api/api.py | 6 +++--- pyairtable/utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 1c0e7a64..a7fc0964 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -37,6 +37,9 @@ class Api: #: Airtable-imposed limit on the length of a URL (including query parameters). MAX_URL_LENGTH = 16000 + # Cached metadata to reduce API calls + _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None + def __init__( self, api_key: str, @@ -71,9 +74,6 @@ def __init__( self.timeout = timeout self.api_key = api_key - self._bases: Dict[str, "pyairtable.api.base.Base"] = {} - self._base_info: Optional[Bases] = None - @property def api_key(self) -> str: """ diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 988e8731..b6e08152 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -198,7 +198,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: @wraps(func) def _inner(self: Any, *, force: bool = False) -> R: - if force or not getattr(self, attr, None): + if force or getattr(self, attr, None) is None: setattr(self, attr, func(self)) return cast(R, getattr(self, attr)) From a2c6898fb1008a7d964e5c86b4b38569f8f20571 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 4 Nov 2023 21:37:04 -0700 Subject: [PATCH 027/272] Clean up test coverage for metadata branch --- pyairtable/utils.py | 4 ++-- tests/test_models_schema.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index b6e08152..8559a33e 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -181,10 +181,10 @@ def _append_docstring_text(obj: Any, text: str) -> None: class FetchMethod(Protocol, Generic[R]): def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: - ... + ... # pragma: no cover def __call__(self_, self: Any, *, force: bool = False) -> R: - ... + ... # pragma: no cover def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index a8775213..c6311918 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -1,8 +1,10 @@ from operator import attrgetter, itemgetter +from typing import List, Optional import pytest import pyairtable.models.schema +from pyairtable.models._base import AirtableModel @pytest.mark.parametrize( @@ -79,3 +81,38 @@ def test_deserialized_values(test_case, sample_json): obj = cls.parse_obj(sample_json(clsname)) val = eval(clsname_attr, None, {clsname: obj}) assert val == expected + + +class Outer(AirtableModel): + inners: List["Outer.Inner"] + + class Inner(AirtableModel): + id: str + name: str + deleted: Optional[bool] = None + + def find(self, id_or_name): + return pyairtable.models.schema._find(self.inners, id_or_name) + + +def test_find(): + """ + Test that _find() retrieves an object based on ID or name, + and skips any models that are marked as deleted. + """ + + collection = Outer.parse_obj( + { + "inners": [ + {"id": "0001", "name": "One"}, + {"id": "0002", "name": "Two"}, + {"id": "0003", "name": "Three", "deleted": True}, + ] + } + ) + assert collection.find("0001").id == "0001" + assert collection.find("Two").id == "0002" + with pytest.raises(KeyError): + collection.find("0003") + with pytest.raises(KeyError): + collection.find("0004") From c0904c9d159382f7d81cb74e60d2c53e97cac563 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 4 Nov 2023 22:38:33 -0700 Subject: [PATCH 028/272] Do not use Literal for permissionLevel for future compatibility --- pyairtable/api/base.py | 11 +++-------- pyairtable/models/schema.py | 18 +++++++----------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 0a0e4025..9ca0e083 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -3,12 +3,7 @@ import pyairtable.api.api import pyairtable.api.table -from pyairtable.models.schema import ( - BaseCollaborators, - BaseSchema, - BaseShares, - PermissionLevel, -) +from pyairtable.models.schema import BaseCollaborators, BaseSchema, BaseShares from pyairtable.models.webhook import ( CreateWebhook, CreateWebhookResponse, @@ -30,7 +25,7 @@ class Base: id: str #: The permission level the current user has on the base - permission_level: Optional[PermissionLevel] + permission_level: Optional[str] # Cached metadata to reduce API calls _collaborators: Optional[BaseCollaborators] = None @@ -43,7 +38,7 @@ def __init__( base_id: str, *, name: Optional[str] = None, - permission_level: Optional[PermissionLevel] = None, + permission_level: Optional[str] = None, ): """ Old style constructor takes ``str`` arguments, and will create its own diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 47a1efc3..21bf89ae 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -7,10 +7,6 @@ from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs -PermissionLevel: TypeAlias = Literal[ - "none", "read", "comment", "edit", "create", "owner" -] - _T = TypeVar("_T", bound=Any) _FL = partial(pydantic.Field, default_factory=list) _FD = partial(pydantic.Field, default_factory=dict) @@ -50,7 +46,7 @@ def base(self, base_id: str) -> "Bases.Info": class Info(AirtableModel): id: str name: str - permission_level: PermissionLevel + permission_level: str class BaseCollaborators(AirtableModel): @@ -62,7 +58,7 @@ class BaseCollaborators(AirtableModel): id: str name: str - permission_level: PermissionLevel + permission_level: str workspace_id: str interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD() group_collaborators: Optional["BaseCollaborators.GroupCollaborators"] @@ -196,7 +192,7 @@ class GroupCollaborator(AirtableModel): granted_by_user_id: str group_id: str name: str - permission_level: PermissionLevel + permission_level: str class IndividualCollaborator(AirtableModel): @@ -204,7 +200,7 @@ class IndividualCollaborator(AirtableModel): granted_by_user_id: str user_id: str email: str - permission_level: PermissionLevel + permission_level: str class InviteLink(AirtableModel): @@ -213,7 +209,7 @@ class InviteLink(AirtableModel): created_time: str invited_email: Optional[str] referred_by_user_id: str - permission_level: PermissionLevel + permission_level: str restricted_to_email_domains: List[str] = _FL() @@ -330,7 +326,7 @@ class BaseCollaboration(AirtableModel): base_id: str created_time: str granted_by_user_id: str - permission_level: PermissionLevel + permission_level: str class InterfaceCollaboration(BaseCollaboration): interface_id: str @@ -339,7 +335,7 @@ class WorkspaceCollaboration(AirtableModel): workspace_id: str created_time: str granted_by_user_id: str - permission_level: PermissionLevel + permission_level: str class UserGroup(AirtableModel): From 7ea6cf356e2fe0a31f0b49a61c6e30fc98c13bd0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 6 Nov 2023 22:50:12 -0800 Subject: [PATCH 029/272] Last minute metadata doc cleanup --- docs/source/conf.py | 2 ++ docs/source/metadata.rst | 57 ++++++++++++++++++++++--------------- pyairtable/api/api.py | 4 +-- pyairtable/api/base.py | 9 ++++-- pyairtable/api/table.py | 6 ---- pyairtable/models/schema.py | 38 ++++++++++++++++++++++--- 6 files changed, 79 insertions(+), 37 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 3d50c94f..015095dd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -49,6 +49,8 @@ # See https://autodoc-pydantic.readthedocs.io/en/v1.9.0/users/configuration.html autodoc_pydantic_field_show_alias = False +autodoc_pydantic_field_show_default = False +autodoc_pydantic_field_show_required = False autodoc_pydantic_model_member_order = "bysource" autodoc_pydantic_model_show_config_summary = False autodoc_pydantic_model_show_field_summary = False diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 5fd86515..228e3c2d 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -57,7 +57,25 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check :noindex: -Modifying schema elements +Modifying existing schema +----------------------------- + +To modify a table or field, you can modify its schema object directly and +call ``save()``, as shown below. You can only change names and descriptions; +the Airtable API does not permit changing a field's type or other options. + +.. code-block:: python + + >>> schema = table.schema() + >>> schema.name = "Renamed" + >>> schema.save() + >>> field = schema.field("Name") + >>> field.name = "Label" + >>> field.description = "The primary field on the table" + >>> field.save() + + +Creating schema elements ----------------------------- The following methods allow creating bases, tables, or fields: @@ -77,33 +95,26 @@ The following methods allow creating bases, tables, or fields: .. automethod:: pyairtable.Table.create_field :noindex: -To modify a table or field, you can modify its schema object directly -and call ``save()``, as below. You can only rename a field or modify -its description; the Airtable API does not permit changing its type -or other field options. - -.. code-block:: python - - >>> schema = table.schema() - >>> schema.name = "Renamed" - >>> schema.save() - >>> field = schema.field("Name") - >>> field.name = "Label" - >>> field.description = "The primary field on the table" - >>> field.save() - Deleting schema elements ----------------------------- +|enterprise_only| + The Airtable API does not allow deleting tables or fields, but it does allow -deleting workspaces, bases, and views. pyAirtable exposes those via these methods: +deleting workspaces, bases, and views. pyAirtable supports the following methods: -.. automethod:: pyairtable.Workspace.delete - :noindex: +To delete a :class:`~pyairtable.Workspace`: -.. automethod:: pyairtable.Base.delete - :noindex: + >>> ws = api.workspace("wspmhESAta6clCCwF") + >>> ws.delete() -.. automethod:: pyairtable.models.schema.ViewSchema.delete - :noindex: +To delete a :class:`~pyairtable.Base`: + + >>> base = api.base("appMxESAta6clCCwF") + >>> base.delete() + +To delete a view, first retrieve its :class:`~pyairtable.models.schema.ViewSchema`: + + >>> vw = table.schema().view("View Name") + >>> vw.delete() diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index a7fc0964..a56bf6df 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -129,8 +129,8 @@ def bases(self) -> Dict[str, "pyairtable.api.base.Base"]: Usage: >>> api.bases() { - 'appSW9R5uCNmRmfl6': , - 'appLkNDICXNqxSDhG': + 'appSW9...': , + 'appLkN...': } """ url = self.build_url("meta/bases") diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 9ca0e083..96164c49 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -16,6 +16,11 @@ class Base: """ Represents an Airtable base. + + Usage: + >>> base = api.base("appNxslc6jG0XedVM") + >>> table = base.table("Table Name") + >>> records = table.all() """ #: The connection to the Airtable API. @@ -121,8 +126,8 @@ def tables(self, *, force: bool = False) -> List["pyairtable.api.table.Table"]: Usage: >>> base.tables() [ -
, -
+
, +
] """ return [ diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 35e6a378..d8800dd1 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -614,12 +614,6 @@ def schema(self, *, force: bool = False) -> TableSchema: fields=[...], views=[...] ) - >>> table.schema().field("fld6jG0XedVMNxFQW") - SingleLineTextFieldSchema( - id='fld6jG0XedVMNxFQW', - name='Name', - type='singleLineText' - ) Args: force: |kwarg_force_metadata| diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 21bf89ae..6d83cf13 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -113,6 +113,20 @@ class BaseSchema(AirtableModel): Schema of all tables within the base. See https://airtable.com/developers/web/api/get-base-schema + + Usage: + >>> schema = api.base(base_id).schema() + >>> schema.tables + [TableSchema(...), ...] + >>> schema.table("Table Name") + TableSchema( + id='tbl6jG0XedVMNxFQW', + name='Table Name', + primary_field_id='fld0XedVMNxFQW6jG', + description=None, + fields=[...], + views=[...] + ) """ tables: List["TableSchema"] @@ -133,16 +147,32 @@ class TableSchema( """ Metadata for a table. + See https://airtable.com/developers/web/api/get-base-schema + Usage: >>> schema = base.table("Table Name").schema() >>> schema.id 'tbl6clmhESAtaCCwF' + >>> schema.name + 'Table Name' + >>> schema.fields [FieldSchema(...), ...] + >>> schema().field("fld6jG0XedVMNxFQW") + SingleLineTextFieldSchema( + id='fld6jG0XedVMNxFQW', + name='Name', + type='singleLineText' + ) + >>> schema.views [ViewSchema(...), ...] - - See https://airtable.com/developers/web/api/get-base-schema + >>> schema().view("View Name") + ViewSchema( + id='viw6jG0XedVMNxFQW', + name='My Grid View', + type='grid' + ) """ id: str @@ -169,6 +199,8 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): """ Metadata for a view. + See https://airtable.com/developers/web/api/get-view-metadata + Usage: >>> vw = table.schema().view("View name") >>> vw.name @@ -176,8 +208,6 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): >>> vw.type 'grid' >>> vw.delete() - - See https://airtable.com/developers/web/api/get-view-metadata """ id: str From 3279954800d234f9dbda2e9608b47159195122e0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 6 Nov 2023 23:17:34 -0800 Subject: [PATCH 030/272] Do not memoize mutable data structures derived from metadata API --- pyairtable/api/api.py | 33 ++++++++++++++++++++------------- pyairtable/utils.py | 2 ++ tests/test_api_api.py | 2 +- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index a56bf6df..09adc75a 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -107,7 +107,7 @@ def base( validate: bool = False, ) -> "pyairtable.api.base.Base": """ - Returns a new :class:`Base` instance that uses this instance of :class:`Api`. + Return a new :class:`Base` instance that uses this instance of :class:`Api`. Args: base_id: |arg_base_id| @@ -121,20 +121,12 @@ def base( return pyairtable.api.base.Base(self, base_id) @cache_unless_forced - def bases(self) -> Dict[str, "pyairtable.api.base.Base"]: + def _base_info(self) -> Bases: """ - Retrieves a list of all bases from the API and caches it, - returning a mapping of IDs to :class:`Base` instances. - - Usage: - >>> api.bases() - { - 'appSW9...': , - 'appLkN...': - } + Return a schema object that represents all bases available via the API. """ url = self.build_url("meta/bases") - collection = Bases.parse_obj( + return Bases.parse_obj( { "bases": [ base_info @@ -143,6 +135,21 @@ def bases(self) -> Dict[str, "pyairtable.api.base.Base"]: ] } ) + + def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: + """ + Build a mapping of IDs to :class:`Base` instances. + + Args: + force: |kwarg_force_metadata| + + Usage: + >>> api.bases() + { + 'appSW9...': , + 'appLkN...': + } + """ return { info.id: pyairtable.api.base.Base( self, @@ -150,7 +157,7 @@ def bases(self) -> Dict[str, "pyairtable.api.base.Base"]: name=info.name, permission_level=info.permission_level, ) - for info in collection.bases + for info in self._base_info(force=force).bases } def create_base( diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 8559a33e..cf27dc69 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -195,6 +195,8 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: """ attr = f"_{func.__name__}" + if attr.startswith("__"): + attr = "_cached_" + attr.lstrip("_") @wraps(func) def _inner(self: Any, *, force: bool = False) -> R: diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 8e7a3b7e..cb612bc2 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -69,7 +69,7 @@ def test_bases(api, requests_mock, sample_json): assert bases["appLkNDICXNqxSDhG"].id == "appLkNDICXNqxSDhG" # Should not make a second API call... - assert api.bases() == bases + assert set(api.bases()) == set(bases) assert m.call_count == 1 # ....unless we force it: reloaded = api.bases(force=True) From be0c5526aa32a21952bd857e2f6a7542b685b870 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 7 Nov 2023 18:02:04 -0800 Subject: [PATCH 031/272] Api.bases() should return a list (not dict) for consistency --- pyairtable/api/api.py | 23 ++++++++++++----------- tests/test_api_api.py | 9 ++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 09adc75a..09373480 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -1,5 +1,5 @@ import posixpath -from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, TypeVar, Union +from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union import requests from requests.sessions import Session @@ -117,7 +117,8 @@ def base( KeyError: if ``fetch=True`` and the given base ID does not exist. """ if validate: - return self.bases(force=True)[base_id] + bases = {base.id: base for base in self.bases(force=True)} + return bases[base_id] return pyairtable.api.base.Base(self, base_id) @cache_unless_forced @@ -136,29 +137,29 @@ def _base_info(self) -> Bases: } ) - def bases(self, *, force: bool = False) -> Dict[str, "pyairtable.api.base.Base"]: + def bases(self, *, force: bool = False) -> List["pyairtable.api.base.Base"]: """ - Build a mapping of IDs to :class:`Base` instances. + Retrieve the base's schema and return a list of :class:`Base` instances. Args: force: |kwarg_force_metadata| Usage: >>> api.bases() - { - 'appSW9...': , - 'appLkN...': - } + [ + , + + ] """ - return { - info.id: pyairtable.api.base.Base( + return [ + pyairtable.api.base.Base( self, info.id, name=info.name, permission_level=info.permission_level, ) for info in self._base_info(force=force).bases - } + ] def create_base( self, diff --git a/tests/test_api_api.py b/tests/test_api_api.py index cb612bc2..6ead1686 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -63,17 +63,16 @@ def test_whoami(api, requests_mock): def test_bases(api, requests_mock, sample_json): m = requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases")) - bases = api.bases() + base_ids = [base.id for base in api.bases()] assert m.call_count == 1 - assert set(bases) == {"appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"} - assert bases["appLkNDICXNqxSDhG"].id == "appLkNDICXNqxSDhG" + assert base_ids == ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"] # Should not make a second API call... - assert set(api.bases()) == set(bases) + assert [base.id for base in api.bases()] == base_ids assert m.call_count == 1 # ....unless we force it: reloaded = api.bases(force=True) - assert set(reloaded) == set(bases) + assert [base.id for base in reloaded] == base_ids assert m.call_count == 2 From 1bbaad6d5fa83fd8df879e987dea221a6a844385 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 7 Nov 2023 19:49:45 -0800 Subject: [PATCH 032/272] Last minute doc/test cleanups --- docs/source/metadata.rst | 2 +- pyairtable/api/api.py | 20 +++---- pyairtable/api/base.py | 22 +++---- pyairtable/api/enterprise.py | 6 +- pyairtable/api/params.py | 6 +- pyairtable/api/retrying.py | 2 +- pyairtable/api/table.py | 57 ++++++++++++------- pyairtable/api/types.py | 4 +- pyairtable/api/workspace.py | 18 ++++-- pyairtable/formulas.py | 14 ++--- pyairtable/models/_base.py | 4 +- pyairtable/models/schema.py | 8 +-- pyairtable/models/webhook.py | 2 +- pyairtable/orm/fields.py | 20 +++---- pyairtable/orm/model.py | 26 +++++---- pyairtable/testing.py | 21 +++++-- pyairtable/utils.py | 12 ++-- .../integration/test_integration_metadata.py | 2 +- tests/test_testing.py | 45 +++++++++++++++ 19 files changed, 186 insertions(+), 105 deletions(-) create mode 100644 tests/test_testing.py diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index 228e3c2d..e7f020b2 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -8,7 +8,7 @@ Metadata The Airtable API gives you the ability to list all of your bases, tables, fields, and views. pyAirtable allows you to inspect and interact with the metadata in your bases. -There may be parts of the pyAirtable API which are not supported below; +There may be parts of the Airtable API which are not supported below; you can always use :meth:`Api.request ` to call them directly. diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 09373480..682ff549 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -168,7 +168,9 @@ def create_base( tables: Sequence[Dict[str, Any]], ) -> "pyairtable.api.base.Base": """ - Creates a base in the given workspace. + Create a base in the given workspace. + + See https://airtable.com/developers/web/api/create-base Args: workspace_id: The ID of the workspace where the new base will live. @@ -180,13 +182,13 @@ def create_base( def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table": """ - Returns a new :class:`Table` instance that uses this instance of :class:`Api`. + Build a new :class:`Table` instance that uses this instance of :class:`Api`. """ return self.base(base_id).table(table_name) def build_url(self, *components: str) -> str: """ - Returns a URL to the Airtable API endpoint with the given URL components, + Build a URL to the Airtable API endpoint with the given URL components, including the API version number. """ return posixpath.join(self.endpoint_url, self.VERSION, *components) @@ -201,10 +203,8 @@ def request( json: Optional[Dict[str, Any]] = None, ) -> Any: """ - Makes a request to the Airtable API, optionally converting a GET to a POST - if the URL exceeds the API's maximum URL length. - - See https://support.airtable.com/docs/enforcement-of-url-length-limit-for-web-api-requests + Make a request to the Airtable API, optionally converting a GET to a POST if the URL exceeds the + `maximum URL length `__. Args: method: HTTP method to use. @@ -278,7 +278,7 @@ def iterate_requests( offset_field: str = "offset", ) -> Iterator[Any]: """ - Makes one or more requests and iterates through each result. + Make one or more requests and iterates through each result. If the response payload contains an 'offset' value, this method will perform another request with that offset value as a parameter (query params for GET, @@ -308,7 +308,7 @@ def iterate_requests( def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: """ - Iterates through chunks of the given sequence that are equal in size + Iterate through chunks of the given sequence that are equal in size to the maximum number of records per request allowed by the API. """ return chunked(iterable, self.MAX_RECORDS_PER_REQUEST) @@ -316,7 +316,7 @@ def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: @enterprise_only def enterprise(self, enterprise_account_id: str) -> Enterprise: """ - Returns an object representing an enterprise account. + Build an object representing an enterprise account. """ return Enterprise(self, enterprise_account_id) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 96164c49..881fe431 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -100,7 +100,7 @@ def table( validate: bool = False, ) -> "pyairtable.api.table.Table": """ - Returns a new :class:`Table` instance using this instance of :class:`Base`. + Build a new :class:`Table` instance using this instance of :class:`Base`. Args: id_or_name: An Airtable table ID or name. Table name should be unencoded, @@ -118,7 +118,7 @@ def table( def tables(self, *, force: bool = False) -> List["pyairtable.api.table.Table"]: """ - Retrieves the base's schema and returns a list of :class:`Table` instances. + Retrieve the base's schema and returns a list of :class:`Table` instances. Args: force: |kwarg_force_metadata| @@ -142,7 +142,7 @@ def create_table( description: Optional[str] = None, ) -> "pyairtable.api.table.Table": """ - Creates a table in the given base. + Create a table in the given base. Args: name: The unique table name. @@ -163,14 +163,14 @@ def url(self) -> str: def meta_url(self, *components: Any) -> str: """ - Builds a URL to a metadata endpoint for this base. + Build a URL to a metadata endpoint for this base. """ return self.api.build_url("meta/bases", self.id, *components) @cache_unless_forced def schema(self) -> BaseSchema: """ - Retrieves the schema of all tables in the base and caches it. + Retrieve the schema of all tables in the base and caches it. Usage: >>> base.schema().tables @@ -191,7 +191,7 @@ def webhooks_url(self) -> str: def webhooks(self) -> List[Webhook]: """ - Retrieves all the base's webhooks + Retrieve all the base's webhooks (see: `List webhooks `_). Usage: @@ -218,7 +218,7 @@ def webhooks(self) -> List[Webhook]: def webhook(self, webhook_id: str) -> Webhook: """ - Returns a single webhook or raises ``KeyError`` if the given ID is invalid. + Build a single webhook or raises ``KeyError`` if the given ID is invalid. Airtable's API does not permit retrieving a single webhook, so this function will call :meth:`~webhooks` and simply return one item from the list. @@ -234,7 +234,7 @@ def add_webhook( spec: Union[WebhookSpecification, Dict[Any, Any]], ) -> CreateWebhookResponse: """ - Creates a webhook on the base with the given + Create a webhook on the base with the given `webhooks specification `_. The return value will contain a unique secret that must be saved @@ -283,7 +283,7 @@ def add_webhook( @cache_unless_forced def collaborators(self) -> "BaseCollaborators": """ - Retrieves `base collaborators `__. + Retrieve `base collaborators `__. """ params = {"include": ["collaborators", "inviteLinks", "interfaces"]} data = self.api.request("GET", self.meta_url(), params=params) @@ -293,7 +293,7 @@ def collaborators(self) -> "BaseCollaborators": @cache_unless_forced def shares(self) -> List[BaseShares.Info]: """ - Retrieves `base shares `__. + Retrieve `base shares `__. """ data = self.api.request("GET", self.meta_url("shares")) return BaseShares.parse_obj(data).shares @@ -301,7 +301,7 @@ def shares(self) -> List[BaseShares.Info]: @enterprise_only def delete(self) -> None: """ - Deletes the base. + Delete the base. Usage: >>> base = api.base("appMxESAta6clCCwF") diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index c8540b55..e772993f 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -26,7 +26,7 @@ def url(self) -> str: @cache_unless_forced def info(self) -> EnterpriseInfo: """ - Retrieves basic information about the enterprise, caching the result. + Retrieve basic information about the enterprise, caching the result. """ params = {"include": ["collaborators", "inviteLinks"]} payload = self.api.request("GET", self.url, params=params) @@ -38,7 +38,7 @@ def group(self, group_id: str) -> UserGroup: def user(self, id_or_email: str) -> UserInfo: """ - Returns information on a single user with the given ID or email. + Retrieve information on a single user with the given ID or email. Args: id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address. @@ -47,7 +47,7 @@ def user(self, id_or_email: str) -> UserInfo: def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: """ - Returns information on the users with the given IDs or emails. + Retrieve information on the users with the given IDs or emails. Following the Airtable API specification, pyAirtable will perform one API request for each user ID. However, when given a list of emails, diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 55d6464e..82e22938 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -12,7 +12,7 @@ def dict_list_to_request_params( values: List[Dict[str, str]], ) -> Dict[str, str]: """ - Returns dict to be used by request params from dict list + Build the dict to be used by request params from dict list Expected Airtable Url Params is: `?sort[0][field]=FieldOne&sort[0][direction]=asc` @@ -95,7 +95,7 @@ def _option_to_param(name: str) -> str: def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: """ - Converts Airtable options to a dict of query params. + Convert Airtable options to a dict of query params. Args: options: A dict of Airtable-specific options. See :ref:`parameters`. @@ -120,7 +120,7 @@ def options_to_json_and_params( options: Dict[str, Any] ) -> Tuple[Dict[str, Any], Dict[str, Any]]: """ - Converts Airtable options to a JSON payload with (possibly) leftover query params. + Convert Airtable options to a JSON payload with (possibly) leftover query params. Args: options: A dict of Airtable-specific options. See :ref:`parameters`. diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py index 43f78717..3a33bc5f 100644 --- a/pyairtable/api/retrying.py +++ b/pyairtable/api/retrying.py @@ -18,7 +18,7 @@ def retry_strategy( **kwargs: Any, ) -> Retry: """ - Creates a `Retry `_ + Create a `Retry `_ instance with adjustable default values. :class:`~pyairtable.Api` accepts this via the ``retry_strategy=`` parameter. diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index d8800dd1..d8f73a79 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -133,9 +133,22 @@ def __repr__(self) -> str: @property def id(self) -> str: """ - Returns the table's Airtable ID. If the instance was created with a name - rather than an ID, this property may perform an API request to retrieve - the base's schema. + Get the table's Airtable ID. + + If the instance was created with a name rather than an ID, this property will perform + an API request to retrieve the base's schema. For example: + + .. code-block:: python + + # This will not create any network traffic + >>> table = base.table('tbl00000000000123') + >>> table.id + 'tbl00000000000123' + + # This will fetch schema for the base when `table.id` is called + >>> table = base.table('Table Name') + >>> table.id + 'tbl00000000000123' """ if is_table_id(self.name): return self.name @@ -144,14 +157,14 @@ def id(self) -> str: @property def url(self) -> str: """ - Returns the URL for this table. + Build the URL for this table. """ token = self._schema.id if self._schema else self.name return self.api.build_url(self.base.id, urllib.parse.quote(token, safe="")) def meta_url(self, *components: str) -> str: """ - Builds a URL to a metadata endpoint for this table. + Build a URL to a metadata endpoint for this table. """ return self.api.build_url( f"meta/bases/{self.base.id}/tables/{self.id}", *components @@ -159,20 +172,20 @@ def meta_url(self, *components: str) -> str: def record_url(self, record_id: RecordId, *components: str) -> str: """ - Returns the URL for the given record ID, with optional trailing components. + Build the URL for the given record ID, with optional trailing components. """ return posixpath.join(self.url, record_id, *components) @property def api(self) -> "pyairtable.api.api.Api": """ - Returns the same API connection as table's :class:`~pyairtable.Base`. + The API connection used by the table's :class:`~pyairtable.Base`. """ return self.base.api def get(self, record_id: RecordId, **options: Any) -> RecordDict: """ - Retrieves a record by its ID. + Retrieve a record by its ID. >>> table.get('recwPQIfs4wKPyc9D') {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}} @@ -191,7 +204,7 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict: def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: """ - Iterates through each page of results from `List records `_. + Iterate through each page of results from `List records `_. To get all records at once, use :meth:`all`. >>> it = table.iterate() @@ -227,7 +240,7 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: def all(self, **options: Any) -> List[RecordDict]: """ - Retrieves all matching records in a single list. + Retrieve all matching records in a single list. >>> table = api.table('base_id', 'table_name') >>> table.all(view='MyView', fields=['ColA', '-ColB']) @@ -251,7 +264,7 @@ def all(self, **options: Any) -> List[RecordDict]: def first(self, **options: Any) -> Optional[RecordDict]: """ - Retrieves the first matching record. + Retrieve the first matching record. Returns ``None`` if no records are returned. This is similar to :meth:`~pyairtable.Table.all`, except @@ -280,7 +293,7 @@ def create( return_fields_by_field_id: bool = False, ) -> RecordDict: """ - Creates a new record + Create a new record >>> record = {'Name': 'John'} >>> table = api.table('base_id', 'table_name') @@ -309,7 +322,7 @@ def batch_create( return_fields_by_field_id: bool = False, ) -> List[RecordDict]: """ - Creats a number of new records in batches. + Create a number of new records in batches. >>> table.batch_create([{'Name': 'John'}, {'Name': 'Marc'}]) [ @@ -358,7 +371,7 @@ def update( typecast: bool = False, ) -> RecordDict: """ - Updates a particular record ID with the given fields. + Update a particular record ID with the given fields. >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21}) {'id': 'recwPQIfs4wKPyc9D', 'fields': {'First Name': 'John', 'Age': 21}} @@ -387,7 +400,7 @@ def batch_update( return_fields_by_field_id: bool = False, ) -> List[RecordDict]: """ - Updates several records in batches. + Update several records in batches. Args: records: Records to update. @@ -428,7 +441,7 @@ def batch_upsert( return_fields_by_field_id: bool = False, ) -> UpsertResultDict: """ - Updates or creates records in batches, either using ``id`` (if given) or using a set of + Update or create records in batches, either using ``id`` (if given) or using a set of fields (``key_fields``) to look for matches. For more information on how this operation behaves, see Airtable's API documentation for `Update multiple records `__. @@ -491,7 +504,7 @@ def batch_upsert( def delete(self, record_id: RecordId) -> RecordDeletedDict: """ - Deletes the given record. + Delete the given record. >>> table.delete('recwPQIfs4wKPyc9D') {'id': 'recwPQIfs4wKPyc9D', 'deleted': True} @@ -509,7 +522,7 @@ def delete(self, record_id: RecordId) -> RecordDeletedDict: def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]: """ - Deletes the given records, operating in batches. + Delete the given records, operating in batches. >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwDxIfs3wDPyc3F']) [ @@ -536,7 +549,7 @@ def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]: """ - Returns a list of comments on the given record. + Retrieve all comments on the given record. Usage: >>> table = Api.table("appNxslc6jG0XedVM", "tblslc6jG0XedVMNx") @@ -581,7 +594,7 @@ def add_comment( text: str, ) -> "pyairtable.models.Comment": """ - Creates a comment on a record. + Create a comment on a record. See `Create comment `_ for details. Usage: @@ -603,7 +616,7 @@ def add_comment( def schema(self, *, force: bool = False) -> TableSchema: """ - Retrieves the schema of the current table. + Retrieve the schema of the current table. Usage: >>> table.schema() @@ -630,7 +643,7 @@ def create_field( options: Optional[Dict[str, Any]] = None, ) -> FieldSchema: """ - Creates a field on the table. + Create a field on the table. Args: name: The unique name of the field. diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 097c720d..dc72cca8 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -325,7 +325,7 @@ class UserAndScopesDict(TypedDict, total=False): @lru_cache def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]: """ - Creates a pydantic model from a TypedDict to use as a validator. + Create a pydantic model from a TypedDict to use as a validator. Memoizes the result so we don't have to call this more than once per class. """ # Mypy can't tell that we are using pydantic v1. @@ -390,7 +390,7 @@ def assert_typed_dicts(cls: Type[T], objects: Any) -> List[T]: def is_airtable_error(obj: Any) -> bool: """ - Returns whether the given object represents an Airtable error. + Determine whether the given object represents an Airtable error. """ if isinstance(obj, dict): return set(obj) in ({"error"}, {"specialValue"}) diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 51a33eed..04d73e28 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -34,7 +34,9 @@ def create_base( tables: Sequence[Dict[str, Any]], ) -> "pyairtable.api.base.Base": """ - Creates a base in the given workspace. + Create a base in the given workspace. + + See https://airtable.com/developers/web/api/create-base Args: name: The name to give to the new base. Does not need to be unique. @@ -52,8 +54,10 @@ def create_base( @cache_unless_forced def collaborators(self) -> WorkspaceCollaborators: """ - Retrieves basic information, collaborators, and invites + Retrieve basic information, collaborators, and invite links for the given workspace, caching the result. + + See https://airtable.com/developers/web/api/get-workspace-collaborators """ params = {"include": ["collaborators", "inviteLinks"]} payload = self.api.request("GET", self.url, params=params) @@ -62,7 +66,7 @@ def collaborators(self) -> WorkspaceCollaborators: @enterprise_only def bases(self) -> List["pyairtable.api.base.Base"]: """ - Retrieves all bases within the workspace. + Retrieve all bases within the workspace. """ return [self.api.base(base_id) for base_id in self.collaborators().base_ids] @@ -77,7 +81,9 @@ def name(self) -> str: @enterprise_only def delete(self) -> None: """ - Deletes the workspace. + Delete the workspace. + + See https://airtable.com/developers/web/api/delete-workspace Usage: >>> ws = api.workspace("wspmhESAta6clCCwF") @@ -93,7 +99,9 @@ def move_base( index: Optional[int] = None, ) -> None: """ - Moves the given base to a new workspace. + Move the given base to a new workspace. + + See https://airtable.com/developers/web/api/move-base Usage: >>> ws = api.workspace("wspmhESAta6clCCwF") diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 4531bda3..09a40f7c 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -9,7 +9,7 @@ def match(dict_values: Fields, *, match_any: bool = False) -> str: """ - Creates one or more ``EQUAL()`` expressions for each provided dict value. + Create one or more ``EQUAL()`` expressions for each provided dict value. If more than one assetions is included, the expressions are groupped together into using ``AND()`` (all values must match). @@ -117,7 +117,7 @@ def to_airtable_value(value: Any) -> Any: def EQUAL(left: Any, right: Any) -> str: """ - Creates an equality assertion + Create an equality assertion >>> EQUAL(2,2) '2=2' @@ -127,7 +127,7 @@ def EQUAL(left: Any, right: Any) -> str: def FIELD(name: str) -> str: """ - Creates a reference to a field. Quotes are escaped. + Create a reference to a field. Quotes are escaped. Args: name: field name @@ -143,7 +143,7 @@ def FIELD(name: str) -> str: def STR_VALUE(value: str) -> str: """ - Wraps string in quotes. This is needed when referencing a string inside a formula. + Wrap string in quotes. This is needed when referencing a string inside a formula. Quotes are escaped. >>> STR_VALUE("John") @@ -158,7 +158,7 @@ def STR_VALUE(value: str) -> str: def IF(logical: str, value1: str, value2: str) -> str: """ - Creates an IF statement + Create an IF statement >>> IF(1=1, 0, 1) 'IF(1=1, 0, 1)' @@ -168,7 +168,7 @@ def IF(logical: str, value1: str, value2: str) -> str: def FIND(what: str, where: str, start_position: int = 0) -> str: """ - Creates a FIND statement + Create a FIND statement >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol')) "FIND('2021', {DatetimeCol})" @@ -187,7 +187,7 @@ def FIND(what: str, where: str, start_position: int = 0) -> str: def AND(*args: str) -> str: """ - Creates an AND Statement + Create an AND Statement >>> AND(1, 2, 3) 'AND(1, 2, 3)' diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 3cdf6692..5703f688 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -44,7 +44,7 @@ def from_api( context: Optional[Any] = None, ) -> SelfType: """ - Constructs an instance which is able to update itself using an + Construct an instance which is able to update itself using an :class:`~pyairtable.Api`. Args: @@ -134,7 +134,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: """ - Sets a link to the API and builds the REST URL used for this resource. + Set a link to the API and builds the REST URL used for this resource. :meta private: """ diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 6d83cf13..1023d1c7 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -39,7 +39,7 @@ class Bases(AirtableModel): def base(self, base_id: str) -> "Bases.Info": """ - Returns basic information about the base with the given ID. + Get basic information about the base with the given ID. """ return _find(self.bases, base_id) @@ -133,7 +133,7 @@ class BaseSchema(AirtableModel): def table(self, id_or_name: str) -> "TableSchema": """ - Returns the schema for the table with the given ID or name. + Get the schema for the table with the given ID or name. """ return _find(self.tables, id_or_name) @@ -184,13 +184,13 @@ class TableSchema( def field(self, id_or_name: str) -> "FieldSchema": """ - Returns the schema for the field with the given ID or name. + Get the schema for the field with the given ID or name. """ return _find(self.fields, id_or_name) def view(self, id_or_name: str) -> "ViewSchema": """ - Returns the schema for the view with the given ID or name. + Get the schema for the view with the given ID or name. """ return _find(self.views, id_or_name) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index ebd725f3..973f2d41 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -214,7 +214,7 @@ def from_request( secret: Union[bytes, str], ) -> SelfType: """ - Validates a request body and X-Airtable-Content-MAC header + Validate a request body and X-Airtable-Content-MAC header using the secret returned when the webhook was created. Args: diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index afdc13a9..7339c4b8 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -179,13 +179,13 @@ def _missing_value(self) -> Optional[T_ORM]: def to_record_value(self, value: Any) -> Any: """ - Returns the value which should be persisted to the API. + Calculate the value which should be persisted to the API. """ return value def to_internal_value(self, value: Any) -> Any: """ - Converts a value from the API into the value's internal representation. + Convert a value from the API into the value's internal representation. """ return value @@ -318,13 +318,13 @@ class DatetimeField(Field[str, datetime]): def to_record_value(self, value: datetime) -> str: """ - Converts a ``datetime`` into an ISO 8601 string, e.g. "2014-09-05T12:34:56.000Z". + Convert a ``datetime`` into an ISO 8601 string, e.g. "2014-09-05T12:34:56.000Z". """ return utils.datetime_to_iso_str(value) def to_internal_value(self, value: str) -> datetime: """ - Converts an ISO 8601 string, e.g. "2014-09-05T07:00:00.000Z" into a ``datetime``. + Convert an ISO 8601 string, e.g. "2014-09-05T07:00:00.000Z" into a ``datetime``. """ return utils.datetime_from_iso_str(value) @@ -340,13 +340,13 @@ class DateField(Field[str, date]): def to_record_value(self, value: date) -> str: """ - Converts a ``date`` into an ISO 8601 string, e.g. "2014-09-05". + Convert a ``date`` into an ISO 8601 string, e.g. "2014-09-05". """ return utils.date_to_iso_str(value) def to_internal_value(self, value: str) -> date: """ - Converts an ISO 8601 string, e.g. "2014-09-05" into a ``date``. + Convert an ISO 8601 string, e.g. "2014-09-05" into a ``date``. """ return utils.date_from_iso_str(value) @@ -363,13 +363,13 @@ class DurationField(Field[int, timedelta]): def to_record_value(self, value: timedelta) -> float: """ - Converts a ``timedelta`` into a number of seconds. + Convert a ``timedelta`` into a number of seconds. """ return value.total_seconds() def to_internal_value(self, value: Union[int, float]) -> timedelta: """ - Converts a number of seconds into a ``timedelta``. + Convert a number of seconds into a ``timedelta``. """ return timedelta(seconds=value) @@ -504,7 +504,7 @@ def __init__( @property def linked_model(self) -> Type[T_Linked]: """ - Resolves a :class:`~pyairtable.orm.Model` class based on + Resolve a :class:`~pyairtable.orm.Model` class based on the ``model=`` constructor parameter to this field instance. """ if isinstance(self._linked_model, str): @@ -564,7 +564,7 @@ def _get_list_value(self, instance: "Model") -> List[T_Linked]: def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: """ - Returns the list of record IDs which should be persisted to the API. + Build the list of record IDs which should be persisted to the API. """ # If the _fields value contains str, it means we loaded it from the API # but we never actually accessed the value (see _get_list_value). diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 4615060a..f02e5d6c 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -85,7 +85,7 @@ def __repr__(self) -> str: @classmethod def _attribute_descriptor_map(cls) -> Dict[str, AnyField]: """ - Returns a dictionary mapping the model's attribute names to the field's + Build a mapping of the model's attribute names to field descriptor instances. >>> class Test(Model): ... first_name = TextField("First Name") @@ -102,7 +102,7 @@ def _attribute_descriptor_map(cls) -> Dict[str, AnyField]: @classmethod def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]: """ - Returns a dictionary that maps field names to descriptor instances. + Build a mapping of the model's field names to field descriptor instances. >>> class Test(Model): ... first_name = TextField("First Name") @@ -118,7 +118,7 @@ def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]: def __init__(self, **fields: Any): """ - Constructs a model instance with field values based on the given keyword args. + Construct a model instance with field values based on the given keyword args. >>> Contact(name="Alice", birthday=date(1980, 1, 1)) @@ -198,12 +198,13 @@ def exists(self) -> bool: def save(self) -> bool: """ - Saves or updates a model. + Save the model to the API. If the instance does not exist already, it will be created; otherwise, the existing record will be updated. - Returns ``True`` if a record was created and ``False`` if it was updated. + Returns: + ``True`` if a record was created, ``False`` if it was updated. """ if self._deleted: raise RuntimeError(f"{self.id} was deleted") @@ -223,7 +224,7 @@ def save(self) -> bool: def delete(self) -> bool: """ - Deletes the record. + Delete the record. Raises: ValueError: if the record does not exist. @@ -239,7 +240,7 @@ def delete(self) -> bool: @classmethod def all(cls, **kwargs: Any) -> List[SelfType]: """ - Returns all records for this model. For all supported + Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. """ table = cls.get_table() @@ -248,7 +249,7 @@ def all(cls, **kwargs: Any) -> List[SelfType]: @classmethod def first(cls, **kwargs: Any) -> Optional[SelfType]: """ - Returns the first record for this model. For all supported + Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. """ table = cls.get_table() @@ -258,7 +259,8 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: def to_record(self, only_writable: bool = False) -> RecordDict: """ - Returns a dictionary object as an Airtable record. + Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance. + This method converts internal field values into values expected by Airtable. For example, a ``datetime`` value from :class:`~pyairtable.orm.fields.DatetimeField` is converted into an ISO 8601 string. @@ -318,7 +320,7 @@ def from_id( def fetch(self) -> None: """ - Fetches field values from the API and resets instance field values. + Fetch field values from the API and resets instance field values. """ if not self.id: raise ValueError("cannot be fetched because instance does not have an id") @@ -361,7 +363,7 @@ def from_ids( @classmethod def batch_save(cls, models: List[SelfType]) -> None: """ - Saves a list of model instances to the Airtable API with as few + Save a list of model instances to the Airtable API with as few network requests as possible. Can accept a mixture of new records (which have not been saved yet) and existing records that have IDs. """ @@ -391,7 +393,7 @@ def batch_save(cls, models: List[SelfType]) -> None: @classmethod def batch_delete(cls, models: List[SelfType]) -> None: """ - Deletes a list of model instances from Airtable. + Delete a list of model instances from Airtable. Raises: ValueError: if the model has not been saved to Airtable. diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 9cf0e04a..93f17cb0 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -11,7 +11,7 @@ def fake_id(type: str = "rec", value: Any = None) -> str: """ - Generates a fake Airtable-style ID. + Generate a fake Airtable-style ID. Args: type: the object type prefix, defaults to "rec" @@ -35,7 +35,7 @@ def fake_meta( api_key: str = "patFakePersonalAccessToken", ) -> type: """ - Returns a ``Meta`` class for inclusion in a ``Model`` subclass. + Generate a ``Meta`` class for inclusion in a ``Model`` subclass. """ attrs = {"base_id": base_id, "table_name": table_name, "api_key": api_key} return type("Meta", (), attrs) @@ -47,7 +47,7 @@ def fake_record( **other_fields: Any, ) -> RecordDict: """ - Returns a fake record dict with the given field values. + Generate a fake record dict with the given field values. >>> fake_record({"Name": "Alice"}) {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}} @@ -66,11 +66,24 @@ def fake_record( def fake_user(value: Any = None) -> CollaboratorDict: + """ + Generate a fake user dict with the given value for an email prefix. + + >>> fake_user("alice") + {'id': 'usr000000000Alice', 'email': 'alice@example.com', 'name': 'Fake User'} + """ id = fake_id("usr", value) - return {"id": id, "email": f"{value or id}@example.com", "name": "Fake User"} + return { + "id": id, + "email": f"{value or id}@example.com", + "name": "Fake User", + } def fake_attachment() -> AttachmentDict: + """ + Generate a fake attachment dict. + """ return { "id": fake_id("att"), "url": "https://example.com/", diff --git a/pyairtable/utils.py b/pyairtable/utils.py index cf27dc69..4c52b895 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -17,7 +17,7 @@ def datetime_to_iso_str(value: datetime) -> str: """ - Converts ``datetime`` object into Airtable compatible ISO 8601 string + Convert ``datetime`` object into Airtable compatible ISO 8601 string e.g. "2014-09-05T12:34:56.000Z" Args: @@ -38,7 +38,7 @@ def datetime_from_iso_str(value: str) -> datetime: def date_to_iso_str(value: Union[date, datetime]) -> str: """ - Converts a ``date`` or ``datetime`` into an Airtable-compatible ISO 8601 string + Convert a ``date`` or ``datetime`` into an Airtable-compatible ISO 8601 string Args: value: date or datetime object, e.g. "2014-09-05" @@ -48,7 +48,7 @@ def date_to_iso_str(value: Union[date, datetime]) -> str: def date_from_iso_str(value: str) -> date: """ - Converts ISO 8601 date string into a ``date`` object. + Convert ISO 8601 date string into a ``date`` object. Args: value: date string, e.g. "2014-09-05" @@ -58,7 +58,7 @@ def date_from_iso_str(value: str) -> date: def attachment(url: str, filename: str = "") -> CreateAttachmentDict: """ - Returns a dictionary using the expected dictionary format for creating attachments. + Build a ``dict`` in the expected format for creating attachments. When creating an attachment, ``url`` is required, and ``filename`` is optional. Airtable will download the file at the given url and keep its own copy of it. @@ -132,7 +132,7 @@ def is_airtable_id(value: Any, prefix: str = "") -> bool: def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F: """ - Wraps a function or method so that if Airtable returns a 404, + Wrap a function or method so that if Airtable returns a 404, we will annotate the error with a helpful note to the user. """ @@ -189,7 +189,7 @@ def __call__(self_, self: Any, *, force: bool = False) -> R: def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: """ - Wraps a method (e.g. ``Base.shares()``) in a decorator that will save + Wrap a method (e.g. ``Base.shares()``) in a decorator that will save a memoized version of the return value for future reuse, but will also allow callers to pass ``force=True`` to recompute the memoized version. """ diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py index 570c4b42..96316821 100644 --- a/tests/integration/test_integration_metadata.py +++ b/tests/integration/test_integration_metadata.py @@ -8,7 +8,7 @@ def test_api_bases(api: Api, base_id: str, base_name: str, table_name: str): - bases = api.bases() + bases = {base.id: base for base in api.bases()} assert bases[base_id].name == base_name assert bases[base_id].table(table_name).name == table_name diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 00000000..93ca3d9e --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,45 @@ +from unittest.mock import ANY, call + +import pytest + +from pyairtable import testing as T + + +@pytest.mark.parametrize( + "funcname,sig,expected", + [ + ("fake_id", call(value=123), "rec00000000000123"), + ("fake_id", call("tbl", "x"), "tbl0000000000000x"), + ( + "fake_record", + call(id=123), + {"id": "rec00000000000123", "createdTime": ANY, "fields": {}}, + ), + ( + "fake_record", + call({"A": 1}, 123), + {"id": "rec00000000000123", "createdTime": ANY, "fields": {"A": 1}}, + ), + ( + "fake_record", + call(one=1, two=2), + { + "id": ANY, + "createdTime": ANY, + "fields": {"one": 1, "two": 2}, + }, + ), + ( + "fake_user", + call("alice"), + { + "id": "usr000000000alice", + "email": "alice@example.com", + "name": "Fake User", + }, + ), + ], +) +def test_fake_function(funcname, sig, expected): + func = getattr(T, funcname) + assert func(*sig.args, **sig.kwargs) == expected From 0d4aed299cef149b8b22ec352fcfdb46b22715c9 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 9 Nov 2023 21:43:57 -0800 Subject: [PATCH 033/272] Changelog for 2.2.0 --- docs/source/changelog.rst | 15 +++++++++++++++ docs/source/metadata.rst | 4 ++-- pyairtable/__init__.py | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 8283c8b8..3e5ddfdf 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,21 @@ Changelog ========= +2.2.0 (2023-11-13) +------------------------ + +* Fixed a bug in how webhook notification signatures are validated + - `PR #312 `_. +* Added support for reading and modifying :doc:`metadata` + - `PR #311 `_. +* Added support for the 'AI Text' field type + - `PR #310 `_. +* Batch methods can now accept generators or iterators, not just lists + - `PR #308 `_. +* Fixed a few documentation errors + - `PR #301 `_, + `PR #306 `_. + 2.1.0 (2023-08-18) ------------------------ diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index e7f020b2..dfac6de1 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -6,7 +6,7 @@ Metadata ============== The Airtable API gives you the ability to list all of your bases, tables, fields, and views. -pyAirtable allows you to inspect and interact with the metadata in your bases. +pyAirtable allows you to inspect and interact with this metadata in your bases. There may be parts of the Airtable API which are not supported below; you can always use :meth:`Api.request ` to call them directly. @@ -62,7 +62,7 @@ Modifying existing schema To modify a table or field, you can modify its schema object directly and call ``save()``, as shown below. You can only change names and descriptions; -the Airtable API does not permit changing a field's type or other options. +the Airtable API does not permit changing any other options. .. code-block:: python diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index ff41b670..c2b162a0 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.1.0.post1" +__version__ = "2.2.0" from .api import Api, Base, Table from .api.enterprise import Enterprise From a5fc1963cc92964c7055f58d3326a50b820895fd Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 13 Nov 2023 23:40:47 -0800 Subject: [PATCH 034/272] Fix typo in `make release` --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b9bf4093..5c604b85 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ hooks: .PHONY: release release: - @bash -c "./scripts/release.sh" + @zsh -c "./scripts/release.sh" .PHONY: test test-e2e coverage lint format docs clean test: From 519f2ad9b7f1882987cae66c556068b80abf903f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 14 Nov 2023 00:59:05 -0800 Subject: [PATCH 035/272] Fix typo in `make release` --- scripts/release.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 93ac7388..dec39132 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -23,7 +23,7 @@ function bump { 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 + confirm_eval git commit -m "Release $release_version" pyairtable/__init__.py fi } @@ -33,8 +33,8 @@ function push { 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 + confirm_eval git tag -s -m "Release $release_version" $release_version + confirm_eval git push $origin $release_version } bump From 43f3a8fb023de4a10ee61f85efac06f79ef40d98 Mon Sep 17 00:00:00 2001 From: Alex L Date: Thu, 16 Nov 2023 01:17:22 -0800 Subject: [PATCH 036/272] Link to stable docs in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d21db9ed..5d748c63 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ pip install pyairtable ## Documentation -Read the full documentation on [pyairtable.readthedocs.io](https://pyairtable.readthedocs.io/en/latest/getting-started.html). +Read the full documentation on [pyairtable.readthedocs.io](https://pyairtable.readthedocs.io/en/stable/getting-started.html). -If you're still using airtable-python-wrapper and want to upgrade, read the [migration guide](https://pyairtable.readthedocs.io/en/latest/migrations.html). +If you're still using airtable-python-wrapper and want to upgrade, read the [migration guide](https://pyairtable.readthedocs.io/en/stable/migrations.html). ## Contributing From 381aee2ea786bcab0ddbb52875076686282efa76 Mon Sep 17 00:00:00 2001 From: Mark Silverberg Date: Sat, 25 Nov 2023 15:20:48 -0600 Subject: [PATCH 037/272] Uncomment out test Test now fails but next commit should implement support for it --- tests/integration/test_integration_api.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index b8b994fe..120bb840 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -60,18 +60,13 @@ def test_return_fields_by_field_id(table: Table, cols): record = table.create({cols.TEXT_ID: "Hello"}, return_fields_by_field_id=True) assert record["fields"][cols.TEXT_ID] == "Hello" - # Updating a record by field ID does not require any special parameters, - # but the return value will have field names (not IDs). - updated = table.update(record["id"], {cols.TEXT_ID: "Goodbye"}) - assert updated["fields"][cols.TEXT] == "Goodbye" - - # This is not supported (422 Client Error: Unprocessable Entity for url) - # updated = table.update( - # record["id"], - # {cols.TEXT_ID: "Goodbye"}, - # return_fields_by_field_id=True, - # ) - # assert updated["fields"][cols.TEXT_ID] == "Goodbye" + # Update one record with return_fields_by_field_id=True + updated = table.update( + record["id"], + {cols.TEXT_ID: "Goodbye"}, + return_fields_by_field_id=True, + ) + assert updated["fields"][cols.TEXT_ID] == "Goodbye" # Create multiple records with return_fields_by_field_id=True records = table.batch_create( From 6bd0b3347a98526ed33d12fdd78e8d39b6f10015 Mon Sep 17 00:00:00 2001 From: Mark Silverberg Date: Sat, 25 Nov 2023 15:23:04 -0600 Subject: [PATCH 038/272] Add `return_fields_by_field_id` to Table.update previously failing test now passes --- pyairtable/api/table.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index d8f73a79..bd339b6f 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -369,6 +369,7 @@ def update( fields: WritableFields, replace: bool = False, typecast: bool = False, + return_fields_by_field_id: bool = False, ) -> RecordDict: """ Update a particular record ID with the given fields. @@ -388,7 +389,11 @@ def update( updated = self.api.request( method=method, url=self.record_url(record_id), - json={"fields": fields, "typecast": typecast}, + json={ + "fields": fields, + "typecast": typecast, + "returnFieldsByFieldId": return_fields_by_field_id, + }, ) return assert_typed_dict(RecordDict, updated) From f5fa341e2a8977aa1fc15e5de4501d424f533233 Mon Sep 17 00:00:00 2001 From: Mark Silverberg Date: Sat, 25 Nov 2023 15:27:50 -0600 Subject: [PATCH 039/272] Add kwarg ref for docs locally, `docs/build/api.html#pyairtable.Table.update` shows the arg definition now --- pyairtable/api/table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index bd339b6f..eff8534b 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -384,6 +384,7 @@ def update( fields: Fields to update. Must be a dict with column names or IDs as keys. replace: |kwarg_replace| typecast: |kwarg_typecast| + return_fields_by_field_id: |kwarg_return_fields_by_field_id| """ method = "put" if replace else "patch" updated = self.api.request( From 27a81006c1a64f1abb2f82b5e9a25187a632c86e Mon Sep 17 00:00:00 2001 From: Mark Silverberg Date: Sat, 25 Nov 2023 15:33:15 -0600 Subject: [PATCH 040/272] Update/fix test --- tests/test_orm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_orm.py b/tests/test_orm.py index b0128596..16387e7c 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -208,6 +208,7 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records): "Link": [address_id], }, "typecast": True, + "returnFieldsByFieldId": False, } From 86310df1395e7126c13c6077fe0c71e1126f72c0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 27 Nov 2023 22:13:00 -0800 Subject: [PATCH 041/272] Release 2.2.1 --- docs/source/changelog.rst | 12 +++++++++--- pyairtable/__init__.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 3e5ddfdf..2dff863f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,12 @@ Changelog ========= +2.2.1 (2023-11-28) +------------------------ + +* :meth:`~pyairtable.Table.update` now accepts ``return_fields_by_field_id=True`` + - `PR #320 `_. + 2.2.0 (2023-11-13) ------------------------ @@ -13,9 +19,9 @@ Changelog - `PR #310 `_. * Batch methods can now accept generators or iterators, not just lists - `PR #308 `_. -* Fixed a few documentation errors - - `PR #301 `_, - `PR #306 `_. +* Fixed a few documentation errors - + `PR #301 `_, + `PR #306 `_. 2.1.0 (2023-08-18) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index c2b162a0..a9f8d6d6 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.2.0" +__version__ = "2.2.1" from .api import Api, Base, Table from .api.enterprise import Enterprise From 83ae50f1a30701d4e0c1510e1351fe9350f59ac4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 27 Nov 2023 22:23:11 -0800 Subject: [PATCH 042/272] Docs typo --- docs/source/migrations.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 13cec4a8..c7492060 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -53,7 +53,7 @@ See below for supported and unsupported patterns: # The following will raise a TypeError. We do this proactively # to avoid situations where self.api and self.base don't align. - >>> table = Table(api, base_id, table_name) # [Api, Base, str] + >>> table = Table(api, base, table_name) # [Api, Base, str] You may need to change how your code looks up some pieces of connection metadata; for example: From 65d579c2924a598362f197d0e3de95e63e5797db Mon Sep 17 00:00:00 2001 From: michalkacprzak99 Date: Sat, 9 Dec 2023 17:05:55 +0100 Subject: [PATCH 043/272] add formulas for missing logical operators --- pyairtable/formulas.py | 50 ++++++++++++++++++++++++++++++++++++++++++ tests/test_formulas.py | 25 +++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 09a40f7c..734a4e4b 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -218,3 +218,53 @@ def LOWER(value: str) -> str: "LOWER(TestValue)" """ return "LOWER({})".format(value) + + +def NOT_EQUAL(left: Any, right: Any) -> str: + """ + Create an inequality assertion + + >>> NOT_EQUAL(2,2) + '2!=2' + """ + return "{}!={}".format(left, right) + + +def LESS_EQUAL(left: Any, right: Any) -> str: + """ + Create a less than assertion + + >>> LESS_EQUAL(2,2) + '2<=2' + """ + return "{}<={}".format(left, right) + + +def GREATER_EQUAL(left: Any, right: Any) -> str: + """ + Create a greater than assertion + + >>> GREATER_EQUAL(2,2) + '2>=2' + """ + return "{}>={}".format(left, right) + + +def LESS(left: Any, right: Any) -> str: + """ + Create a less assertion + + >>> LESS(2,2) + '2<2' + """ + return "{}<{}".format(left, right) + + +def GREATER(left: Any, right: Any) -> str: + """ + Create a greater assertion + + >>> GREATER(2,2) + '2>2' + """ + return "{}>{}".format(left, right) diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 4e36a89d..0736da5a 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -5,8 +5,13 @@ EQUAL, FIELD, FIND, + GREATER, + GREATER_EQUAL, IF, + LESS, + LESS_EQUAL, LOWER, + NOT_EQUAL, OR, STR_VALUE, escape_quotes, @@ -86,3 +91,23 @@ def test_escape_quotes(text, escaped): def test_lower(): assert LOWER("TestValue") == "LOWER(TestValue)" + + +def test_greater_equal(): + assert GREATER_EQUAL("A", "B") == "A>=B" + + +def test_less_equal(): + assert LESS_EQUAL("A", "B") == "A<=B" + + +def test_greater(): + assert GREATER("A", "B") == "A>B" + + +def test_less(): + assert LESS("A", "B") == "A Date: Mon, 22 Jan 2024 20:38:37 -0800 Subject: [PATCH 044/272] Update getting-started.rst --- docs/source/getting-started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index b94140c7..87f24c8b 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -53,7 +53,7 @@ records in Airtable: "createdTime": "2017-03-14T22:04:31.000Z", "fields": { "Name": "Alice", - "Exail": "alice@example.com" + "Email": "alice@example.com" } } ] From feab4a5ab4cd3662ddebe36746f892987743afa3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 21 Jan 2024 02:44:30 -0800 Subject: [PATCH 045/272] Update Enterprise.users() to new (simpler) API --- pyairtable/api/enterprise.py | 56 +++++++++++++++--------------------- pyairtable/models/schema.py | 25 ++++++++++++++-- tests/test_api_enterprise.py | 11 +------ 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index e772993f..7538f799 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,7 +1,7 @@ -from typing import Dict, Iterable, List, Optional +from typing import Iterable, List, Optional from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo -from pyairtable.utils import cache_unless_forced, enterprise_only, is_user_id +from pyairtable.utils import cache_unless_forced, enterprise_only @enterprise_only @@ -33,8 +33,10 @@ def info(self) -> EnterpriseInfo: return EnterpriseInfo.parse_obj(payload) def group(self, group_id: str) -> UserGroup: + params = {"include": ["collaborations"]} url = self.api.build_url(f"meta/groups/{group_id}") - return UserGroup.parse_obj(self.api.request("GET", url)) + payload = self.api.request("GET", url, params=params) + return UserGroup.parse_obj(payload) def user(self, id_or_email: str) -> UserInfo: """ @@ -49,44 +51,32 @@ def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: """ Retrieve information on the users with the given IDs or emails. - Following the Airtable API specification, pyAirtable will perform - one API request for each user ID. However, when given a list of emails, - pyAirtable only needs to perform one API request for the entire list. - - Read more at `Get user by ID `__ - and `Get user by email `__. + Read more at `Get users by ID or email `__. Args: ids_or_emails: A sequence of user IDs (``usrQBq2RGdihxl3vU``) or email addresses (or both). """ - users: Dict[str, UserInfo] = {} # key by user ID to avoid returning duplicates user_ids: List[str] = [] emails: List[str] = [] for value in ids_or_emails: - if "@" in value: - emails.append(value) - elif is_user_id(value): - user_ids.append(value) - else: - raise ValueError(f"unrecognized user ID or email: {value!r}") - - for user_id in user_ids: - response = self.api.request("GET", f"{self.url}/users/{user_id}") - info = UserInfo.parse_obj(response) - users[info.id] = info - - if emails: - params = {"email": emails} - response = self.api.request("GET", f"{self.url}/users", params=params) - users.update( - { - info.id: info - for user_obj in response["users"] - if (info := UserInfo.parse_obj(user_obj)) - } - ) - + (emails if "@" in value else user_ids).append(value) + + response = self.api.request( + method="GET", + url=f"{self.url}/users", + params={ + "id": user_ids, + "email": emails, + "include": ["collaborations"], + }, + ) + # key by user ID to avoid returning duplicates + users = { + info.id: info + for user_obj in response["users"] + if (info := UserInfo.from_api(user_obj, self.api, context=self)) + } return list(users.values()) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 1023d1c7..154184e9 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -337,7 +337,7 @@ class UserInfo(AirtableModel): enterprise_user_type: Optional[str] invited_to_airtable_by_user_id: Optional[str] is_managed: bool = False - collaborations: Optional["Collaborations"] + collaborations: "Collaborations" groups: List[NestedId] = pydantic.Field(default_factory=list) @@ -352,6 +352,27 @@ class Collaborations(AirtableModel): interface_collaborations: List["Collaborations.InterfaceCollaboration"] workspace_collaborations: List["Collaborations.WorkspaceCollaboration"] + @property + def bases(self) -> Dict[str, "Collaborations.BaseCollaboration"]: + """ + Mapping of base IDs to collaborations, to make lookups easier. + """ + return {c.base_id: c for c in self.base_collaborations} + + @property + def interfaces(self) -> Dict[str, "Collaborations.InterfaceCollaboration"]: + """ + Mapping of interface IDs to collaborations, to make lookups easier. + """ + return {c.interface_id: c for c in self.interface_collaborations} + + @property + def workspaces(self) -> Dict[str, "Collaborations.WorkspaceCollaboration"]: + """ + Mapping of workspace IDs to collaborations, to make lookups easier. + """ + return {c.workspace_id: c for c in self.workspace_collaborations} + class BaseCollaboration(AirtableModel): base_id: str created_time: str @@ -381,7 +402,7 @@ class UserGroup(AirtableModel): created_time: str updated_time: str members: List["UserGroup.Member"] - collaborations: Optional["Collaborations"] + collaborations: "Collaborations" class Member(AirtableModel): user_id: str diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index e62b065e..ce12aa1e 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -21,10 +21,6 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): enterprise.url, json=sample_json("EnterpriseInfo"), ) - m.get_user = requests_mock.get( - f"{enterprise.url}/users/{m.user_id}", - json=user_json, - ) m.get_users = requests_mock.get( f"{enterprise.url}/users", json={"users": [user_json]}, @@ -50,7 +46,7 @@ def test_info(enterprise, enterprise_mocks): def test_user(enterprise, enterprise_mocks): user = enterprise.user(enterprise_mocks.user_id) assert isinstance(user, UserInfo) - assert enterprise_mocks.get_user.call_count == 1 + assert enterprise_mocks.get_users.call_count == 1 @pytest.mark.parametrize( @@ -69,11 +65,6 @@ def test_users(enterprise, enterprise_mocks, search_for): assert user.state == "provisioned" -def test_users__invalid_value(enterprise, enterprise_mocks): - with pytest.raises(ValueError): - enterprise.users(["not an ID or email"]) - - def test_group(enterprise, enterprise_mocks): info = enterprise.group("ugp1mKGb3KXUyQfOZ") assert enterprise_mocks.get_group.call_count == 1 From 3eeae4f6a5101ccefbfde3a697d1566fbb9ec451 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 24 Jan 2024 11:27:59 -0800 Subject: [PATCH 046/272] Integration test for Enterprise.{user,users} --- .../test_integration_enterprise.py | 34 +++++++++++++++++++ tox.ini | 4 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py index 3fef91ea..5b1aae64 100644 --- a/tests/integration/test_integration_enterprise.py +++ b/tests/integration/test_integration_enterprise.py @@ -1,3 +1,4 @@ +import os import uuid import pytest @@ -8,6 +9,14 @@ pytestmark = [pytest.mark.integration] +@pytest.fixture +def enterprise(api): + try: + return api.enterprise(os.environ["AIRTABLE_ENTERPRISE_ID"]) + except KeyError: + pytest.skip("test requires AIRTABLE_ENTERPRISE_ID") + + @pytest.fixture def workspace_id(): return "wsp0HnyXmNnKzc5ng" @@ -38,6 +47,31 @@ def blank_base(workspace: pyairtable.Workspace): base.delete() +def test_user(enterprise: pyairtable.Enterprise): + """ + Test that we can retrieve information about the current logged-in user. + """ + user_id = enterprise.api.whoami()["id"] + assert user_id == enterprise.user(user_id).id + + +def test_user__invalid(enterprise): + with pytest.raises(HTTPError): + enterprise.user("invalidUserId") + + +def test_users(enterprise: pyairtable.Enterprise): + """ + Test that we can retrieve information about an enterprise + and retrieve user information by ID or by email. + """ + user_ids = enterprise.info().user_ids[:5] + users_from_ids = enterprise.users(user_ids) + assert {u.id for u in users_from_ids} == set(user_ids) + users_from_emails = enterprise.users(u.email for u in users_from_ids) + assert {u.id for u in users_from_emails} == set(user_ids) + + def test_create_table(blank_base: pyairtable.Base): """ Test that we can create a new table on an existing base. diff --git a/tox.ini b/tox.ini index 426fe49e..978a3a99 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,9 @@ deps = -r requirements-dev.txt commands = mypy --strict pyairtable tests/test_typing.py [testenv] -passenv = AIRTABLE_API_KEY +passenv = + AIRTABLE_API_KEY + AIRTABLE_ENTERPRISE_ID addopts = -v testpaths = tests commands = python -m pytest {posargs} From 20d9819ac8e591d5a0ffebc94b86e19a5580ba3e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 25 Jan 2024 21:40:25 -0800 Subject: [PATCH 047/272] Make collaborations optional (but default) on Enterprise.user/s --- pyairtable/api/enterprise.py | 16 +++++++--- pyairtable/models/schema.py | 59 ++++++++++++++++++++---------------- tests/test_api_enterprise.py | 25 ++++++++++++--- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 7538f799..6749cb28 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -38,16 +38,22 @@ def group(self, group_id: str) -> UserGroup: payload = self.api.request("GET", url, params=params) return UserGroup.parse_obj(payload) - def user(self, id_or_email: str) -> UserInfo: + def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: """ Retrieve information on a single user with the given ID or email. Args: id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address. + collaborations: If ``False``, no collaboration data will be requested + from Airtable. This may result in faster responses. """ - return self.users([id_or_email])[0] + return self.users([id_or_email], collaborations=collaborations)[0] - def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: + def users( + self, + ids_or_emails: Iterable[str], + collaborations: bool = True, + ) -> List[UserInfo]: """ Retrieve information on the users with the given IDs or emails. @@ -56,6 +62,8 @@ def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: Args: ids_or_emails: A sequence of user IDs (``usrQBq2RGdihxl3vU``) or email addresses (or both). + collaborations: If ``False``, no collaboration data will be requested + from Airtable. This may result in faster responses. """ user_ids: List[str] = [] emails: List[str] = [] @@ -68,7 +76,7 @@ def users(self, ids_or_emails: Iterable[str]) -> List[UserInfo]: params={ "id": user_ids, "email": emails, - "include": ["collaborations"], + "include": ["collaborations"] if collaborations else [], }, ) # key by user ID to avoid returning duplicates diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 154184e9..37f05913 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -319,28 +319,6 @@ class NestedFieldId(AirtableModel): field_id: str -class UserInfo(AirtableModel): - """ - Detailed information about a user. - - See https://airtable.com/developers/web/api/get-user-by-id - """ - - id: str - name: str - email: str - state: str - is_sso_required: bool - is_two_factor_auth_enabled: bool - last_activity_time: Optional[str] - created_time: Optional[str] - enterprise_user_type: Optional[str] - invited_to_airtable_by_user_id: Optional[str] - is_managed: bool = False - collaborations: "Collaborations" - groups: List[NestedId] = pydantic.Field(default_factory=list) - - class Collaborations(AirtableModel): """ The full set of collaborations granted to a user or user group. @@ -348,9 +326,16 @@ class Collaborations(AirtableModel): See https://airtable.com/developers/web/api/model/collaborations """ - base_collaborations: List["Collaborations.BaseCollaboration"] - interface_collaborations: List["Collaborations.InterfaceCollaboration"] - workspace_collaborations: List["Collaborations.WorkspaceCollaboration"] + base_collaborations: List["Collaborations.BaseCollaboration"] = _FL() + interface_collaborations: List["Collaborations.InterfaceCollaboration"] = _FL() + workspace_collaborations: List["Collaborations.WorkspaceCollaboration"] = _FL() + + def __bool__(self) -> bool: + return bool( + self.base_collaborations + or self.interface_collaborations + or self.workspace_collaborations + ) @property def bases(self) -> Dict[str, "Collaborations.BaseCollaboration"]: @@ -389,6 +374,28 @@ class WorkspaceCollaboration(AirtableModel): permission_level: str +class UserInfo(AirtableModel): + """ + Detailed information about a user. + + See https://airtable.com/developers/web/api/get-user-by-id + """ + + id: str + name: str + email: str + state: str + is_sso_required: bool + is_two_factor_auth_enabled: bool + last_activity_time: Optional[str] + created_time: Optional[str] + enterprise_user_type: Optional[str] + invited_to_airtable_by_user_id: Optional[str] + is_managed: bool = False + groups: List[NestedId] = _FL() + collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) + + class UserGroup(AirtableModel): """ Detailed information about a user group and its members. @@ -402,7 +409,7 @@ class UserGroup(AirtableModel): created_time: str updated_time: str members: List["UserGroup.Member"] - collaborations: "Collaborations" + collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) class Member(AirtableModel): user_id: str diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index ce12aa1e..341e9965 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -21,10 +21,8 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): enterprise.url, json=sample_json("EnterpriseInfo"), ) - m.get_users = requests_mock.get( - f"{enterprise.url}/users", - json={"users": [user_json]}, - ) + m.get_users_json = {"users": [sample_json("UserInfo")]} + m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.get_users_json) m.get_group = requests_mock.get( enterprise.api.build_url(f"meta/groups/{group_json['id']}"), json=group_json, @@ -48,6 +46,25 @@ def test_user(enterprise, enterprise_mocks): assert isinstance(user, UserInfo) assert enterprise_mocks.get_users.call_count == 1 + assert user.collaborations + assert "appLkNDICXNqxSDhG" in user.collaborations.bases + assert "pbdyGA3PsOziEHPDE" in user.collaborations.interfaces + assert "wspmhESAta6clCCwF" in user.collaborations.workspaces + + +def test_user__no_collaboration(enterprise, enterprise_mocks): + del enterprise_mocks.get_users_json["users"][0]["collaborations"] + + user = enterprise.user(enterprise_mocks.user_id, collaborations=False) + assert isinstance(user, UserInfo) + assert enterprise_mocks.get_users.call_count == 1 + assert not enterprise_mocks.get_users.last_request.qs.get("include") + + assert not user.collaborations # test for Collaborations.__bool__ + assert not user.collaborations.bases + assert not user.collaborations.interfaces + assert not user.collaborations.workspaces + @pytest.mark.parametrize( "search_for", From 7159c6724ae182f1a21e60fb4b8918faaf70cb5c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 25 Jan 2024 21:45:55 -0800 Subject: [PATCH 048/272] Make collaborations optional (but default) on Enterprise.group --- pyairtable/api/enterprise.py | 4 +-- tests/test_api_enterprise.py | 51 ++++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 6749cb28..ae88108b 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -32,8 +32,8 @@ def info(self) -> EnterpriseInfo: payload = self.api.request("GET", self.url, params=params) return EnterpriseInfo.parse_obj(payload) - def group(self, group_id: str) -> UserGroup: - params = {"include": ["collaborations"]} + def group(self, group_id: str, collaborations: bool = True) -> UserGroup: + params = {"include": ["collaborations"] if collaborations else []} url = self.api.build_url(f"meta/groups/{group_id}") payload = self.api.request("GET", url, params=params) return UserGroup.parse_obj(payload) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 341e9965..d74eaf38 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -13,19 +13,17 @@ def enterprise(api): @pytest.fixture def enterprise_mocks(enterprise, requests_mock, sample_json): - user_json = sample_json("UserInfo") - group_json = sample_json("UserGroup") m = Mock() - m.user_id = user_json["id"] - m.get_info = requests_mock.get( - enterprise.url, - json=sample_json("EnterpriseInfo"), - ) - m.get_users_json = {"users": [sample_json("UserInfo")]} - m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.get_users_json) + m.json_user = sample_json("UserInfo") + m.json_users = {"users": [m.json_user]} + m.json_group = sample_json("UserGroup") + m.user_id = m.json_user["id"] + m.group_id = m.json_group["id"] + m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo")) + m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.json_users) m.get_group = requests_mock.get( - enterprise.api.build_url(f"meta/groups/{group_json['id']}"), - json=group_json, + enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"), + json=m.json_group, ) return m @@ -45,7 +43,6 @@ def test_user(enterprise, enterprise_mocks): user = enterprise.user(enterprise_mocks.user_id) assert isinstance(user, UserInfo) assert enterprise_mocks.get_users.call_count == 1 - assert user.collaborations assert "appLkNDICXNqxSDhG" in user.collaborations.bases assert "pbdyGA3PsOziEHPDE" in user.collaborations.interfaces @@ -53,13 +50,12 @@ def test_user(enterprise, enterprise_mocks): def test_user__no_collaboration(enterprise, enterprise_mocks): - del enterprise_mocks.get_users_json["users"][0]["collaborations"] + del enterprise_mocks.json_users["users"][0]["collaborations"] user = enterprise.user(enterprise_mocks.user_id, collaborations=False) assert isinstance(user, UserInfo) assert enterprise_mocks.get_users.call_count == 1 assert not enterprise_mocks.get_users.last_request.qs.get("include") - assert not user.collaborations # test for Collaborations.__bool__ assert not user.collaborations.bases assert not user.collaborations.interfaces @@ -83,9 +79,26 @@ def test_users(enterprise, enterprise_mocks, search_for): def test_group(enterprise, enterprise_mocks): - info = enterprise.group("ugp1mKGb3KXUyQfOZ") + grp = enterprise.group("ugp1mKGb3KXUyQfOZ") + assert enterprise_mocks.get_group.call_count == 1 + assert isinstance(grp, UserGroup) + assert grp.id == "ugp1mKGb3KXUyQfOZ" + assert grp.name == "Group name" + assert grp.members[0].email == "foo@bar.com" + assert grp.collaborations + assert "appLkNDICXNqxSDhG" in grp.collaborations.bases + assert "pbdyGA3PsOziEHPDE" in grp.collaborations.interfaces + assert "wspmhESAta6clCCwF" in grp.collaborations.workspaces + + +def test_group__no_collaboration(enterprise, enterprise_mocks): + del enterprise_mocks.json_group["collaborations"] + + grp = enterprise.group(enterprise_mocks.group_id, collaborations=False) + assert isinstance(grp, UserGroup) assert enterprise_mocks.get_group.call_count == 1 - assert isinstance(info, UserGroup) - assert info.id == "ugp1mKGb3KXUyQfOZ" - assert info.name == "Group name" - assert info.members[0].email == "foo@bar.com" + assert not enterprise_mocks.get_group.last_request.qs.get("include") + assert not grp.collaborations # test for Collaborations.__bool__ + assert not grp.collaborations.bases + assert not grp.collaborations.interfaces + assert not grp.collaborations.workspaces From 2d895646169e5c689d41f4395e9c4ecb07705730 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 26 Jan 2024 14:46:51 -0800 Subject: [PATCH 049/272] Release 2.2.2 --- docs/source/changelog.rst | 16 ++++++++++++++++ pyairtable/__init__.py | 2 +- pyairtable/api/enterprise.py | 8 ++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2dff863f..4c2f0914 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,22 @@ Changelog ========= +2.2.2 (2023-01-28) +------------------------ + +* Enterprise methods :meth:`~pyairtable.Enterprise.user`, + :meth:`~pyairtable.Enterprise.users`, and :meth:`~pyairtable.Enterprise.group` + now return collaborations by default. + - `PR #332 `_. +* Added more helper functions for formulas: + :func:`~pyairtable.formulas.LESS`, + :func:`~pyairtable.formulas.LESS_EQUAL`, + :func:`~pyairtable.formulas.GREATER`, + :func:`~pyairtable.formulas.GREATER_EQUAL`, + and + :func:`~pyairtable.formulas.NOT_EQUAL`. + - `PR #323 `_. + 2.2.1 (2023-11-28) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index a9f8d6d6..50d52dc1 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.2.1" +__version__ = "2.2.2" from .api import Api, Base, Table from .api.enterprise import Enterprise diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index ae88108b..96d5912b 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -33,6 +33,14 @@ def info(self) -> EnterpriseInfo: return EnterpriseInfo.parse_obj(payload) def group(self, group_id: str, collaborations: bool = True) -> UserGroup: + """ + Retrieve information on a single user group with the given ID. + + Args: + group_id: A user group ID (``grpQBq2RGdihxl3vU``). + collaborations: If ``False``, no collaboration data will be requested + from Airtable. This may result in faster responses. + """ params = {"include": ["collaborations"] if collaborations else []} url = self.api.build_url(f"meta/groups/{group_id}") payload = self.api.request("GET", url, params=params) From be878f8949e42b9989cabfb955147fdded59e8d8 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 29 Jan 2024 08:56:40 -0800 Subject: [PATCH 050/272] Include Python 3.12 in CI --- setup.cfg | 1 + tox.ini | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index fd968f11..b70ab5f4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Programming Language :: Python :: Implementation :: CPython Topic :: Software Development diff --git a/tox.ini b/tox.ini index 978a3a99..c04d7b18 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,8 @@ envlist = pre-commit mypy mypy-pydantic1 - py3{8,9,10,11}-requests{min,max} - py3{8,9,10,11}-pydantic1 + py3{8,9,10,11,12}-requests{min,max} + py3{8,9,10,11,12}-pydantic1 coverage [gh-actions] @@ -13,6 +13,7 @@ python = 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [testenv:pre-commit] deps = pre-commit From 03d42eeb3c171d9d6855c8beb965ce1a0523d0ce Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 29 Jan 2024 16:58:59 -0800 Subject: [PATCH 051/272] Clean up tox envlist --- tox.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index c04d7b18..9d762972 100644 --- a/tox.ini +++ b/tox.ini @@ -2,9 +2,7 @@ envlist = pre-commit mypy - mypy-pydantic1 - py3{8,9,10,11,12}-requests{min,max} - py3{8,9,10,11,12}-pydantic1 + py3{8,9,10,11,12}{,-pydantic1,-requestsmin} coverage [gh-actions] @@ -33,7 +31,6 @@ commands = python -m pytest {posargs} deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg - requestsmax: requests>=2.22.0 # Keep in sync with setup.cfg pydantic1: pydantic<2 # Lots of projects still use 1.x [testenv:coverage] From 59814eae5e65a5f78203f18f0c33616f8c921360 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 31 Jan 2024 21:13:00 -0800 Subject: [PATCH 052/272] Add `force=` kwarg to `base()` and `table()` methods --- docs/source/_substitutions.rst | 4 +-- docs/source/changelog.rst | 12 +++++++ pyairtable/api/api.py | 42 +++++++++++++++------- pyairtable/api/base.py | 9 +++-- pyairtable/api/workspace.py | 2 +- tests/test_api_api.py | 41 ++++++++++++++++++--- tests/test_api_base.py | 65 ++++++++++++++++++++++++---------- 7 files changed, 134 insertions(+), 41 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index fc65137a..d0fd0b8a 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -1,6 +1,6 @@ -.. |arg_base_id| replace:: An Airtable base id. +.. |arg_base_id| replace:: An Airtable base ID. -.. |arg_record_id| replace:: An Airtable record id. +.. |arg_record_id| replace:: An Airtable record ID. .. |kwarg_view| replace:: The name or ID of a view. If set, only the records in that view will be returned. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 4c2f0914..004f249c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,18 @@ Changelog ========= +2.3.0 (TBD) +------------------------ + +* :meth:`Api.base `, + :meth:`Api.table `, + and :meth:`Base.table ` + will use cached base metadata when called multiple times with ``validate=True``, + unless the caller passes a new keyword argument ``force=True``. + This allows callers to validate the IDs/names of many bases or tables at once + without having to perform expensive network overhead each time. + - `PR #336 `_. + 2.2.2 (2023-01-28) ------------------------ diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 682ff549..33319c45 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -105,6 +105,7 @@ def base( base_id: str, *, validate: bool = False, + force: bool = False, ) -> "pyairtable.api.base.Base": """ Return a new :class:`Base` instance that uses this instance of :class:`Api`. @@ -112,13 +113,14 @@ def base( Args: base_id: |arg_base_id| validate: |kwarg_validate_metadata| + force: |kwarg_force_metadata| Raises: - KeyError: if ``fetch=True`` and the given base ID does not exist. + KeyError: if ``validate=True`` and the given base ID does not exist. """ if validate: - bases = {base.id: base for base in self.bases(force=True)} - return bases[base_id] + info = self._base_info(force=force).base(base_id) + return self._base_from_info(info) return pyairtable.api.base.Base(self, base_id) @cache_unless_forced @@ -137,6 +139,14 @@ def _base_info(self) -> Bases: } ) + def _base_from_info(self, base_info: Bases.Info) -> "pyairtable.api.base.Base": + return pyairtable.api.base.Base( + self, + base_info.id, + name=base_info.name, + permission_level=base_info.permission_level, + ) + def bases(self, *, force: bool = False) -> List["pyairtable.api.base.Base"]: """ Retrieve the base's schema and return a list of :class:`Base` instances. @@ -152,13 +162,7 @@ def bases(self, *, force: bool = False) -> List["pyairtable.api.base.Base"]: ] """ return [ - pyairtable.api.base.Base( - self, - info.id, - name=info.name, - permission_level=info.permission_level, - ) - for info in self._base_info(force=force).bases + self._base_from_info(info) for info in self._base_info(force=force).bases ] def create_base( @@ -180,11 +184,25 @@ def create_base( """ return self.workspace(workspace_id).create_base(name, tables) - def table(self, base_id: str, table_name: str) -> "pyairtable.api.table.Table": + def table( + self, + base_id: str, + table_name: str, + *, + validate: bool = False, + force: bool = False, + ) -> "pyairtable.api.table.Table": """ Build a new :class:`Table` instance that uses this instance of :class:`Api`. + + Args: + base_id: |arg_base_id| + table_name: The Airtable table's ID or name. + validate: |kwarg_validate_metadata| + force: |kwarg_force_metadata| """ - return self.base(base_id).table(table_name) + base = self.base(base_id, validate=validate, force=force) + return base.table(table_name, validate=validate, force=force) def build_url(self, *components: str) -> str: """ diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 881fe431..75bf47ae 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -59,7 +59,10 @@ def __init__( Args: api: An instance of :class:`Api` or an Airtable access token. - base_id: |arg_base_id| + base_id: An Airtable base ID. + name: The name of the Airtable base, if known. + permission_level: The permission level the current authenticated user + has upon the Airtable base, if known. """ if isinstance(api, str): warnings.warn( @@ -98,6 +101,7 @@ def table( id_or_name: str, *, validate: bool = False, + force: bool = False, ) -> "pyairtable.api.table.Table": """ Build a new :class:`Table` instance using this instance of :class:`Base`. @@ -106,13 +110,14 @@ def table( id_or_name: An Airtable table ID or name. Table name should be unencoded, as shown on browser. validate: |kwarg_validate_metadata| + force: |kwarg_force_metadata| Usage: >>> base.table('Apartments')
""" if validate: - schema = self.schema(force=True).table(id_or_name) + schema = self.schema(force=force).table(id_or_name) return pyairtable.api.table.Table(None, self, schema) return pyairtable.api.table.Table(None, self, id_or_name) diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 04d73e28..fbecf489 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -46,7 +46,7 @@ def create_base( url = self.api.build_url("meta/bases") payload = {"name": name, "workspaceId": self.id, "tables": list(tables)} response = self.api.request("POST", url, json=payload) - return self.api.base(response["id"], validate=True) + return self.api.base(response["id"], validate=True, force=True) # Everything below here requires .info() and is therefore Enterprise-only diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 6ead1686..6e5f32f7 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -1,8 +1,15 @@ from unittest import mock +import pytest + from pyairtable import Api, Base, Table # noqa +@pytest.fixture +def mock_bases_endpoint(api, requests_mock, sample_json): + return requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases")) + + def test_repr(api): assert "Api" in api.__repr__() @@ -61,19 +68,43 @@ def test_whoami(api, requests_mock): assert api.whoami() == payload -def test_bases(api, requests_mock, sample_json): - m = requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases")) +@pytest.mark.parametrize("base_id", ("appLkNDICXNqxSDhG", "Apartment Hunting")) +def test_base(api, base_id, mock_bases_endpoint): + # test behavior without validation + base = api.base(base_id) + assert base.id == base_id + assert base.name is None + assert base.permission_level is None + assert mock_bases_endpoint.call_count == 0 + + # test behavior with validation + base = api.base(base_id, validate=True) + assert base.id == "appLkNDICXNqxSDhG" + assert base.name == "Apartment Hunting" + assert base.permission_level == "create" + assert mock_bases_endpoint.call_count == 1 + + # calling a second time uses cached information... + api.base(base_id, validate=True) + assert mock_bases_endpoint.call_count == 1 + + # ...unless we also pass force=True + base = api.base(base_id, validate=True, force=True) + assert mock_bases_endpoint.call_count == 2 + + +def test_bases(api, mock_bases_endpoint): base_ids = [base.id for base in api.bases()] - assert m.call_count == 1 + assert mock_bases_endpoint.call_count == 1 assert base_ids == ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"] # Should not make a second API call... assert [base.id for base in api.bases()] == base_ids - assert m.call_count == 1 + assert mock_bases_endpoint.call_count == 1 # ....unless we force it: reloaded = api.bases(force=True) assert [base.id for base in reloaded] == base_ids - assert m.call_count == 2 + assert mock_bases_endpoint.call_count == 2 def test_iterate_requests(api: Api, requests_mock): diff --git a/tests/test_api_base.py b/tests/test_api_base.py index a9e3ab31..4223680d 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -7,6 +7,11 @@ from pyairtable.testing import fake_id +@pytest.fixture +def mock_tables_endpoint(base, requests_mock, sample_json): + return requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + + def test_constructor(api): base = Base(api, "base_id") assert base.api == api @@ -57,17 +62,16 @@ def test_url(base): assert base.url == "https://api.airtable.com/v0/appJMY16gZDQrMWpA" -def test_schema(base: Base, requests_mock, sample_json): - m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) +def test_schema(base: Base, mock_tables_endpoint): table_schema = base.schema().table("tbltp8DGLhqbUmjK1") assert table_schema.name == "Apartments" - assert m.call_count == 1 + assert mock_tables_endpoint.call_count == 1 # Test that we cache the result unless force=True base.schema() - assert m.call_count == 1 + assert mock_tables_endpoint.call_count == 1 base.schema(force=True) - assert m.call_count == 2 + assert mock_tables_endpoint.call_count == 2 def test_table(base: Base, requests_mock): @@ -79,25 +83,30 @@ def test_table(base: Base, requests_mock): assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename" -def test_table_validate(base: Base, requests_mock, sample_json): +def test_table_validate(base: Base, mock_tables_endpoint): """ Test that Base.table(..., validate=True) allows us to look up a table by either ID or name and get the correct properties. """ - m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + # It will reuse the cached schema when validate=True is called multiple times... base.table("tbltp8DGLhqbUmjK1", validate=True) base.table("Apartments", validate=True) - assert m.call_count == 2 - # ...and will raise an exception if called with an invalid ID/name: + assert mock_tables_endpoint.call_count == 1 + # ...unless we also pass force=True + base.table("Apartments", validate=True, force=True) + assert mock_tables_endpoint.call_count == 2 + + +def test_table__invalid(base, mock_tables_endpoint): + # validate=True will raise an exception if called with an invalid ID/name: with pytest.raises(KeyError): base.table("DoesNotExist", validate=True) -def test_tables(base: Base, requests_mock, sample_json): +def test_tables(base: Base, mock_tables_endpoint): """ Test that Base.tables() returns a list of validated Base instances. """ - requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) result = base.tables() assert len(result) == 2 assert result[0].name == "Apartments" @@ -199,26 +208,44 @@ def test_name(api, base, requests_mock): assert Base("tok", "app", name="Base Name").name == "Base Name" -def test_create_table(base, requests_mock, sample_json): +def test_create_table(base, requests_mock, mock_tables_endpoint): """ Test that Base.create_table() makes two calls, one to create the table, and another to re-retrieve the entire base's schema. """ - schema = sample_json("BaseSchema") - url = base.meta_url("tables") - m = requests_mock.post(url, json={"id": "tbltp8DGLhqbUmjK1"}) - m_get = requests_mock.get(url + "?include=visibleFieldIds", json=schema) + m = requests_mock.post(mock_tables_endpoint._url, json={"id": "tblWasJustCreated"}) + mock_tables_endpoint._responses[0]._params["json"]["tables"].append( + { + "id": "tblWasJustCreated", + "name": "Table Name", + "primaryFieldId": "fldWasJustCreated", + "fields": [ + { + "id": "fldWasJustCreated", + "name": "Whatever", + "type": "singleLineText", + } + ], + "views": [], + } + ) table = base.create_table( - "Table Name", [{"name": "Whatever"}], description="Description" + "Table Name", + fields=[{"name": "Whatever"}], + description="Description", ) - assert isinstance(table, Table) - assert m.call_count == m_get.call_count == 1 + assert m.call_count == 1 assert m.request_history[-1].json() == { "name": "Table Name", "description": "Description", "fields": [{"name": "Whatever"}], } + assert isinstance(table, Table) + assert table.id == "tblWasJustCreated" + assert table.name == "Table Name" + assert table.schema().primary_field_id == "fldWasJustCreated" + def test_delete(base, requests_mock): """ From ef4647ce0f379ba8c38f6d8040922a266de52702 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 31 Jan 2024 21:26:19 -0800 Subject: [PATCH 053/272] Add Python 3.12 to GitHub Actions --- .github/workflows/test_lint_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml index 15b7270e..0f747dd7 100644 --- a/.github/workflows/test_lint_deploy.yml +++ b/.github/workflows/test_lint_deploy.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From 326e3a8dff9dddbd2b7370257639f12fa1fe45c6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 13 Nov 2023 13:57:07 -0800 Subject: [PATCH 054/272] WIP for reading/parsing audit log events --- pyairtable/api/api.py | 24 +++++- pyairtable/api/enterprise.py | 145 ++++++++++++++++++++++++++++++++++- pyairtable/models/audit.py | 61 +++++++++++++++ tests/test_api_enterprise.py | 67 +++++++++++++++- 4 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 pyairtable/models/audit.py diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 33319c45..a2708af0 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -293,6 +293,7 @@ def iterate_requests( url: str, fallback: Optional[Tuple[str, str]] = None, options: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, offset_field: str = "offset", ) -> Iterator[Any]: """ @@ -311,18 +312,35 @@ def iterate_requests( fallback: The method and URL to use if we have to convert a GET to a POST. options: Airtable-specific query params to use while fetching records. See :ref:`Parameters` for valid options. + params: Additional query params to append to the URL as-is. offset_field: The key to use in the API response to determine whether there are additional pages to retrieve. """ options = options or {} + params = params or {} + + def _get_offset_field(response: Dict[str, Any]) -> Optional[str]: + value = response.get("pagination") or response # see Enterprise.audit_log + field_names = offset_field.split(".") + while field_names: + if not (value := value.get(field_names.pop(0))): + return None + return str(value) + while True: - response = self.request(method, url, fallback=fallback, options=options) + response = self.request( + method=method, + url=url, + fallback=fallback, + options=options, + params=params, + ) yield response if not isinstance(response, dict): return - if not (offset := response.get(offset_field)): + if not (offset := _get_offset_field(response)): return - options = {**options, offset_field: offset} + params = {**params, offset_field: offset} def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: """ diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 96d5912b..3338aced 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,4 +1,7 @@ -from typing import Iterable, List, Optional +import datetime +from typing import Any, Iterable, Iterator, List, Optional, Union, cast +from typing_extensions import TypeVar +from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import cache_unless_forced, enterprise_only @@ -95,6 +98,146 @@ def users( } return list(users.values()) + def audit_log( + self, + *, + page_size: Optional[int] = None, + sort_asc: Optional[bool] = False, + previous: Optional[str] = None, + next: Optional[str] = None, + start_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, + end_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, + user_id: Optional[Union[str, Iterable[str]]] = None, + event_type: Optional[Union[str, Iterable[str]]] = None, + model_id: Optional[Union[str, Iterable[str]]] = None, + category: Optional[Union[str, Iterable[str]]] = None, + ) -> Iterator[AuditLogResponse]: + """ + Retrieve and yield results from the `Audit Log `__, + one page of results at a time. Each result is an instance of :class:`~pyairtable.models.audit.AuditLogResponse` + and contains the pagination IDs returned from the API, as described in the linked documentation. + + By default, the Airtable API will return up to 180 days of audit log events, going backwards from most recent. + Retrieving all records may take some time, but is as straightforward as: + + >>> enterprise = Enterprise("entYourEnterpriseId") + >>> events = [ + ... event + ... for page in enterprise.audit_log() + ... for event in page.events + ... ] + + If you are creating a record of all audit log events, you probably want to start with the earliest + events in the retention window and iterate chronologically. You'll likely have a job running + periodically in the background, so you'll need some way to persist the pagination IDs retrieved + from the API in case that job is interrupted and needs to be restarted. + + The sample code below will use a local file to remember the next page's ID, so that if the job is + interrupted, it will resume where it left off (potentially processing some entries twice). + + .. code-block:: python + + import os + import shelve + import pyairtable + + def handle_event(event): + print(event) + + api = pyairtable.Api(os.environ["AIRTABLE_API_KEY"]) + enterprise = api.enterprise(os.environ["AIRTABLE_ENTERPRISE_ID"]) + persistence = shelve.open("audit_log.db") + first_page = persistence.get("next", None) + + for page in enterprise.audit_log(sort_asc=True, next=first_page): + for event in page.events: + handle_event(event) + persistence["next"] = page.pagination.next + + For more information on any of the keyword parameters below, refer to the + `audit log events `__ + API documentation. + + Args: + page_size: How many events per page to return (maximum 100). + sort_asc: Whether to sort in ascending order (earliest to latest) + rather than descending order (latest to earliest). + previous: Requests the previous page of results from the given ID. + See the `audit log integration guide `__ + for more information on pagination parameters. + next: Requests the next page of results according to the given ID. + See the `audit log integration guide `__ + for more information on pagination parameters. + start_time: Earliest timestamp to retrieve (inclusive). + end_time: Latest timestamp to retrieve (inclusive). + originating_user_id: Retrieve audit log events originating + from the provided user ID or IDs (maximum 100). + event_type: Retrieve audit log events falling under the provided + `audit log event type `__ + or types (maximum 100). + model_id: Retrieve audit log events taking action on, or involving, + the provided model ID or IDs (maximum 100). + category: Retrieve audit log events belonging to the provided + audit log event category or categories. + + Returns: + An object representing a single page of audit log results. + """ + + start_time = _coerce_isoformat(start_time) + end_time = _coerce_isoformat(end_time) + user_id = _coerce_list(user_id) + event_type = _coerce_list(event_type) + model_id = _coerce_list(model_id) + category = _coerce_list(category) + params = { + "startTime": start_time, + "endTime": end_time, + "originatingUserId": user_id, + "eventType": event_type, + "modelId": model_id, + "category": category, + "pageSize": page_size, + "sortOrder": ("ascending" if sort_asc else "descending"), + "previous": previous, + "next": next, + } + params = {k: v for (k, v) in params.items() if v} + offset_field = "next" if sort_asc else "previous" + url = self.api.build_url(f"meta/enterpriseAccounts/{self.id}/auditLogEvents") + for response in self.api.iterate_requests( + method="GET", + url=url, + params=params, + offset_field=offset_field, + ): + parsed = AuditLogResponse.parse_obj(response) + yield parsed + if not parsed.events: + return + + +def _coerce_isoformat(value: Any) -> Optional[str]: + if value is None: + return value + if isinstance(value, str): + datetime.datetime.fromisoformat(value) # validates type, nothing more + return value + if isinstance(value, (datetime.date, datetime.datetime)): + return value.isoformat() + raise TypeError(f"cannot coerce {type(value)} into ISO 8601 str") + + +T = TypeVar("T") + + +def _coerce_list(value: Optional[Union[str, Iterable[T]]]) -> List[T]: + if value is None: + return [] + if isinstance(value, str): + return cast(List[T], [value]) + return list(value) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py new file mode 100644 index 00000000..b4b436d3 --- /dev/null +++ b/pyairtable/models/audit.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Optional + +from typing_extensions import TypeAlias + +from pyairtable.models._base import AirtableModel, update_forward_refs + + +class AuditLogResponse(AirtableModel): + events: List["AuditLogEvent"] + pagination: Optional["AuditLogResponse.Pagination"] = None + + class Pagination(AirtableModel): + next: Optional[str] + previous: Optional[str] + + +class AuditLogEvent(AirtableModel): + id: str + timestamp: str + action: str + actor: "AuditLogActor" + model_id: str + model_type: str + payload: "AuditLogPayload" + payload_version: str + context: "AuditLogEvent.Context" + origin: "AuditLogEvent.Origin" + + class Context(AirtableModel): + base_id: Optional[str] = None + action_id: str + enterprise_account_id: str + descendant_enterprise_account_id: Optional[str] = None + interface_id: Optional[str] = None + workspace_id: Optional[str] = None + + class Origin(AirtableModel): + ip_address: str + user_agent: str + oauth_access_token_id: Optional[str] = None + personal_access_token_id: Optional[str] = None + session_id: Optional[str] = None + + +class AuditLogActor(AirtableModel): + type: str + user: Optional["AuditLogActor.UserInfo"] = None + view_id: Optional[str] = None + automation_id: Optional[str] = None + + class UserInfo(AirtableModel): + id: str + email: str + name: Optional[str] = None + + +# Placeholder until we can parse https://airtable.com/developers/web/api/audit-log-event-types +AuditLogPayload: TypeAlias = Dict[str, Any] + + +update_forward_refs(vars()) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index d74eaf38..fa60f7b7 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -1,9 +1,11 @@ -from unittest.mock import Mock +import datetime +from unittest.mock import Mock, call import pytest from pyairtable.api.enterprise import Enterprise from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo +from pyairtable.testing import fake_id @pytest.fixture @@ -11,7 +13,11 @@ def enterprise(api): return Enterprise(api, "entUBq2RGdihxl3vU") -@pytest.fixture +N_AUDIT_PAGES = 15 +N_AUDIT_PAGE_SIZE = 10 + + +@pytest.fixture(autouse=True) def enterprise_mocks(enterprise, requests_mock, sample_json): m = Mock() m.json_user = sample_json("UserInfo") @@ -25,9 +31,48 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"), json=m.json_group, ) + m.get_audit_log = requests_mock.get( + enterprise.api.build_url( + f"meta/enterpriseAccounts/{enterprise.id}/auditLogEvents" + ), + response_list=[ + { + "json": { + "events": fake_audit_log_events(n), + "pagination": ( + None if n == N_AUDIT_PAGES - 1 else {"previous": "dummy"} + ), + } + } + for n in range(N_AUDIT_PAGES) + ], + ) return m +def fake_audit_log_events(counter, page_size=N_AUDIT_PAGE_SIZE): + return [ + { + "id": str(counter * 1000 + n), + "timestamp": datetime.datetime.now().isoformat(), + "action": "viewBase", + "actor": {"type": "anonymousUser"}, + "model_id": (base_id := fake_id("app")), + "model_type": "base", + "payload": {"name": "The Base Name"}, + "payloadVersion": "1.0", + "context": { + "baseId": base_id, + "actionId": fake_id("act"), + "enterpriseAccountId": fake_id("ent"), + "workspaceId": fake_id("wsp"), + }, + "origin": {"ipAddress": "8.8.8.8", "userAgent": "Internet Explorer"}, + } + for n in range(page_size) + ] + + def test_info(enterprise, enterprise_mocks): assert isinstance(info := enterprise.info(), EnterpriseInfo) assert info.id == "entUBq2RGdihxl3vU" @@ -70,7 +115,7 @@ def test_user__no_collaboration(enterprise, enterprise_mocks): ["usrL2PNC5o3H4lBEi", "foo@bar.com"], # should not return duplicates ), ) -def test_users(enterprise, enterprise_mocks, search_for): +def test_users(enterprise, search_for): results = enterprise.users(search_for) assert len(results) == 1 assert isinstance(user := results[0], UserInfo) @@ -102,3 +147,19 @@ def test_group__no_collaboration(enterprise, enterprise_mocks): assert not grp.collaborations.bases assert not grp.collaborations.interfaces assert not grp.collaborations.workspaces + + +@pytest.mark.parametrize( + "fncall,length", + [ + (call(), N_AUDIT_PAGES * N_AUDIT_PAGE_SIZE), + ], +) +def test_audit_log(enterprise, fncall, length): + events = [ + event + for page in enterprise.audit_log(*fncall.args, **fncall.kwargs) + for event in page.events + ] + print(repr(events)) + assert len(events) == length From 5d49275fd573690a7db18214e4a9666aa6540cc3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 13 Jan 2024 11:46:01 -0800 Subject: [PATCH 055/272] Audit log test coverage --- docs/source/enterprise.rst | 29 +++++++++++ pyairtable/api/enterprise.py | 12 +++-- .../test_integration_enterprise.py | 13 +++++ tests/test_api_enterprise.py | 50 +++++++++++++++++-- 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 docs/source/enterprise.rst diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst new file mode 100644 index 00000000..871c2319 --- /dev/null +++ b/docs/source/enterprise.rst @@ -0,0 +1,29 @@ +.. include:: _substitutions.rst +.. include:: _warn_latest.rst + +Enterprise Features +============================== + + +pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. +The following methods are only available on an `Enterprise plan `__. +If you call one of them against a base that is not part of an enterprise workspace, Airtable will +return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan. + +.. automethod:: pyairtable.Api.enterprise + :noindex: + +.. automethod:: pyairtable.Base.collaborators + :noindex: + +.. automethod:: pyairtable.Base.shares + :noindex: + +.. automethod:: pyairtable.Workspace.collaborators + :noindex: + +.. automethod:: pyairtable.Enterprise.info + :noindex: + +.. automethod:: pyairtable.Enterprise.audit_log + :noindex: diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 3338aced..1a5d2cd2 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,8 +1,9 @@ import datetime from typing import Any, Iterable, Iterator, List, Optional, Union, cast + from typing_extensions import TypeVar -from pyairtable.models.audit import AuditLogResponse +from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import cache_unless_forced, enterprise_only @@ -102,6 +103,7 @@ def audit_log( self, *, page_size: Optional[int] = None, + page_limit: Optional[int] = None, sort_asc: Optional[bool] = False, previous: Optional[str] = None, next: Optional[str] = None, @@ -160,6 +162,7 @@ def handle_event(event): Args: page_size: How many events per page to return (maximum 100). + page_limit: How many pages to return before stopping. sort_asc: Whether to sort in ascending order (earliest to latest) rather than descending order (latest to earliest). previous: Requests the previous page of results from the given ID. @@ -205,16 +208,19 @@ def handle_event(event): params = {k: v for (k, v) in params.items() if v} offset_field = "next" if sort_asc else "previous" url = self.api.build_url(f"meta/enterpriseAccounts/{self.id}/auditLogEvents") - for response in self.api.iterate_requests( + iter_requests = self.api.iterate_requests( method="GET", url=url, params=params, offset_field=offset_field, - ): + ) + for count, response in enumerate(iter_requests, start=1): parsed = AuditLogResponse.parse_obj(response) yield parsed if not parsed.events: return + if page_limit is not None and count >= page_limit: + return def _coerce_isoformat(value: Any) -> Optional[str]: diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py index 5b1aae64..4deb2d44 100644 --- a/tests/integration/test_integration_enterprise.py +++ b/tests/integration/test_integration_enterprise.py @@ -133,3 +133,16 @@ def reload_field(): field.description = "Renamed" field.save() assert reload_field().description == "Renamed" + + +def test_audit_log(api): + """ + Test that we can call the audit log endpoint. + """ + if "AIRTABLE_ENTERPRISE_ID" not in os.environ: + return pytest.skip("test_audit_log requires AIRTABLE_ENTERPRISE_ID") + + enterprise = api.enterprise(os.environ["AIRTABLE_ENTERPRISE_ID"]) + for page in enterprise.audit_log(page_limit=1): + for event in page.events: + assert isinstance(event.action, str) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index fa60f7b7..7b27031a 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -1,5 +1,5 @@ import datetime -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch import pytest @@ -150,16 +150,56 @@ def test_group__no_collaboration(enterprise, enterprise_mocks): @pytest.mark.parametrize( - "fncall,length", + "fncall,expected_size", [ (call(), N_AUDIT_PAGES * N_AUDIT_PAGE_SIZE), + (call(page_limit=1), N_AUDIT_PAGE_SIZE), ], ) -def test_audit_log(enterprise, fncall, length): +def test_audit_log(enterprise, fncall, expected_size): events = [ event for page in enterprise.audit_log(*fncall.args, **fncall.kwargs) for event in page.events ] - print(repr(events)) - assert len(events) == length + assert len(events) == expected_size + + +def test_audit_log__no_loop(enterprise, requests_mock): + """ + Test that an empty page of events does not cause an infinite loop. + """ + requests_mock.get( + enterprise.api.build_url( + f"meta/enterpriseAccounts/{enterprise.id}/auditLogEvents" + ), + json={ + "events": [], + "pagination": {"previous": "dummy"}, + }, + ) + events = [event for page in enterprise.audit_log() for event in page.events] + assert len(events) == 0 + + +@pytest.mark.parametrize( + "fncall,sortorder,offset_field", + [ + (call(), "descending", "previous"), + (call(sort_asc=True), "ascending", "next"), + ], +) +def test_audit_log__sortorder( + api, + enterprise, + enterprise_mocks, + fncall, + sortorder, + offset_field, +): + with patch.object(api, "iterate_requests", wraps=api.iterate_requests) as m: + list(enterprise.audit_log(*fncall.args, **fncall.kwargs)) + + request = enterprise_mocks.get_audit_log.last_request + assert request.qs["sortorder"] == [sortorder] + assert m.mock_calls[-1].kwargs["offset_field"] == offset_field From 73cf94b0a119bba5ff20f069206622cc5adba965 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 20 Jan 2024 16:23:58 -0800 Subject: [PATCH 056/272] Tests for utils.coerce_{iso_str,list_str} --- pyairtable/api/enterprise.py | 46 +++++++++++------------------------- pyairtable/utils.py | 40 ++++++++++++++++++++++++++++++- tests/test_utils.py | 29 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 1a5d2cd2..10a1c24d 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,11 +1,15 @@ import datetime -from typing import Any, Iterable, Iterator, List, Optional, Union, cast - -from typing_extensions import TypeVar +from typing import Iterable, Iterator, List, Optional, Union from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo -from pyairtable.utils import cache_unless_forced, enterprise_only +from pyairtable.utils import ( + cache_unless_forced, + coerce_iso_str, + coerce_list_str, + enterprise_only, + is_user_id, +) @enterprise_only @@ -187,12 +191,12 @@ def handle_event(event): An object representing a single page of audit log results. """ - start_time = _coerce_isoformat(start_time) - end_time = _coerce_isoformat(end_time) - user_id = _coerce_list(user_id) - event_type = _coerce_list(event_type) - model_id = _coerce_list(model_id) - category = _coerce_list(category) + start_time = coerce_iso_str(start_time) + end_time = coerce_iso_str(end_time) + user_id = coerce_list_str(user_id) + event_type = coerce_list_str(event_type) + model_id = coerce_list_str(model_id) + category = coerce_list_str(category) params = { "startTime": start_time, "endTime": end_time, @@ -223,28 +227,6 @@ def handle_event(event): return -def _coerce_isoformat(value: Any) -> Optional[str]: - if value is None: - return value - if isinstance(value, str): - datetime.datetime.fromisoformat(value) # validates type, nothing more - return value - if isinstance(value, (datetime.date, datetime.datetime)): - return value.isoformat() - raise TypeError(f"cannot coerce {type(value)} into ISO 8601 str") - - -T = TypeVar("T") - - -def _coerce_list(value: Optional[Union[str, Iterable[T]]]) -> List[T]: - if value is None: - return [] - if isinstance(value, str): - return cast(List[T], [value]) - return list(value) - - # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa import pyairtable.api.base # noqa diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 4c52b895..8588ea7a 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -3,7 +3,19 @@ import textwrap from datetime import date, datetime from functools import partial, wraps -from typing import Any, Callable, Generic, Iterator, Sequence, TypeVar, Union, cast +from typing import ( + Any, + Callable, + Generic, + Iterable, + Iterator, + List, + Optional, + Sequence, + TypeVar, + Union, + cast, +) import requests from typing_extensions import ParamSpec, Protocol @@ -208,3 +220,29 @@ def _inner(self: Any, *, force: bool = False) -> R: _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|") return _inner + + +def coerce_iso_str(value: Any) -> Optional[str]: + """ + Given an input that might be a date or datetime, or an ISO 8601 formatted str, + convert the value into an ISO 8601 formatted str. + """ + if value is None: + return value + if isinstance(value, str): + datetime.fromisoformat(value) # validates type, nothing more + return value + if isinstance(value, (date, datetime)): + return value.isoformat() + raise TypeError(f"cannot coerce {type(value)} into ISO 8601 str") + + +def coerce_list_str(value: Optional[Union[str, Iterable[str]]]) -> List[str]: + """ + Given an input that is either a str or an iterable of str, return a list. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2dfc0413..9a3589b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -58,3 +58,32 @@ def test_attachment(): ) def test_id_check(func, value, expected): assert func(value) is expected + + +@pytest.mark.parametrize( + "func,input,expected", + [ + (utils.coerce_iso_str, None, None), + (utils.coerce_iso_str, "asdf", ValueError), + (utils.coerce_iso_str, -1, TypeError), + (utils.coerce_iso_str, "2023-01-01", "2023-01-01"), + (utils.coerce_iso_str, "2023-01-01 12:34:56", "2023-01-01 12:34:56"), + (utils.coerce_iso_str, date(2023, 1, 1), "2023-01-01"), + ( + utils.coerce_iso_str, + datetime(2023, 1, 1, 12, 34, 56), + "2023-01-01T12:34:56", + ), + (utils.coerce_list_str, None, []), + (utils.coerce_list_str, "asdf", ["asdf"]), + (utils.coerce_list_str, ("one", "two", "three"), ["one", "two", "three"]), + (utils.coerce_list_str, -1, TypeError), + ], +) +def test_converter(func, input, expected): + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + func(input) + return + + assert func(input) == expected From 6166fa7396d9a0c7163656bd60036cc4ff13d237 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 20 Jan 2024 19:29:05 -0800 Subject: [PATCH 057/272] Add audit log to documentation --- docs/source/api.rst | 8 ++++++++ docs/source/changelog.rst | 3 +++ docs/source/index.rst | 5 +++-- docs/source/metadata.rst | 24 ------------------------ docs/source/webhooks.rst | 3 +++ pyairtable/models/audit.py | 14 ++++++++++++++ tests/test_api_enterprise.py | 7 +++++++ 7 files changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 8815c7de..2c0d9baa 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -49,6 +49,14 @@ API: pyairtable.models :inherited-members: AirtableModel +API: pyairtable.models.audit +******************************** + +.. automodule:: pyairtable.models.audit + :members: + :inherited-members: AirtableModel + + API: pyairtable.models.schema ******************************** diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 004f249c..0ed91d5d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,6 +5,9 @@ Changelog 2.3.0 (TBD) ------------------------ +* Added :meth:`Enterprise.audit_log ` + to iterate page-by-page through `audit log events `__. + - `PR #330 `_. * :meth:`Api.base `, :meth:`Api.table `, and :meth:`Base.table ` diff --git a/docs/source/index.rst b/docs/source/index.rst index 32e09649..ec4ca998 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -27,9 +27,9 @@ pyAirtable getting-started tables orm - webhooks metadata - migrations + webhooks + enterprise api @@ -37,6 +37,7 @@ pyAirtable :caption: More :hidden: + migrations about changelog contributing diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index dfac6de1..fd09fc96 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -33,30 +33,6 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` :noindex: -Enterprise information ------------------------------ - -pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. -The following methods are only available on an `Enterprise plan `__. -If you call one of them against a base that is not part of an enterprise workspace, Airtable will -return a 404 error, and pyAirtable will add a reminder to the exception to check your billing plan. - -.. automethod:: pyairtable.Api.enterprise - :noindex: - -.. automethod:: pyairtable.Base.collaborators - :noindex: - -.. automethod:: pyairtable.Base.shares - :noindex: - -.. automethod:: pyairtable.Workspace.collaborators - :noindex: - -.. automethod:: pyairtable.Enterprise.info - :noindex: - - Modifying existing schema ----------------------------- diff --git a/docs/source/webhooks.rst b/docs/source/webhooks.rst index 5a74d536..9f6a132d 100644 --- a/docs/source/webhooks.rst +++ b/docs/source/webhooks.rst @@ -1,3 +1,6 @@ +.. include:: _substitutions.rst +.. include:: _warn_latest.rst + Webhooks ============================== diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index b4b436d3..00a63f25 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -6,6 +6,13 @@ class AuditLogResponse(AirtableModel): + """ + Represents a page of audit log events. + + See `Audit log events `__ + for more information on how to interpret this data structure. + """ + events: List["AuditLogEvent"] pagination: Optional["AuditLogResponse.Pagination"] = None @@ -15,6 +22,13 @@ class Pagination(AirtableModel): class AuditLogEvent(AirtableModel): + """ + Represents a single audit log event. + + See `Audit log events `__ + for more information on how to interpret this data structure. + """ + id: str timestamp: str action: str diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 7b27031a..61ea163c 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -157,6 +157,9 @@ def test_group__no_collaboration(enterprise, enterprise_mocks): ], ) def test_audit_log(enterprise, fncall, expected_size): + """ + Test that we iterate through multiple pages of the audit log. correctly + """ events = [ event for page in enterprise.audit_log(*fncall.args, **fncall.kwargs) @@ -197,6 +200,10 @@ def test_audit_log__sortorder( sortorder, offset_field, ): + """ + Test that we calculate sortorder and offset_field correctly + dpeending on whether we're ascending or descending. + """ with patch.object(api, "iterate_requests", wraps=api.iterate_requests) as m: list(enterprise.audit_log(*fncall.args, **fncall.kwargs)) From 723064158dcd9dd615506049a0d57c2601739953 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 8 Jan 2024 08:29:52 -0800 Subject: [PATCH 058/272] base.collaborators().add_{user,group} --- pyairtable/api/api.py | 6 +++++ pyairtable/api/base.py | 2 +- pyairtable/api/table.py | 2 +- pyairtable/models/_base.py | 26 ++++++++++++---------- pyairtable/models/schema.py | 44 +++++++++++++++++++++++++++++++++++-- tests/test_models_schema.py | 18 +++++++++++++++ 6 files changed, 82 insertions(+), 16 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index a2708af0..f856c894 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -1,4 +1,5 @@ import posixpath +from functools import partialmethod from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union import requests @@ -268,6 +269,11 @@ def request( response = self.session.send(prepared, timeout=self.timeout) return self._process_response(response) + get = partialmethod(request, "GET") + post = partialmethod(request, "POST") + patch = partialmethod(request, "PATCH") + delete = partialmethod(request, "DELETE") + def _process_response(self, response: requests.Response) -> Any: try: response.raise_for_status() diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 75bf47ae..d28a20ea 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -292,7 +292,7 @@ def collaborators(self) -> "BaseCollaborators": """ params = {"include": ["collaborators", "inviteLinks", "interfaces"]} data = self.api.request("GET", self.meta_url(), params=params) - return BaseCollaborators.parse_obj(data) + return BaseCollaborators.from_api(data, self.api, context=self) @enterprise_only @cache_unless_forced diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index eff8534b..c66f3413 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -667,7 +667,7 @@ def create_field( # This hopscotch ensures that the FieldSchema object we return has an API and a URL, # and that developers don't need to reload our schema to be able to access it. field_schema = parse_field_schema(response) - field_schema.set_api( + field_schema._set_api( self.api, context={ "base": self.base, diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 5703f688..bb76a55d 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -106,7 +106,7 @@ def cascade_api( # This is what we came here for if isinstance(obj, RestfulModel): - obj.set_api(api, context=context) + obj._set_api(api, context=context) # Find and apply API/context to nested models in every Pydantic field. for field_name in type(obj).__fields__: @@ -132,17 +132,26 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) super().__init_subclass__() - def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: + def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: """ Set a link to the API and builds the REST URL used for this resource. - - :meta private: """ self._api = api self._url = self.__url_pattern.format(**context, self=self) if self._url and not self._url.startswith("http"): self._url = api.build_url(self._url) + def _reload(self, obj: Optional[Dict[str, Any]] = None) -> None: + """ + Reload the model's contents from the given object, or by making a GET request to the API. + """ + if obj is None: + obj = self._api.get(self._url) + copyable = type(self).parse_obj(obj) + self.__dict__.update( + {key: copyable.__dict__.get(key) for key in type(self).__fields__} + ) + class CanDeleteModel(RestfulModel): """ @@ -223,14 +232,7 @@ def save(self) -> None: exclude_none=(not self.__save_none), ) response = self._api.request("PATCH", self._url, json=data) - copyable = type(self).parse_obj(response) - self.__dict__.update( - { - key: value - for (key, value) in copyable.__dict__.items() - if key in type(self).__fields__ - } - ) + self._reload(response) def __setattr__(self, name: str, value: Any) -> None: # Prevents implementers from changing values on readonly or non-writable fields. diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 37f05913..714591f8 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -5,7 +5,13 @@ from pyairtable._compat import pydantic -from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs +from ._base import ( + AirtableModel, + CanDeleteModel, + CanUpdateModel, + RestfulModel, + update_forward_refs, +) _T = TypeVar("_T", bound=Any) _FL = partial(pydantic.Field, default_factory=list) @@ -49,7 +55,7 @@ class Info(AirtableModel): permission_level: str -class BaseCollaborators(AirtableModel): +class BaseCollaborators(RestfulModel, url="meta/bases/{base.id}"): """ Detailed information about who can access a base. @@ -85,6 +91,40 @@ class InviteLinks(AirtableModel): base_invite_links: List["InviteLink"] = _FL() workspace_invite_links: List["InviteLink"] = _FL() + def _add_collaborator( + self, collaborator_type: str, collaborator_id: str, permission_level: str + ) -> None: + payload = { + "collaborators": [ + { + collaborator_type: {"id": collaborator_id}, + "permissionLevel": permission_level, + } + ] + } + self._api.post(f"{self._url}/collaborators", json=payload) + self._reload() + + def add_user(self, user_id: str, permission_level: str) -> None: + """ + Add a user as a base collaborator. + + Args: + user_id: The user ID. + permission_level: See `application permission levels `__. + """ + self._add_collaborator("user", user_id, permission_level) + + def add_group(self, group_id: str, permission_level: str) -> None: + """ + Add a group as a base collaborator. + + Args: + group_id: The group ID. + permission_level: See `application permission levels `__. + """ + self._add_collaborator("group", group_id, permission_level) + class BaseShares(AirtableModel): """ diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index c6311918..fd7659bf 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -116,3 +116,21 @@ def test_find(): collection.find("0003") with pytest.raises(KeyError): collection.find("0004") + + +@pytest.mark.parametrize( + "kind,collaborator", + [ + ("user", "usrsOEchC9xuwRgKk"), + ("group", "ugpR8ZT9KtIgp8Bh3"), + ], +) +def test_base_collaborators__add(base, kind, collaborator, requests_mock, sample_json): + requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators")) + m = requests_mock.post(base.meta_url("collaborators"), body="") + method = getattr(base.collaborators(), f"add_{kind}") + method(collaborator, "read") + assert m.call_count == 1 + assert m.last_request.json() == { + "collaborators": [{kind: {"id": collaborator}, "permissionLevel": "read"}] + } From aa082eabe52cce0c50944ab7933c523a939c752c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 8 Jan 2024 21:43:24 -0800 Subject: [PATCH 059/272] Replace all uses of parse_obj with from_api --- pyairtable/api/api.py | 17 ++++----- pyairtable/api/base.py | 21 ++++++----- pyairtable/api/enterprise.py | 10 ++--- pyairtable/api/table.py | 16 ++++---- pyairtable/api/workspace.py | 10 ++--- pyairtable/models/_base.py | 11 ++---- pyairtable/models/webhook.py | 2 +- tests/test_api_table.py | 2 +- tests/test_models.py | 72 ++++++++++++++++++++++++++++++++++-- 9 files changed, 110 insertions(+), 51 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index f856c894..3f552812 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -130,15 +130,14 @@ def _base_info(self) -> Bases: Return a schema object that represents all bases available via the API. """ url = self.build_url("meta/bases") - return Bases.parse_obj( - { - "bases": [ - base_info - for page in self.iterate_requests("GET", url) - for base_info in page["bases"] - ] - } - ) + data = { + "bases": [ + base_info + for page in self.iterate_requests("GET", url) + for base_info in page["bases"] + ] + } + return Bases.from_api(data, self) def _base_from_info(self, base_info: Bases.Info) -> "pyairtable.api.base.Base": return pyairtable.api.base.Base( diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index d28a20ea..f11ab291 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -159,7 +159,7 @@ def create_table( payload = {"name": name, "fields": fields} if description: payload["description"] = description - response = self.api.request("POST", url, json=payload) + response = self.api.post(url, json=payload) return self.table(response["id"], validate=True) @property @@ -187,7 +187,7 @@ def schema(self) -> BaseSchema: """ url = self.meta_url("tables") params = {"include": ["visibleFieldIds"]} - data = self.api.request("GET", url, params=params) + data = self.api.get(url, params=params) return BaseSchema.from_api(data, self.api, context=self) @property @@ -215,7 +215,7 @@ def webhooks(self) -> List[Webhook]: ) ] """ - response = self.api.request("GET", self.webhooks_url) + response = self.api.get(self.webhooks_url) return [ Webhook.from_api(data, self.api, context=self) for data in response["webhooks"] @@ -277,12 +277,12 @@ def add_webhook( can also provide :class:`~pyairtable.models.webhook.WebhookSpecification`. """ if isinstance(spec, dict): - spec = WebhookSpecification.parse_obj(spec) + spec = WebhookSpecification.from_api(spec, self.api) create = CreateWebhook(notification_url=notify_url, specification=spec) request = create.dict(by_alias=True, exclude_unset=True) - response = self.api.request("POST", self.webhooks_url, json=request) - return CreateWebhookResponse.parse_obj(response) + response = self.api.post(self.webhooks_url, json=request) + return CreateWebhookResponse.from_api(response, self.api) @enterprise_only @cache_unless_forced @@ -291,7 +291,7 @@ def collaborators(self) -> "BaseCollaborators": Retrieve `base collaborators `__. """ params = {"include": ["collaborators", "inviteLinks", "interfaces"]} - data = self.api.request("GET", self.meta_url(), params=params) + data = self.api.get(self.meta_url(), params=params) return BaseCollaborators.from_api(data, self.api, context=self) @enterprise_only @@ -300,8 +300,9 @@ def shares(self) -> List[BaseShares.Info]: """ Retrieve `base shares `__. """ - data = self.api.request("GET", self.meta_url("shares")) - return BaseShares.parse_obj(data).shares + data = self.api.get(self.meta_url("shares")) + shares_obj = BaseShares.from_api(data, self.api, context=self) + return shares_obj.shares @enterprise_only def delete(self) -> None: @@ -312,4 +313,4 @@ def delete(self) -> None: >>> base = api.base("appMxESAta6clCCwF") >>> base.delete() """ - self.api.request("DELETE", self.meta_url()) + self.api.delete(self.meta_url()) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 10a1c24d..5a0d8ba9 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -8,7 +8,6 @@ coerce_iso_str, coerce_list_str, enterprise_only, - is_user_id, ) @@ -37,8 +36,8 @@ def info(self) -> EnterpriseInfo: Retrieve basic information about the enterprise, caching the result. """ params = {"include": ["collaborators", "inviteLinks"]} - payload = self.api.request("GET", self.url, params=params) - return EnterpriseInfo.parse_obj(payload) + response = self.api.get(self.url, params=params) + return EnterpriseInfo.from_api(response, self.api) def group(self, group_id: str, collaborations: bool = True) -> UserGroup: """ @@ -51,7 +50,7 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup: """ params = {"include": ["collaborations"] if collaborations else []} url = self.api.build_url(f"meta/groups/{group_id}") - payload = self.api.request("GET", url, params=params) + payload = self.api.get(url, params=params) return UserGroup.parse_obj(payload) def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: @@ -86,8 +85,7 @@ def users( for value in ids_or_emails: (emails if "@" in value else user_ids).append(value) - response = self.api.request( - method="GET", + response = self.api.get( url=f"{self.url}/users", params={ "id": user_ids, diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index c66f3413..0145ce5f 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -199,7 +199,7 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict: user_locale: |kwarg_user_locale| return_fields_by_field_id: |kwarg_return_fields_by_field_id| """ - record = self.api.request("get", self.record_url(record_id), options=options) + record = self.api.get(self.record_url(record_id), options=options) return assert_typed_dict(RecordDict, record) def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: @@ -304,8 +304,7 @@ def create( typecast: |kwarg_typecast| return_fields_by_field_id: |kwarg_return_fields_by_field_id| """ - created = self.api.request( - method="post", + created = self.api.post( url=self.url, json={ "fields": fields, @@ -350,8 +349,7 @@ def batch_create( for chunk in self.api.chunked(records): new_records = [{"fields": fields} for fields in chunk] - response = self.api.request( - method="post", + response = self.api.post( url=self.url, json={ "records": new_records, @@ -523,7 +521,7 @@ def delete(self, record_id: RecordId) -> RecordDeletedDict: """ return assert_typed_dict( RecordDeletedDict, - self.api.request("delete", self.record_url(record_id)), + self.api.delete(self.record_url(record_id)), ) def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]: @@ -548,7 +546,7 @@ def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict record_ids = list(record_ids) for chunk in self.api.chunked(record_ids): - result = self.api.request("delete", self.url, params={"records[]": chunk}) + result = self.api.delete(self.url, params={"records[]": chunk}) deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"]) return deleted_records @@ -615,7 +613,7 @@ def add_comment( text: The text of the comment. Use ``@[usrIdentifier]`` to mention users. """ url = self.record_url(record_id, "comments") - response = self.api.request("POST", url, json={"text": text}) + response = self.api.post(url, json={"text": text}) return pyairtable.models.Comment.from_api( response, self.api, context={"record_url": self.record_url(record_id)} ) @@ -663,7 +661,7 @@ def create_field( request["description"] = description if options: request["options"] = options - response = self.api.request("POST", self.meta_url("fields"), json=request) + response = self.api.post(self.meta_url("fields"), json=request) # This hopscotch ensures that the FieldSchema object we return has an API and a URL, # and that developers don't need to reload our schema to be able to access it. field_schema = parse_field_schema(response) diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index fbecf489..3da75fdc 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -45,7 +45,7 @@ def create_base( """ url = self.api.build_url("meta/bases") payload = {"name": name, "workspaceId": self.id, "tables": list(tables)} - response = self.api.request("POST", url, json=payload) + response = self.api.post(url, json=payload) return self.api.base(response["id"], validate=True, force=True) # Everything below here requires .info() and is therefore Enterprise-only @@ -60,8 +60,8 @@ def collaborators(self) -> WorkspaceCollaborators: See https://airtable.com/developers/web/api/get-workspace-collaborators """ params = {"include": ["collaborators", "inviteLinks"]} - payload = self.api.request("GET", self.url, params=params) - return WorkspaceCollaborators.parse_obj(payload) + payload = self.api.get(self.url, params=params) + return WorkspaceCollaborators.from_api(payload, self.api, context=self) @enterprise_only def bases(self) -> List["pyairtable.api.base.Base"]: @@ -89,7 +89,7 @@ def delete(self) -> None: >>> ws = api.workspace("wspmhESAta6clCCwF") >>> ws.delete() """ - self.api.request("DELETE", self.url) + self.api.delete(self.url) @enterprise_only def move_base( @@ -114,7 +114,7 @@ def move_base( if index is not None: payload["targetIndex"] = index url = self.url + "/moveBase" - self.api.request("POST", url, json=payload) + self.api.post(url, json=payload) # These are at the bottom of the module to avoid circular imports diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index bb76a55d..2f6c271a 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -29,12 +29,6 @@ class Config: _raw: Any = pydantic.PrivateAttr() - @classmethod - def parse_obj(cls, obj: Any) -> SelfType: - instance = super().parse_obj(obj) - instance._raw = obj - return instance - @classmethod def from_api( cls, @@ -56,6 +50,7 @@ def from_api( the URL for a :class:`~pyairtable.models._base.RestfulModel`. """ instance = cls.parse_obj(obj) + instance._raw = obj cascade_api(instance, api, context=context) return instance @@ -127,6 +122,7 @@ class RestfulModel(AirtableModel): _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() _url: str = pydantic.PrivateAttr(default="") + _url_context: Any = None def __init_subclass__(cls, **kwargs: Any) -> None: cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) @@ -138,6 +134,7 @@ def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> No """ self._api = api self._url = self.__url_pattern.format(**context, self=self) + self._url_context = context if self._url and not self._url.startswith("http"): self._url = api.build_url(self._url) @@ -147,7 +144,7 @@ def _reload(self, obj: Optional[Dict[str, Any]] = None) -> None: """ if obj is None: obj = self._api.get(self._url) - copyable = type(self).parse_obj(obj) + copyable = type(self).from_api(obj, self._api, context=self._url_context) self.__dict__.update( {key: copyable.__dict__.get(key) for key in type(self).__fields__} ) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 639d6ce9..e8d7cdbf 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -147,7 +147,7 @@ def payloads( ): payloads = page["payloads"] for index, payload in enumerate(payloads): - payload = WebhookPayload.parse_obj(payload) + payload = WebhookPayload.from_api(payload, self._api, context=self) payload.cursor = cursor + index yield payload count += 1 diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 95d41167..6bd6fe43 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -11,7 +11,7 @@ @pytest.fixture() -def table_schema(sample_json) -> TableSchema: +def table_schema(sample_json, api, base) -> TableSchema: return TableSchema.parse_obj(sample_json("TableSchema")) diff --git a/tests/test_models.py b/tests/test_models.py index 5a6a8857..e8c8fecd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,5 @@ +from typing import List + import pytest from pyairtable.models._base import ( @@ -36,13 +38,13 @@ class Subclass(*base_classes, **kwargs): return _creates_instance -def test_raw(raw_data): +def test_raw(raw_data, api): """ - Test that AirtableModel.parse_obj saves the raw value, so that developers + Test that AirtableModel.from_api saves the raw value, so that developers can access the exact payload we received from the API. This is mostly in case Airtable adds new things to webhooks or webhook payloads in the future. """ - obj = AirtableModel.parse_obj(raw_data) + obj = AirtableModel.from_api(raw_data, api) assert not hasattr(obj, "foo") assert not hasattr(obj, "bar") assert obj._raw == raw_data @@ -100,6 +102,70 @@ def test_save_without_url(create_instance): obj.save() +def test_save__nested_reload(requests_mock, api): + """ + Test that reloading an object with nested models correctly reloads all of them, + while preserving those nested models' access to the API. + """ + + class Parent(CanUpdateModel, url="foo/{self.id}"): + id: int + name: str + children: List["Parent.Child"] # noqa + + class Child(CanUpdateModel, url="foo/{parent.id}/child/{child.id}"): + id: int + name: str + + update_forward_refs(Parent) + + parent_data = { + "id": 1, + "name": "One", + "children": [ + (child2_data := {"id": 2, "name": "Two"}), + (child3_data := {"id": 3, "name": "Three"}), + ], + } + requests_mock.get(parent_url := api.build_url("foo/1"), json=parent_data) + requests_mock.get(child2_url := api.build_url("foo/1/child/2"), json=child2_data) + requests_mock.get(child3_url := api.build_url("foo/1/child/3"), json=child3_data) + + parent = Parent.from_api(parent_data, api) + assert parent.name == "One" + assert parent.children[0].name == "Two" + + # Test that we can still reload the parent object + m_parent_patch = requests_mock.patch( + parent_url, + json={ + **parent_data, + "name": (parent_update := "One Updated"), + }, + ) + parent.name = parent_update + parent.save() + assert m_parent_patch.call_count == 1 + assert m_parent_patch.last_request.json()["name"] == parent_update + + # Test that we can still patch a nested object after its parent was reloaded, + # because we saved the URL context from `from_api()` and reused it on `_reload()`. + m_child2_patch = requests_mock.patch(child2_url, json=child2_data) + m_child3_patch = requests_mock.patch( + child3_url, + json={ + **child3_data, + "name": (child3_update := "Three Updated"), + }, + ) + parent.children[1].name = child3_update + parent.children[1].save() + assert m_child3_patch.call_count == 1 + assert m_child3_patch.last_request.json()["name"] == child3_update + assert parent.children[1].name == child3_update + assert m_child2_patch.call_count == 0 # just to be sure + + def test_delete(requests_mock, create_instance): obj = create_instance() m = requests_mock.delete(obj._url) From 0ee19f43a4aaf8c60e84840f826a5bf50dde6d66 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 21 Jan 2024 04:25:39 -0800 Subject: [PATCH 060/272] Fix bug that did not save _raw on nested models --- pyairtable/models/_base.py | 11 +++++++---- tests/test_models.py | 21 +++++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 2f6c271a..5632e18c 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -29,10 +29,14 @@ class Config: _raw: Any = pydantic.PrivateAttr() + def __init__(self, **data: Any) -> None: + super().__init__(**data) + self._raw = data + @classmethod def from_api( cls, - obj: Any, + obj: Dict[str, Any], api: "pyairtable.api.api.Api", *, context: Optional[Any] = None, @@ -49,8 +53,7 @@ def from_api( which will be used as arguments to ``str.format()`` when constructing the URL for a :class:`~pyairtable.models._base.RestfulModel`. """ - instance = cls.parse_obj(obj) - instance._raw = obj + instance = cls(**obj) cascade_api(instance, api, context=context) return instance @@ -130,7 +133,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: """ - Set a link to the API and builds the REST URL used for this resource. + Set a link to the API and build the REST URL used for this resource. """ self._api = api self._url = self.__url_pattern.format(**context, self=self) diff --git a/tests/test_models.py b/tests/test_models.py index e8c8fecd..83018b34 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -38,16 +38,29 @@ class Subclass(*base_classes, **kwargs): return _creates_instance -def test_raw(raw_data, api): +def test_raw(api): """ Test that AirtableModel.from_api saves the raw value, so that developers can access the exact payload we received from the API. This is mostly - in case Airtable adds new things to webhooks or webhook payloads in the future. + in case Airtable adds new things to webhooks or schemas in the future. """ - obj = AirtableModel.from_api(raw_data, api) + + class Grandchild(AirtableModel): + value: int + + class Child(AirtableModel): + grandchild: Grandchild + + class Parent(AirtableModel): + child: Child + + raw = {"child": {"grandchild": {"value": 1}}, "foo": "FOO", "bar": "BAR"} + obj = Parent.from_api(raw, api) assert not hasattr(obj, "foo") assert not hasattr(obj, "bar") - assert obj._raw == raw_data + assert obj._raw == raw + assert obj.child._raw == raw["child"] + assert obj.child.grandchild._raw == raw["child"]["grandchild"] @pytest.mark.parametrize("prefix", ["https://api.airtable.com/v0/prefix", "prefix"]) From 9304dae8e682675b2fb173cd1d690899cb6d087e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 21 Jan 2024 21:48:20 -0800 Subject: [PATCH 061/272] collaborators().{add_user,add_group,update,remove} --- pyairtable/models/schema.py | 107 ++++++++++++++++++++++-------------- tests/conftest.py | 4 +- tests/test_models_schema.py | 81 +++++++++++++++++++++++++-- 3 files changed, 146 insertions(+), 46 deletions(-) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 714591f8..bee3c1b5 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -34,6 +34,68 @@ def _find(collection: List[_T], id_or_name: str) -> _T: return items_by_name[id_or_name] +class _Collaborators(RestfulModel): + """ + Mixin for use with RestfulModel subclasses that have a /collaborators endpoint. + """ + + def _add_collaborator( + self, collaborator_type: str, collaborator_id: str, permission_level: str + ) -> None: + payload = { + "collaborators": [ + { + collaborator_type: {"id": collaborator_id}, + "permissionLevel": permission_level, + } + ] + } + self._api.post(f"{self._url}/collaborators", json=payload) + self._reload() + + def add_user(self, user_id: str, permission_level: str) -> None: + """ + Add a user as a collaborator. + + Args: + user_id: The user ID. + permission_level: See `application permission levels `__. + """ + self._add_collaborator("user", user_id, permission_level) + + def add_group(self, group_id: str, permission_level: str) -> None: + """ + Add a group as a collaborator. + + Args: + group_id: The group ID. + permission_level: See `application permission levels `__. + """ + self._add_collaborator("group", group_id, permission_level) + + def update(self, collaborator_id: str, permission_level: str) -> None: + """ + Change the permission level granted to a user or group. + + Args: + collaborator_id: The user or group ID. + permission_level: See `application permission levels `__. + """ + self._api.patch( + f"{self._url}/collaborators/{collaborator_id}", + json={"permissionLevel": permission_level}, + ) + + def remove(self, collaborator_id: str) -> None: + """ + Remove a user or group as a collaborator. + + Args: + collaborator_id: The user or group ID. + """ + self._api.delete(f"{self._url}/collaborators/{collaborator_id}") + + class Bases(AirtableModel): """ The list of bases visible to the API token. @@ -55,7 +117,7 @@ class Info(AirtableModel): permission_level: str -class BaseCollaborators(RestfulModel, url="meta/bases/{base.id}"): +class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"): """ Detailed information about who can access a base. @@ -91,40 +153,6 @@ class InviteLinks(AirtableModel): base_invite_links: List["InviteLink"] = _FL() workspace_invite_links: List["InviteLink"] = _FL() - def _add_collaborator( - self, collaborator_type: str, collaborator_id: str, permission_level: str - ) -> None: - payload = { - "collaborators": [ - { - collaborator_type: {"id": collaborator_id}, - "permissionLevel": permission_level, - } - ] - } - self._api.post(f"{self._url}/collaborators", json=payload) - self._reload() - - def add_user(self, user_id: str, permission_level: str) -> None: - """ - Add a user as a base collaborator. - - Args: - user_id: The user ID. - permission_level: See `application permission levels `__. - """ - self._add_collaborator("user", user_id, permission_level) - - def add_group(self, group_id: str, permission_level: str) -> None: - """ - Add a group as a base collaborator. - - Args: - group_id: The group ID. - permission_level: See `application permission levels `__. - """ - self._add_collaborator("group", group_id, permission_level) - class BaseShares(AirtableModel): """ @@ -315,7 +343,7 @@ class EmailDomain(AirtableModel): is_sso_required: bool -class WorkspaceCollaborators(AirtableModel): +class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): """ Detailed information about who can access a workspace. @@ -326,13 +354,10 @@ class WorkspaceCollaborators(AirtableModel): name: str created_time: str base_ids: List[str] - # We really don't need black to wrap these lines of text. - # fmt: off - restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") + restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip group_collaborators: Optional["WorkspaceCollaborators.GroupCollaborators"] = None - individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None + individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None # fmt: skip invite_links: Optional["WorkspaceCollaborators.InviteLinks"] = None - # fmt: on class Restrictions(AirtableModel): invite_creation: str = pydantic.Field(alias="inviteCreationRestriction") diff --git a/tests/conftest.py b/tests/conftest.py index 7bd9593f..bf0c02e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,9 @@ def _url_builder(base_id, table_name, params=None): @pytest.fixture def constants(): return dict( - API_KEY="FakeApiKey", BASE_ID="appJMY16gZDQrMWpA", TABLE_NAME="Table Name" + API_KEY="FakeApiKey", + BASE_ID="appJMY16gZDQrMWpA", + TABLE_NAME="Table Name", ) diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index fd7659bf..9ee160bf 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -5,6 +5,7 @@ import pyairtable.models.schema from pyairtable.models._base import AirtableModel +from pyairtable.testing import fake_id @pytest.mark.parametrize( @@ -119,18 +120,90 @@ def test_find(): @pytest.mark.parametrize( - "kind,collaborator", + "kind,id", [ ("user", "usrsOEchC9xuwRgKk"), ("group", "ugpR8ZT9KtIgp8Bh3"), ], ) -def test_base_collaborators__add(base, kind, collaborator, requests_mock, sample_json): +def test_base_collaborators__add(base, kind, id, requests_mock, sample_json): + """ + Test that we can call base.collaborators().add_{user,group} + to grant access to the base. + """ requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators")) m = requests_mock.post(base.meta_url("collaborators"), body="") method = getattr(base.collaborators(), f"add_{kind}") - method(collaborator, "read") + method(id, "read") assert m.call_count == 1 assert m.last_request.json() == { - "collaborators": [{kind: {"id": collaborator}, "permissionLevel": "read"}] + "collaborators": [{kind: {"id": id}, "permissionLevel": "read"}] } + + +@pytest.mark.parametrize( + "kind,id", + [ + ("user", "usrsOEchC9xuwRgKk"), + ("group", "ugpR8ZT9KtIgp8Bh3"), + ], +) +def test_workspace_collaborators__add(api, kind, id, requests_mock, sample_json): + """ + Test that we can call workspace.collaborators().add_{user,group} + to grant access to the workspace. + """ + workspace_json = sample_json("WorkspaceCollaborators") + workspace = api.workspace(workspace_json["id"]) + requests_mock.get(workspace.url, json=workspace_json) + m = requests_mock.post(f"{workspace.url}/collaborators", body="") + method = getattr(workspace.collaborators(), f"add_{kind}") + method(id, "read") + assert m.call_count == 1 + assert m.last_request.json() == { + "collaborators": [{kind: {"id": id}, "permissionLevel": "read"}] + } + + +@pytest.mark.parametrize( + "name,id", + [ + ("base", "appLkNDICXNqxSDhG"), + ("workspace", "wspmhESAta6clCCwF"), + ], +) +def test_update_collaborator(api, name, id, requests_mock, sample_json): + """ + Test that we can call collaborators().update() to change the permission level + of a user or group on a base or workspace. + """ + target = getattr(api, name)(id) + grp = fake_id("grp") + obj = sample_json(f"{name.capitalize()}Collaborators") + requests_mock.get(api.build_url(f"meta/{name}s/{id}"), json=obj) + m = requests_mock.patch(api.build_url(f"meta/{name}s/{id}/collaborators/{grp}")) + target.collaborators().update(grp, "read") + assert m.call_count == 1 + assert m.last_request.json() == {"permissionLevel": "read"} + + +@pytest.mark.parametrize( + "name,id", + [ + ("base", "appLkNDICXNqxSDhG"), + ("workspace", "wspmhESAta6clCCwF"), + ], +) +def test_remove_collaborator(api, name, id, requests_mock, sample_json): + """ + Test that we can call collaborators().remove() to revoke permissions + from a user or group to a base or workspace. + """ + target = getattr(api, name)(id) + grp = fake_id("grp") + obj = sample_json(f"{name.capitalize()}Collaborators") + requests_mock.get(api.build_url(f"meta/{name}s/{id}"), json=obj) + m = requests_mock.delete(api.build_url(f"meta/{name}s/{id}/collaborators/{grp}")) + target.collaborators().remove(grp) + assert m.call_count == 1 + assert m.last_request.body is None From 3d336bc185bb9b50a91c9f957db593bdfeeeacb8 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 22 Jan 2024 13:08:20 -0800 Subject: [PATCH 062/272] InviteLink.delete() --- docs/source/_substitutions.rst | 3 + pyairtable/models/_base.py | 8 ++- pyairtable/models/schema.py | 72 +++++++++++++------ tests/conftest.py | 14 +++- tests/sample_data/WorkspaceCollaborators.json | 2 +- tests/test_api_base.py | 2 +- tests/test_api_table.py | 2 +- tests/test_models_schema.py | 34 ++++++++- 8 files changed, 109 insertions(+), 28 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index d0fd0b8a..c95a5626 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -70,6 +70,9 @@ If ``True``, will fetch information from the metadata API and validate the ID/name exists, raising ``KeyError`` if it does not. +.. |kwarg_permission_level| replace:: + See `application permission levels `__. + .. |warn| unicode:: U+26A0 .. WARNING SIGN .. |enterprise_only| replace:: |warn| This feature is only available on Enterprise billing plans. diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 5632e18c..f1bfc668 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -102,8 +102,8 @@ def cascade_api( # If it's a ModelNamedThis, the key will be model_named_this. context = {**context, _context_name(obj): obj} - # This is what we came here for if isinstance(obj, RestfulModel): + # This is what we came here for; set the API and URL on the RESTful model. obj._set_api(api, context=context) # Find and apply API/context to nested models in every Pydantic field. @@ -136,8 +136,12 @@ def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> No Set a link to the API and build the REST URL used for this resource. """ self._api = api - self._url = self.__url_pattern.format(**context, self=self) self._url_context = context + try: + self._url = self.__url_pattern.format(**context, self=self) + except (KeyError, AttributeError) as exc: + exc.args = (*exc.args, context) + raise if self._url and not self._url.startswith("http"): self._url = api.build_url(self._url) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index bee3c1b5..8846b24e 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,3 +1,4 @@ +import importlib from functools import partial from typing import Any, Dict, List, Literal, Optional, TypeVar, Union @@ -18,6 +19,18 @@ _FD = partial(pydantic.Field, default_factory=dict) +def _F(classname: str, **kwargs: Any) -> Any: + def _create_default_from_classname() -> Any: + this_module = importlib.import_module(__name__) + obj = this_module + for segment in classname.split("."): + obj = getattr(obj, segment) + return obj + + kwargs["default_factory"] = _create_default_from_classname + return pydantic.Field(**kwargs) + + def _find(collection: List[_T], id_or_name: str) -> _T: """ For use on a collection model to find objects by either id or name. @@ -79,7 +92,7 @@ def update(self, collaborator_id: str, permission_level: str) -> None: Args: collaborator_id: The user or group ID. - permission_level: See `application permission levels `__. + permission_level: |kwarg_permission_level| """ self._api.patch( f"{self._url}/collaborators/{collaborator_id}", @@ -129,9 +142,9 @@ class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"): permission_level: str workspace_id: str interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD() - group_collaborators: Optional["BaseCollaborators.GroupCollaborators"] - individual_collaborators: Optional["BaseCollaborators.IndividualCollaborators"] - invite_links: Optional["BaseCollaborators.InviteLinks"] + group_collaborators: "BaseCollaborators.GroupCollaborators" = _F("BaseCollaborators.GroupCollaborators") # fmt: skip + individual_collaborators: "BaseCollaborators.IndividualCollaborators" = _F("BaseCollaborators.IndividualCollaborators") # fmt: skip + invite_links: "BaseCollaborators.InviteLinks" = _F("BaseCollaborators.InviteLinks") # fmt: skip class InterfaceCollaborators(AirtableModel): created_time: str @@ -145,13 +158,11 @@ class GroupCollaborators(AirtableModel): class IndividualCollaborators(AirtableModel): via_base: List["IndividualCollaborator"] = _FL(alias="baseCollaborators") - via_workspace: List["IndividualCollaborator"] = _FL( - alias="workspaceCollaborators" - ) + via_workspace: List["IndividualCollaborator"] = _FL(alias="workspaceCollaborators") # fmt: skip class InviteLinks(AirtableModel): - base_invite_links: List["InviteLink"] = _FL() - workspace_invite_links: List["InviteLink"] = _FL() + via_base: List["InviteLink"] = _FL(alias="baseInviteLinks") + via_workspace: List["InviteLinkViaWorkspace"] = _FL(alias="workspaceInviteLinks") # fmt: skip class BaseShares(AirtableModel): @@ -301,7 +312,9 @@ class IndividualCollaborator(AirtableModel): permission_level: str -class InviteLink(AirtableModel): +class InviteLink( + CanDeleteModel, url="meta/bases/{base_collaborators.id}/invites/{self.id}" +): id: str type: str created_time: str @@ -311,6 +324,13 @@ class InviteLink(AirtableModel): restricted_to_email_domains: List[str] = _FL() +class InviteLinkViaWorkspace( + InviteLink, + url="meta/workspaces/{base_collaborators.workspace_id}/invites/{self.id}", +): + pass + + class BaseIndividualCollaborator(IndividualCollaborator): base_id: str @@ -319,10 +339,20 @@ class BaseGroupCollaborator(GroupCollaborator): base_id: str -class BaseInviteLink(InviteLink): +class BaseInviteLink( + InviteLink, + url="meta/bases/{self.base_id}/invites/{self.id}", +): base_id: str +class WorkspaceInviteLink( + InviteLink, + url="meta/workspaces/{workspace_collaborators.id}/invites/{self.id}", +): + pass + + class EnterpriseInfo(AirtableModel): """ Information about groups, users, workspaces, and email domains @@ -355,25 +385,27 @@ class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): created_time: str base_ids: List[str] restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip - group_collaborators: Optional["WorkspaceCollaborators.GroupCollaborators"] = None - individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None # fmt: skip - invite_links: Optional["WorkspaceCollaborators.InviteLinks"] = None + group_collaborators: "WorkspaceCollaborators.GroupCollaborators" = _F("WorkspaceCollaborators.GroupCollaborators") # fmt: skip + individual_collaborators: "WorkspaceCollaborators.IndividualCollaborators" = _F("WorkspaceCollaborators.IndividualCollaborators") # fmt: skip + invite_links: "WorkspaceCollaborators.InviteLinks" = _F("WorkspaceCollaborators.InviteLinks") # fmt: skip class Restrictions(AirtableModel): invite_creation: str = pydantic.Field(alias="inviteCreationRestriction") share_creation: str = pydantic.Field(alias="shareCreationRestriction") class GroupCollaborators(AirtableModel): - base_collaborators: List["BaseGroupCollaborator"] - workspace_collaborators: List["GroupCollaborator"] + via_base: List["BaseGroupCollaborator"] = _FL(alias="baseCollaborators") + via_workspace: List["GroupCollaborator"] = _FL(alias="workspaceCollaborators") class IndividualCollaborators(AirtableModel): - base_collaborators: List["BaseIndividualCollaborator"] - workspace_collaborators: List["IndividualCollaborator"] + via_base: List["BaseIndividualCollaborator"] = _FL(alias="baseCollaborators") + via_workspace: List["IndividualCollaborator"] = _FL( + alias="workspaceCollaborators" + ) class InviteLinks(AirtableModel): - base_invite_links: List["BaseInviteLink"] - workspace_invite_links: List["InviteLink"] + via_base: List["BaseInviteLink"] = _FL(alias="baseInviteLinks") + via_workspace: List["WorkspaceInviteLink"] = _FL(alias="workspaceInviteLinks") class NestedId(AirtableModel): diff --git a/tests/conftest.py b/tests/conftest.py index bf0c02e5..d5179693 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from mock import Mock from requests import HTTPError -from pyairtable.api import Api, Base, Table +from pyairtable import Api, Base, Table, Workspace @pytest.fixture @@ -31,7 +31,7 @@ def _url_builder(base_id, table_name, params=None): def constants(): return dict( API_KEY="FakeApiKey", - BASE_ID="appJMY16gZDQrMWpA", + BASE_ID="appLkNDICXNqxSDhG", TABLE_NAME="Table Name", ) @@ -56,6 +56,16 @@ def table(base: Base, constants) -> Table: return base.table(constants["TABLE_NAME"]) +@pytest.fixture +def workspace_id() -> str: + return "wspmhESAta6clCCwF" # see WorkspaceCollaborators.json + + +@pytest.fixture +def workspace(api: Api, workspace_id) -> Workspace: + return api.workspace(workspace_id) + + @pytest.fixture def mock_records(): return [ diff --git a/tests/sample_data/WorkspaceCollaborators.json b/tests/sample_data/WorkspaceCollaborators.json index ca14986f..253a3f79 100644 --- a/tests/sample_data/WorkspaceCollaborators.json +++ b/tests/sample_data/WorkspaceCollaborators.json @@ -71,7 +71,7 @@ "inviteLinks": { "baseInviteLinks": [ { - "baseId": "appSW9R5uCNmRmfl6", + "baseId": "appLkNDICXNqxSDhG", "createdTime": "2019-01-03T12:33:12.421Z", "id": "invJiqaXmPqq6Ec87", "invitedEmail": null, diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 4223680d..83432f4e 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -59,7 +59,7 @@ def test_repr(api, kwargs, expected): def test_url(base): - assert base.url == "https://api.airtable.com/v0/appJMY16gZDQrMWpA" + assert base.url == "https://api.airtable.com/v0/appLkNDICXNqxSDhG" def test_schema(base: Base, mock_tables_endpoint): diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 6bd6fe43..6fa43c6a 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -78,7 +78,7 @@ def test_invalid_constructor(api, base): def test_repr(table: Table): - assert repr(table) == "
" + assert repr(table) == "
" def test_schema(base, requests_mock, sample_json): diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 9ee160bf..08c5733e 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -67,7 +67,7 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json): "UserInfo.is_two_factor_auth_enabled": False, "UserInfo.name": "foo baz", "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"], - "WorkspaceCollaborators.invite_links.base_invite_links[0].id": "invJiqaXmPqq6Ec87", + "WorkspaceCollaborators.invite_links.via_base[0].id": "invJiqaXmPqq6Ec87", }.items(), ids=itemgetter(0), ) @@ -207,3 +207,35 @@ def test_remove_collaborator(api, name, id, requests_mock, sample_json): target.collaborators().remove(grp) assert m.call_count == 1 assert m.last_request.body is None + + +@pytest.mark.parametrize("kind", ["base", "workspace"]) +@pytest.mark.parametrize("via", ["base", "workspace"]) +def test_collaborators_invite_link__delete( + api, kind, via, base, workspace, requests_mock, sample_json +): + """ + Test that we can revoke an invite link against a base or a workspace + if it comes from either base.collaborators() or workspace.collaborators() + """ + # obj/kind => the object we're using to get invite links + obj = locals()[kind] + # via => the pathway through which the invite link was created + via_id = locals()[via].id + + # ensure .collaborators() gets the right kind of data back + requests_mock.get( + api.build_url(f"meta/{kind}s/{obj.id}"), + json=sample_json(f"{kind.capitalize()}Collaborators"), + ) + invite_link = getattr(obj.collaborators().invite_links, f"via_{via}")[0] + + # construct the URL we expect InviteLink.delete() to call + url = api.build_url(f"meta/{via}s/{via_id}/invites/{invite_link.id}") + endpoint = requests_mock.delete(url) + print(f"{kind=} {via=} {url=}") + + # test that it happens + invite_link.delete() + assert endpoint.call_count == 1 + assert endpoint.last_request.method == "DELETE" From b103015de611f11293652cb99ee7dd1494e97179 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 3 Feb 2024 22:38:51 -0800 Subject: [PATCH 063/272] Support adding/modifying interface collaborators --- pyairtable/api/types.py | 28 +++++++++ pyairtable/models/_base.py | 4 +- pyairtable/models/schema.py | 75 +++++++++++++++++------- tests/sample_data/BaseCollaborators.json | 27 +++++++++ tests/test_models_schema.py | 57 +++++++++++++++++- 5 files changed, 165 insertions(+), 26 deletions(-) diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index dc72cca8..5ab90ca6 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -2,6 +2,7 @@ pyAirtable provides a number of type aliases and TypedDicts which are used as inputs and return values to various pyAirtable methods. """ + from functools import lru_cache from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast @@ -25,6 +26,10 @@ FieldName: TypeAlias = str +class NestedIdDict(TypedDict): + id: str + + class AITextDict(TypedDict, total=False): """ A ``dict`` representing text generated by AI. @@ -179,6 +184,29 @@ class CollaboratorEmailDict(TypedDict): email: str +class AddUserCollaboratorDict(TypedDict): + """ + Used to add a user as a collaborator to a base, workspace, or interface. + """ + + user: NestedIdDict + permissionLevel: str + + +class AddGroupCollaboratorDict(TypedDict): + """ + Used to add a group as a collaborator to a base, workspace, or interface. + """ + + group: NestedIdDict + permissionLevel: str + + +AddCollaboratorDict: TypeAlias = Union[ + AddUserCollaboratorDict, AddGroupCollaboratorDict +] + + #: Represents the types of values that we might receive from the API. #: At present, is an alias for ``Any`` because we don't want to lose #: forward compatibility with any changes Airtable makes in the future. diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index f1bfc668..4e92aceb 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -93,8 +93,8 @@ def cascade_api( for value in obj: cascade_api(value, api, context=context) if isinstance(obj, dict): - for value in obj.values(): - cascade_api(value, api, context=context) + for key, value in obj.items(): + cascade_api(value, api, context={**context, "key": key}) if not isinstance(obj, AirtableModel): return diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 8846b24e..2b2f65b3 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,10 +1,11 @@ import importlib from functools import partial -from typing import Any, Dict, List, Literal, Optional, TypeVar, Union +from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast from typing_extensions import TypeAlias from pyairtable._compat import pydantic +from pyairtable.api.types import AddCollaboratorDict from ._base import ( AirtableModel, @@ -52,39 +53,66 @@ class _Collaborators(RestfulModel): Mixin for use with RestfulModel subclasses that have a /collaborators endpoint. """ - def _add_collaborator( - self, collaborator_type: str, collaborator_id: str, permission_level: str - ) -> None: - payload = { - "collaborators": [ - { - collaborator_type: {"id": collaborator_id}, - "permissionLevel": permission_level, - } - ] - } - self._api.post(f"{self._url}/collaborators", json=payload) - self._reload() - def add_user(self, user_id: str, permission_level: str) -> None: """ - Add a user as a collaborator. + Add a user as a collaborator to this Airtable object. Args: user_id: The user ID. - permission_level: See `application permission levels `__. + permission_level: |kwarg_permission_level| """ - self._add_collaborator("user", user_id, permission_level) + self.add_collaborator("user", user_id, permission_level) def add_group(self, group_id: str, permission_level: str) -> None: """ - Add a group as a collaborator. + Add a group as a collaborator to this Airtable object. Args: group_id: The group ID. - permission_level: See `application permission levels `__. + permission_level: |kwarg_permission_level| + """ + self.add_collaborator("group", group_id, permission_level) + + def add_collaborator( + self, + collaborator_type: str, + collaborator_id: str, + permission_level: str, + ) -> None: + """ + Add a user or group as a collaborator to this Airtable object. + + Args: + collaborator_type: Either ``'user'`` or ``'group'``. + collaborator_id: The user or group ID. + permission_level: |kwarg_permission_level| + """ + if collaborator_type not in ("user", "group"): + raise ValueError("collaborator_type must be 'user' or 'group'") + self.add_collaborators( + [ + cast( + AddCollaboratorDict, + { + collaborator_type: {"id": collaborator_id}, + "permissionLevel": permission_level, + }, + ) + ] + ) + + def add_collaborators(self, collaborators: Iterable[AddCollaboratorDict]) -> None: + """ + Add multiple collaborators to this Airtable object. + + Args: + collaborators: A list of ``dict`` that conform to the specification + laid out in the `Add base collaborator `__ + API documentation. """ - self._add_collaborator("group", group_id, permission_level) + payload = {"collaborators": list(collaborators)} + self._api.post(f"{self._url}/collaborators", json=payload) + self._reload() def update(self, collaborator_id: str, permission_level: str) -> None: """ @@ -146,7 +174,10 @@ class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"): individual_collaborators: "BaseCollaborators.IndividualCollaborators" = _F("BaseCollaborators.IndividualCollaborators") # fmt: skip invite_links: "BaseCollaborators.InviteLinks" = _F("BaseCollaborators.InviteLinks") # fmt: skip - class InterfaceCollaborators(AirtableModel): + class InterfaceCollaborators( + _Collaborators, + url="meta/bases/{base.id}/interfaces/{key}", + ): created_time: str group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() diff --git a/tests/sample_data/BaseCollaborators.json b/tests/sample_data/BaseCollaborators.json index 414aecf9..9516fdfe 100644 --- a/tests/sample_data/BaseCollaborators.json +++ b/tests/sample_data/BaseCollaborators.json @@ -61,6 +61,33 @@ } ] }, + "interfaces": { + "pbdLkNDICXNqxSDhG": { + "createdTime": "2024-02-04T02:28:06.000Z", + "firstPublishTime": "2024-02-04T02:28:12.000Z", + "groupCollaborators": [ + { + "createdTime": "2024-02-04T02:28:20.184Z", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "groupId": "ugpR8ZT9KtIgp8Bh3", + "name": "Test Interface", + "permissionLevel": "read" + } + ], + "id": "pbdLkNDICXNqxSDhG", + "individualCollaborators": [ + { + "createdTime": "2024-02-04T04:00:00.749Z", + "email": "test@example.com", + "grantedByUserId": "usrL2PNC5o3H4lBEi", + "permissionLevel": "edit", + "userId": "usrR8ZT9KtIgp8Bh3" + } + ], + "inviteLinks": [], + "name": "Interface" + } + }, "inviteLinks": { "baseInviteLinks": [ { diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 08c5733e..e105a953 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -8,6 +8,14 @@ from pyairtable.testing import fake_id +@pytest.fixture +def mock_base_metadata(api, base, sample_json, requests_mock): + base_json = sample_json("BaseCollaborators") + requests_mock.get(base.meta_url(), json=base_json) + for pbd_id, pbd_json in base_json["interfaces"].items(): + requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) + + @pytest.mark.parametrize( "clsname", [ @@ -126,12 +134,13 @@ def test_find(): ("group", "ugpR8ZT9KtIgp8Bh3"), ], ) -def test_base_collaborators__add(base, kind, id, requests_mock, sample_json): +def test_base_collaborators__add( + base, kind, id, requests_mock, sample_json, mock_base_metadata +): """ Test that we can call base.collaborators().add_{user,group} to grant access to the base. """ - requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators")) m = requests_mock.post(base.meta_url("collaborators"), body="") method = getattr(base.collaborators(), f"add_{kind}") method(id, "read") @@ -239,3 +248,47 @@ def test_collaborators_invite_link__delete( invite_link.delete() assert endpoint.call_count == 1 assert endpoint.last_request.method == "DELETE" + + +@pytest.fixture +def interface_url(base): + return base.meta_url("interfaces", "pbdLkNDICXNqxSDhG") + + +@pytest.mark.parametrize("kind", ("user", "group")) +def test_add_interface_collaborator( + base, kind, requests_mock, interface_url, mock_base_metadata +): + m = requests_mock.post(f"{interface_url}/collaborators", body="") + interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"] + method = getattr(interface_schema, f"add_{kind}") + method("testObjectId", "read") + assert m.call_count == 1 + assert m.last_request.json() == { + "collaborators": [ + { + kind: {"id": "testObjectId"}, + "permissionLevel": "read", + } + ] + } + + +def test_update_interface_collaborator( + base, interface_url, requests_mock, mock_base_metadata +): + m = requests_mock.patch(f"{interface_url}/collaborators/testObjectId") + interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"] + interface_schema.update("testObjectId", "read") + assert m.call_count == 1 + assert m.last_request.json() == {"permissionLevel": "read"} + + +def test_remove_interface_collaborator( + base, interface_url, requests_mock, mock_base_metadata +): + m = requests_mock.delete(f"{interface_url}/collaborators/testObjectId") + interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"] + interface_schema.remove("testObjectId") + assert m.call_count == 1 + assert m.last_request.body is None From 7d40259c261417ef91d009dda9465fa6b235cb17 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 8 Feb 2024 16:27:04 -0800 Subject: [PATCH 064/272] Test coverage for adding collaborators --- pyairtable/models/_base.py | 5 +- pyairtable/models/schema.py | 6 +- tests/test_models.py | 31 +++++++++++ tests/test_models_schema.py | 106 ++++++++++++++++++++++++++++++++---- 4 files changed, 133 insertions(+), 15 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 4e92aceb..516c1058 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -140,7 +140,10 @@ def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> No try: self._url = self.__url_pattern.format(**context, self=self) except (KeyError, AttributeError) as exc: - exc.args = (*exc.args, context) + exc.args = ( + *exc.args, + {k: v for (k, v) in context.items() if k != "__visited__"}, + ) raise if self._url and not self._url.startswith("http"): self._url = api.build_url(self._url) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 2b2f65b3..c94dd233 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -61,7 +61,7 @@ def add_user(self, user_id: str, permission_level: str) -> None: user_id: The user ID. permission_level: |kwarg_permission_level| """ - self.add_collaborator("user", user_id, permission_level) + self.add("user", user_id, permission_level) def add_group(self, group_id: str, permission_level: str) -> None: """ @@ -71,9 +71,9 @@ def add_group(self, group_id: str, permission_level: str) -> None: group_id: The group ID. permission_level: |kwarg_permission_level| """ - self.add_collaborator("group", group_id, permission_level) + self.add("group", group_id, permission_level) - def add_collaborator( + def add( self, collaborator_type: str, collaborator_id: str, diff --git a/tests/test_models.py b/tests/test_models.py index 83018b34..52e52560 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,6 +6,7 @@ AirtableModel, CanDeleteModel, CanUpdateModel, + RestfulModel, update_forward_refs, ) @@ -247,3 +248,33 @@ class Inner(AirtableModel): # This will cause RecursionError if we're not careful update_forward_refs(Outer) + + +def test_restfulmodel__set_url(api, base): + """ + Test that the RestfulModel class generates a URL based on API context. + Also test that RestfulModel puts the full URL context into certain types + of exceptions that occur during URL formatting. + """ + + class Dummy(RestfulModel, url="{base.id}/{dummy.one}/{dummy.two}"): + one: int + two: str + + data = {"one": 1, "two": "2"} + + d = Dummy.from_api(data, api, context={"base": base}) + assert d._url == api.build_url(f"{base.id}/1/2") + + with pytest.raises(KeyError) as exc_info: + Dummy.from_api(data, api) + + assert exc_info.match(r"\('base', \{'dummy': .*\}\)") + + with pytest.raises(AttributeError) as exc_info: + Dummy.from_api(data, api, context={"base": None}) + + assert exc_info.match( + r'"\'NoneType\' object has no attribute \'id\'"' + r", \{'base': None, 'dummy': Dummy\(.*\)\}" + ) diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index e105a953..53c588d7 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -1,6 +1,7 @@ -from operator import attrgetter, itemgetter -from typing import List, Optional +from operator import attrgetter +from typing import Any, List, Optional +import mock import pytest import pyairtable.models.schema @@ -8,6 +9,31 @@ from pyairtable.testing import fake_id +@pytest.fixture +def schema_obj(api, sample_json): + """ + Test fixture that provides a callable function which retrieves + an object generated from tests/sample_data, and optionally + retrieves an attribute of that object. + """ + + def _get_schema_obj(name: str, *, context: Any = None) -> Any: + obj_name, _, obj_path = name.partition(".") + obj_data = sample_json(obj_name) + obj_cls = getattr(pyairtable.models.schema, obj_name) + + if context: + obj = obj_cls.from_api(obj_data, api, context=context) + else: + obj = obj_cls.parse_obj(obj_data) + + if obj_path: + obj = eval(f"obj.{obj_path}", None, {"obj": obj}) + return obj + + return _get_schema_obj + + @pytest.fixture def mock_base_metadata(api, base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") @@ -56,7 +82,7 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json): @pytest.mark.parametrize( - "test_case", + "obj_path, expected_value", { "BaseCollaborators.individual_collaborators.via_base[0].permission_level": "create", "BaseCollaborators.individual_collaborators.via_base[0].user_id": "usrsOEchC9xuwRgKk", @@ -77,19 +103,13 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json): "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"], "WorkspaceCollaborators.invite_links.via_base[0].id": "invJiqaXmPqq6Ec87", }.items(), - ids=itemgetter(0), ) -def test_deserialized_values(test_case, sample_json): +def test_deserialized_values(obj_path, expected_value, schema_obj): """ Spot check that certain values get loaded correctly from JSON into Python. This is not intended to be comprehensive, just another chance to catch regressions. """ - clsname_attr, expected = test_case - clsname = clsname_attr.split(".")[0] - cls = attrgetter(clsname)(pyairtable.models.schema) - obj = cls.parse_obj(sample_json(clsname)) - val = eval(clsname_attr, None, {clsname: obj}) - assert val == expected + assert schema_obj(obj_path) == expected_value class Outer(AirtableModel): @@ -292,3 +312,67 @@ def test_remove_interface_collaborator( interface_schema.remove("testObjectId") assert m.call_count == 1 assert m.last_request.body is None + + +@pytest.mark.parametrize( + "target_path", + ( + "BaseCollaborators", + "WorkspaceCollaborators", + "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']", + ), +) +@pytest.mark.parametrize("kind", ("user", "group")) +def test_add_collaborator( + target_path, + kind, + schema_obj, + requests_mock, # ensures no network traffic +): + target = schema_obj(target_path) + with mock.patch.object(target.__class__, "add_collaborators") as m: + target.add(kind, "testId", "read") + m.assert_called_once_with([{kind: {"id": "testId"}, "permissionLevel": "read"}]) + + +@pytest.mark.parametrize( + "target_path", + ( + "BaseCollaborators", + "WorkspaceCollaborators", + "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']", + ), +) +def test_add_collaborator__invalid_kind( + target_path, + schema_obj, + requests_mock, # ensures no network traffic +): + target = schema_obj(target_path) + with mock.patch.object(target.__class__, "add_collaborators") as m: + with pytest.raises(ValueError): + target.add("asdf", "testId", "read") + assert m.call_count == 0 + + +@pytest.mark.parametrize( + "target_path", + ( + "BaseCollaborators", + "WorkspaceCollaborators", + "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']", + ), +) +def test_add_collaborators( + target_path, + schema_obj, + base, + workspace, + requests_mock, +): + target = schema_obj(target_path, context={"base": base, "workspace": workspace}) + requests_mock.get(target._url, json=target._raw) + m = requests_mock.post(target._url + "/collaborators") + target.add_collaborators([1, 2, 3, 4]) + assert m.call_count == 1 + assert m.last_request.json() == {"collaborators": [1, 2, 3, 4]} From 687ec33f06b9c32b41c70b8a24d09118345a8acf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 9 Feb 2024 08:44:36 -0800 Subject: [PATCH 065/272] Test coverage for RestfulModel URL generation --- pyairtable/models/schema.py | 60 +++++---- tests/sample_data/BaseCollaborators.json | 14 +- tests/sample_data/WorkspaceCollaborators.json | 4 +- tests/test_models_schema.py | 122 +++++++++++++----- 4 files changed, 142 insertions(+), 58 deletions(-) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index c94dd233..937ff030 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -181,7 +181,7 @@ class InterfaceCollaborators( created_time: str group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() - invite_links: List["InviteLink"] = _FL() + invite_links: List["InterfaceInviteLink"] = _FL() class GroupCollaborators(AirtableModel): via_base: List["GroupCollaborator"] = _FL(alias="baseCollaborators") @@ -191,9 +191,9 @@ class IndividualCollaborators(AirtableModel): via_base: List["IndividualCollaborator"] = _FL(alias="baseCollaborators") via_workspace: List["IndividualCollaborator"] = _FL(alias="workspaceCollaborators") # fmt: skip - class InviteLinks(AirtableModel): + class InviteLinks(RestfulModel, url="{base_collaborators._url}/invites"): via_base: List["InviteLink"] = _FL(alias="baseInviteLinks") - via_workspace: List["InviteLinkViaWorkspace"] = _FL(alias="workspaceInviteLinks") # fmt: skip + via_workspace: List["WorkspaceInviteLink"] = _FL(alias="workspaceInviteLinks") # fmt: skip class BaseShares(AirtableModel): @@ -343,9 +343,21 @@ class IndividualCollaborator(AirtableModel): permission_level: str -class InviteLink( - CanDeleteModel, url="meta/bases/{base_collaborators.id}/invites/{self.id}" -): +class BaseIndividualCollaborator(IndividualCollaborator): + base_id: str + + +class BaseGroupCollaborator(GroupCollaborator): + base_id: str + + +# URL generation for an InviteLink assumes that it is nested within +# a RestfulModel class named "InviteLink" that provides URL context. +class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"): + """ + Represents an `invite link `__. + """ + id: str type: str created_time: str @@ -355,33 +367,35 @@ class InviteLink( restricted_to_email_domains: List[str] = _FL() -class InviteLinkViaWorkspace( +class BaseInviteLink( InviteLink, - url="meta/workspaces/{base_collaborators.workspace_id}/invites/{self.id}", + url="meta/bases/{self.base_id}/invites/{self.id}", ): - pass - - -class BaseIndividualCollaborator(IndividualCollaborator): - base_id: str - + """ + Represents a `base invite link `__. + """ -class BaseGroupCollaborator(GroupCollaborator): base_id: str -class BaseInviteLink( +class WorkspaceInviteLink( InviteLink, - url="meta/bases/{self.base_id}/invites/{self.id}", + url="meta/workspaces/{base_collaborators.workspace_id}/invites/{self.id}", ): - base_id: str + """ + Represents an `invite link `__ + to a workspace that was returned within a base schema. + """ -class WorkspaceInviteLink( +class InterfaceInviteLink( InviteLink, - url="meta/workspaces/{workspace_collaborators.id}/invites/{self.id}", + url="{interface_collaborators._url}/invites/{self.id}", ): - pass + """ + Represents an `invite link `__ + to an interface that was returned within a base schema. + """ class EnterpriseInfo(AirtableModel): @@ -434,9 +448,9 @@ class IndividualCollaborators(AirtableModel): alias="workspaceCollaborators" ) - class InviteLinks(AirtableModel): + class InviteLinks(RestfulModel, url="{workspace_collaborators._url}/invites"): via_base: List["BaseInviteLink"] = _FL(alias="baseInviteLinks") - via_workspace: List["WorkspaceInviteLink"] = _FL(alias="workspaceInviteLinks") + via_workspace: List["InviteLink"] = _FL(alias="workspaceInviteLinks") class NestedId(AirtableModel): diff --git a/tests/sample_data/BaseCollaborators.json b/tests/sample_data/BaseCollaborators.json index 9516fdfe..411b4b56 100644 --- a/tests/sample_data/BaseCollaborators.json +++ b/tests/sample_data/BaseCollaborators.json @@ -84,7 +84,17 @@ "userId": "usrR8ZT9KtIgp8Bh3" } ], - "inviteLinks": [], + "inviteLinks": [ + { + "createdTime": "2019-01-03T12:33:12.421Z", + "id": "invJiqaXmPqq6ABCD", + "invitedEmail": "bam@bam.com", + "permissionLevel": "edit", + "referredByUserId": "usrL2PNC5o3H4lBEi", + "restrictedToEmailDomains": [], + "type": "singleUse" + } + ], "name": "Interface" } }, @@ -105,7 +115,7 @@ "workspaceInviteLinks": [ { "createdTime": "2019-01-03T12:33:12.421Z", - "id": "invJiqaXmPqq6Ec87", + "id": "invJiqaXmPqq6Ec99", "invitedEmail": "bam@bam.com", "permissionLevel": "edit", "referredByUserId": "usrL2PNC5o3H4lBEi", diff --git a/tests/sample_data/WorkspaceCollaborators.json b/tests/sample_data/WorkspaceCollaborators.json index 253a3f79..3d208014 100644 --- a/tests/sample_data/WorkspaceCollaborators.json +++ b/tests/sample_data/WorkspaceCollaborators.json @@ -73,7 +73,7 @@ { "baseId": "appLkNDICXNqxSDhG", "createdTime": "2019-01-03T12:33:12.421Z", - "id": "invJiqaXmPqq6Ec87", + "id": "invJiqaXmPqqAPP99", "invitedEmail": null, "permissionLevel": "read", "referredByUserId": "usrsOEchC9xuwRgKk", @@ -84,7 +84,7 @@ "workspaceInviteLinks": [ { "createdTime": "2019-01-03T12:33:12.421Z", - "id": "invJiqaXmPqq6Ec87", + "id": "invJiqaXmPqqWSP00", "invitedEmail": "bam@bam.com", "permissionLevel": "owner", "referredByUserId": "usrL2PNC5o3H4lBEi", diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 53c588d7..ac131c96 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -35,13 +35,20 @@ def _get_schema_obj(name: str, *, context: Any = None) -> Any: @pytest.fixture -def mock_base_metadata(api, base, sample_json, requests_mock): +def mock_base_metadata(base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") requests_mock.get(base.meta_url(), json=base_json) + requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) for pbd_id, pbd_json in base_json["interfaces"].items(): requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) +@pytest.fixture +def mock_workspace_metadata(workspace, sample_json, requests_mock): + workspace_json = sample_json("WorkspaceCollaborators") + requests_mock.get(workspace.url, json=workspace_json) + + @pytest.mark.parametrize( "clsname", [ @@ -101,7 +108,7 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json): "UserInfo.is_two_factor_auth_enabled": False, "UserInfo.name": "foo baz", "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"], - "WorkspaceCollaborators.invite_links.via_base[0].id": "invJiqaXmPqq6Ec87", + "WorkspaceCollaborators.invite_links.via_base[0].id": "invJiqaXmPqqAPP99", }.items(), ) def test_deserialized_values(obj_path, expected_value, schema_obj): @@ -238,36 +245,27 @@ def test_remove_collaborator(api, name, id, requests_mock, sample_json): assert m.last_request.body is None -@pytest.mark.parametrize("kind", ["base", "workspace"]) -@pytest.mark.parametrize("via", ["base", "workspace"]) -def test_collaborators_invite_link__delete( - api, kind, via, base, workspace, requests_mock, sample_json +def test_invite_link__delete( + base, + workspace, + requests_mock, + mock_base_metadata, + mock_workspace_metadata, ): """ - Test that we can revoke an invite link against a base or a workspace - if it comes from either base.collaborators() or workspace.collaborators() + Test that we can revoke an invite link. """ - # obj/kind => the object we're using to get invite links - obj = locals()[kind] - # via => the pathway through which the invite link was created - via_id = locals()[via].id - - # ensure .collaborators() gets the right kind of data back - requests_mock.get( - api.build_url(f"meta/{kind}s/{obj.id}"), - json=sample_json(f"{kind.capitalize()}Collaborators"), - ) - invite_link = getattr(obj.collaborators().invite_links, f"via_{via}")[0] - - # construct the URL we expect InviteLink.delete() to call - url = api.build_url(f"meta/{via}s/{via_id}/invites/{invite_link.id}") - endpoint = requests_mock.delete(url) - print(f"{kind=} {via=} {url=}") - - # test that it happens - invite_link.delete() - assert endpoint.call_count == 1 - assert endpoint.last_request.method == "DELETE" + for invite_link in [ + base.collaborators().invite_links.via_base[0], + base.collaborators().invite_links.via_workspace[0], + base.collaborators().interfaces["pbdLkNDICXNqxSDhG"].invite_links[0], + workspace.collaborators().invite_links.via_base[0], + workspace.collaborators().invite_links.via_workspace[0], + ]: + endpoint = requests_mock.delete(invite_link._url) + invite_link.delete() + assert endpoint.call_count == 1 + assert endpoint.last_request.method == "DELETE" @pytest.fixture @@ -327,7 +325,7 @@ def test_add_collaborator( target_path, kind, schema_obj, - requests_mock, # ensures no network traffic + requests_mock, # unused; ensures no network traffic ): target = schema_obj(target_path) with mock.patch.object(target.__class__, "add_collaborators") as m: @@ -346,7 +344,7 @@ def test_add_collaborator( def test_add_collaborator__invalid_kind( target_path, schema_obj, - requests_mock, # ensures no network traffic + requests_mock, # unused; ensures no network traffic ): target = schema_obj(target_path) with mock.patch.object(target.__class__, "add_collaborators") as m: @@ -376,3 +374,65 @@ def test_add_collaborators( target.add_collaborators([1, 2, 3, 4]) assert m.call_count == 1 assert m.last_request.json() == {"collaborators": [1, 2, 3, 4]} + + +@pytest.mark.parametrize( + "expr,expected_url", + [ + ( + "base.collaborators()", + "meta/bases/appLkNDICXNqxSDhG", + ), + ( + "base.collaborators().interfaces['pbdLkNDICXNqxSDhG']", + "meta/bases/appLkNDICXNqxSDhG/interfaces/pbdLkNDICXNqxSDhG", + ), + ( + "base.collaborators().invite_links.via_base[0]", + "meta/bases/appLkNDICXNqxSDhG/invites/invJiqaXmPqq6Ec87", + ), + ( + "base.collaborators().invite_links.via_workspace[0]", + "meta/workspaces/wspmhESAta6clCCwF/invites/invJiqaXmPqq6Ec99", + ), + ( + "base.collaborators().interfaces['pbdLkNDICXNqxSDhG'].invite_links[0]", + "meta/bases/appLkNDICXNqxSDhG/interfaces/pbdLkNDICXNqxSDhG/invites/invJiqaXmPqq6ABCD", + ), + ( + "workspace.collaborators().invite_links.via_base[0]", + "meta/bases/appLkNDICXNqxSDhG/invites/invJiqaXmPqqAPP99", + ), + ( + "workspace.collaborators().invite_links.via_workspace[0]", + "meta/workspaces/wspmhESAta6clCCwF/invites/invJiqaXmPqqWSP00", + ), + ( + "table.schema()", + "meta/bases/appLkNDICXNqxSDhG/tables/tbltp8DGLhqbUmjK1", + ), + ( + "table.schema().field('fld1VnoyuotSTyxW1')", + "meta/bases/appLkNDICXNqxSDhG/tables/tbltp8DGLhqbUmjK1/fields/fld1VnoyuotSTyxW1", + ), + ( + "table.schema().view('viwQpsuEDqHFqegkp')", + "meta/bases/appLkNDICXNqxSDhG/views/viwQpsuEDqHFqegkp", + ), + ], +) +def test_restful_urls( + expr, + expected_url, + api, + base, + workspace, + mock_base_metadata, # unused; ensures no network traffic + mock_workspace_metadata, # unused; ensures no network traffic +): + """ + Test that the URLs for RestfulModels are generated correctly. + """ + table = base.table("tbltp8DGLhqbUmjK1") + obj = eval(expr, None, {"base": base, "table": table, "workspace": workspace}) + assert obj._url == api.build_url(expected_url) From f9f79aaf15c572e5a1f7d26370269ad58de541ce Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 9 Feb 2024 16:50:54 -0800 Subject: [PATCH 066/272] Manage/delete base share --- pyairtable/models/_base.py | 7 ++++++- pyairtable/models/schema.py | 16 ++++++++++++++- tests/test_models_schema.py | 39 +++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 516c1058..d22acca9 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -198,6 +198,7 @@ class CanUpdateModel(RestfulModel): __writable: ClassVar[Optional[Iterable[str]]] = None __readonly: ClassVar[Optional[Iterable[str]]] = None __save_none: ClassVar[bool] = True + __reload_after_save: ClassVar[bool] = True def __init_subclass__(cls, **kwargs: Any) -> None: if "writable" in kwargs and "readonly" in kwargs: @@ -205,6 +206,9 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.__writable = kwargs.pop("writable", cls.__writable) cls.__readonly = kwargs.pop("readonly", cls.__readonly) cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none)) + cls.__reload_after_save = bool( + kwargs.pop("reload_after_save", cls.__reload_after_save) + ) if cls.__writable: _append_docstring_text( cls, @@ -239,7 +243,8 @@ def save(self) -> None: exclude_none=(not self.__save_none), ) response = self._api.request("PATCH", self._url, json=data) - self._reload(response) + if self.__reload_after_save: + self._reload(response) def __setattr__(self, name: str, value: Any) -> None: # Prevents implementers from changing values on readonly or non-writable fields. diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 937ff030..44c478d9 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -205,7 +205,13 @@ class BaseShares(AirtableModel): shares: List["BaseShares.Info"] - class Info(AirtableModel): + class Info( + CanUpdateModel, + CanDeleteModel, + url="meta/bases/{base.id}/shares/{self.share_id}", + writable=["state"], + reload_after_save=False, + ): state: str created_by_user_id: str created_time: str @@ -217,6 +223,14 @@ class Info(AirtableModel): view_id: Optional[str] = None effective_email_domain_allow_list: List[str] = _FL() + def enable(self) -> None: + self.state = "enabled" + self.save() + + def disable(self) -> None: + self.state = "disabled" + self.save() + class BaseSchema(AirtableModel): """ diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index ac131c96..8df0f700 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -4,7 +4,7 @@ import mock import pytest -import pyairtable.models.schema +from pyairtable.models import schema from pyairtable.models._base import AirtableModel from pyairtable.testing import fake_id @@ -20,7 +20,7 @@ def schema_obj(api, sample_json): def _get_schema_obj(name: str, *, context: Any = None) -> Any: obj_name, _, obj_path = name.partition(".") obj_data = sample_json(obj_name) - obj_cls = getattr(pyairtable.models.schema, obj_name) + obj_cls = getattr(schema, obj_name) if context: obj = obj_cls.from_api(obj_data, api, context=context) @@ -39,6 +39,7 @@ def mock_base_metadata(base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") requests_mock.get(base.meta_url(), json=base_json) requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) for pbd_id, pbd_json in base_json["interfaces"].items(): requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) @@ -60,11 +61,11 @@ def mock_workspace_metadata(workspace, sample_json, requests_mock): ], ) def test_parse(sample_json, clsname): - cls = attrgetter(clsname)(pyairtable.models.schema) + cls = attrgetter(clsname)(schema) cls.parse_obj(sample_json(clsname)) -@pytest.mark.parametrize("cls", pyairtable.models.schema.FieldSchema.__args__) +@pytest.mark.parametrize("cls", schema.FieldSchema.__args__) def test_parse_field(sample_json, cls): cls.parse_obj(sample_json("field_schema/" + cls.__name__)) @@ -83,7 +84,7 @@ def test_parse_field(sample_json, cls): ], ) def test_find_in_collection(clsname, method, id_or_name, sample_json): - cls = attrgetter(clsname)(pyairtable.models.schema) + cls = attrgetter(clsname)(schema) obj = cls.parse_obj(sample_json(clsname)) assert getattr(obj, method)(id_or_name) @@ -128,7 +129,7 @@ class Inner(AirtableModel): deleted: Optional[bool] = None def find(self, id_or_name): - return pyairtable.models.schema._find(self.inners, id_or_name) + return schema._find(self.inners, id_or_name) def test_find(): @@ -436,3 +437,29 @@ def test_restful_urls( table = base.table("tbltp8DGLhqbUmjK1") obj = eval(expr, None, {"base": base, "table": table, "workspace": workspace}) assert obj._url == api.build_url(expected_url) + + +@pytest.fixture +def base_share(base, mock_base_metadata) -> schema.BaseShares.Info: + return base.shares()[0] + + +def test_share__enable(base_share, requests_mock): + m = requests_mock.patch(base_share._url) + base_share.enable() + assert m.call_count == 1 + assert m.last_request.json() == {"state": "enabled"} + + +def test_share__disable(base_share, requests_mock): + m = requests_mock.patch(base_share._url) + base_share.disable() + assert m.call_count == 1 + assert m.last_request.json() == {"state": "disabled"} + + +def test_share__delete(base_share, requests_mock): + m = requests_mock.delete(base_share._url) + base_share.delete() + assert m.call_count == 1 + assert m.last_request.body is None From 5a14593e591344d457749d8de8f6ef5e63d23f87 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 9 Feb 2024 17:03:27 -0800 Subject: [PATCH 067/272] Manage workspace restrictions --- pyairtable/models/_base.py | 4 +++- pyairtable/models/schema.py | 13 ++++++++++++- tests/test_models_schema.py | 15 ++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index d22acca9..479d201f 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -198,6 +198,7 @@ class CanUpdateModel(RestfulModel): __writable: ClassVar[Optional[Iterable[str]]] = None __readonly: ClassVar[Optional[Iterable[str]]] = None __save_none: ClassVar[bool] = True + __save_http_method: ClassVar[str] = "PATCH" __reload_after_save: ClassVar[bool] = True def __init_subclass__(cls, **kwargs: Any) -> None: @@ -206,6 +207,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.__writable = kwargs.pop("writable", cls.__writable) cls.__readonly = kwargs.pop("readonly", cls.__readonly) cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none)) + cls.__save_http_method = kwargs.pop("save_method", cls.__save_http_method) cls.__reload_after_save = bool( kwargs.pop("reload_after_save", cls.__reload_after_save) ) @@ -242,7 +244,7 @@ def save(self) -> None: exclude=exclude, exclude_none=(not self.__save_none), ) - response = self._api.request("PATCH", self._url, json=data) + response = self._api.request(self.__save_http_method, self._url, json=data) if self.__reload_after_save: self._reload(response) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 44c478d9..6256f2e1 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -224,10 +224,16 @@ class Info( effective_email_domain_allow_list: List[str] = _FL() def enable(self) -> None: + """ + Enable the base share. + """ self.state = "enabled" self.save() def disable(self) -> None: + """ + Disable the base share. + """ self.state = "disabled" self.save() @@ -448,7 +454,12 @@ class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): individual_collaborators: "WorkspaceCollaborators.IndividualCollaborators" = _F("WorkspaceCollaborators.IndividualCollaborators") # fmt: skip invite_links: "WorkspaceCollaborators.InviteLinks" = _F("WorkspaceCollaborators.InviteLinks") # fmt: skip - class Restrictions(AirtableModel): + class Restrictions( + CanUpdateModel, + url="{workspace_collaborators._url}/updateRestrictions", + save_method="POST", + reload_after_save=False, + ): invite_creation: str = pydantic.Field(alias="inviteCreationRestriction") share_creation: str = pydantic.Field(alias="shareCreationRestriction") diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 8df0f700..dfbd26ef 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -266,7 +266,6 @@ def test_invite_link__delete( endpoint = requests_mock.delete(invite_link._url) invite_link.delete() assert endpoint.call_count == 1 - assert endpoint.last_request.method == "DELETE" @pytest.fixture @@ -463,3 +462,17 @@ def test_share__delete(base_share, requests_mock): base_share.delete() assert m.call_count == 1 assert m.last_request.body is None + + +def test_workspace_restrictions(workspace, mock_workspace_metadata, requests_mock): + restrictions = workspace.collaborators().restrictions + restrictions.invite_creation = "unrestricted" + restrictions.share_creation = "onlyOwners" + + m = requests_mock.post(restrictions._url) + restrictions.save() + assert m.call_count == 1 + assert m.last_request.json() == { + "inviteCreationRestriction": "unrestricted", + "shareCreationRestriction": "onlyOwners", + } From 40c75db6dc96d9ae603a83dac92f18d0628ab2ff Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 9 Feb 2024 17:08:58 -0800 Subject: [PATCH 068/272] Update documentation for managing permissions --- docs/source/changelog.rst | 2 + docs/source/enterprise.rst | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0ed91d5d..326702ef 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,6 +5,8 @@ Changelog 2.3.0 (TBD) ------------------------ +* Added support for :ref:`managing permissions and shares`. + - `PR #337 `_. * Added :meth:`Enterprise.audit_log ` to iterate page-by-page through `audit log events `__. - `PR #330 `_. diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index 871c2319..184d0e3a 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -1,10 +1,14 @@ .. include:: _substitutions.rst .. include:: _warn_latest.rst + Enterprise Features ============================== +Retrieving information +---------------------- + pyAirtable exposes a number of classes and methods for interacting with enterprise organizations. The following methods are only available on an `Enterprise plan `__. If you call one of them against a base that is not part of an enterprise workspace, Airtable will @@ -25,5 +29,106 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check .. automethod:: pyairtable.Enterprise.info :noindex: + +Retrieving audit logs +--------------------- + .. automethod:: pyairtable.Enterprise.audit_log :noindex: + + +Managing permissions and shares +------------------------------- + +You can use pyAirtable to change permissions on a base or workspace +via the following methods exposed on schema objects. + +If for some reason you need to call these API endpoints without first retrieving +schema information, you might consider calling :meth:`~pyairtable.Api.request` directly. + +`Add base collaborator `__ + + >>> base.collaborators().add_user("usrUserId", "read") + >>> base.collaborators().add_group("ugpGroupId", "edit") + >>> base.collaborators().add("user", "usrUserId", "comment") + +`Add interface collaborator `__ + + >>> base.collaborators().interfaces[pbd].add_user("usrUserId", "read") + >>> base.collaborators().interfaces[pbd].add_group("ugpGroupId", "read") + >>> base.collaborators().interfaces[pbd].add("user", "usrUserId", "read") + +`Add workspace collaborator `__ + + >>> workspace.collaborators().add_user("usrUserId", "read") + >>> workspace.collaborators().add_group("ugpGroupId", "edit") + >>> workspace.collaborators().add("user", "usrUserId", "comment") + +`Update collaborator base permission `__ + + >>> base.collaborators().update("usrUserId", "edit") + >>> base.collaborators().update("ugpGroupId", "edit") + +`Update interface collaborator `__ + + >>> base.collaborators().interfaces[pbd].update("usrUserId", "edit") + >>> base.collaborators().interfaces[pbd].update("ugpGroupId", "edit") + +`Update workspace collaborator `__ + + >>> workspace.collaborators().update("usrUserId", "edit") + >>> workspace.collaborators().update("ugpGroupId", "edit") + +`Delete base collaborator `__ + + >>> base.collaborators().remove("usrUserId") + >>> base.collaborators().remove("ugpGroupId") + +`Delete interface collaborator `__ + + >>> base.collaborators().interfaces[pbd].remove("usrUserId") + >>> base.collaborators().interfaces[pbd].remove("ugpGroupId") + +`Delete workspace collaborator `__ + + >>> workspace.collaborators().remove("usrUserId") + >>> workspace.collaborators().remove("ugpGroupId") + +`Delete base invite `__ + + >>> base.collaborators().invite_links.via_base[0].delete() + >>> workspace.collaborators().invite_links.via_base[0].delete() + +`Delete interface invite `__ + + >>> base.collaborators().interfaces["pbdLkNDICXNqxSDhG"].invite_links[0].delete() + +`Delete workspace invite `__ + + >>> base.collaborators().invite_links.via_workspace[0].delete() + >>> workspace.collaborators().invite_links.via_workspace[0].delete() + +`Manage share `__ + + .. code-block:: python + + >>> share = base.shares()[0] + >>> share.disable() + >>> share.enable() + + :meth:`~pyairtable.models.schema.BaseShares.Info.disable` and + :meth:`~pyairtable.models.schema.BaseShares.Info.enable` are shortcuts for: + + >>> share.state = "enabled" + >>> share.save() + +`Delete share `__ + + >>> share.delete() + +`Update workspace restrictions `__ + + >>> r = workspace.collaborators().restrictions + >>> r.invite_creation = "unrestricted" + >>> r.share_creation = "onlyOwners" + >>> r.save() From cf325f087176316c08e91df4ce9d2e36076450c1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 10 Feb 2024 22:16:31 -0800 Subject: [PATCH 069/272] Remove user from enterprise --- docs/source/enterprise.rst | 8 +++ pyairtable/api/enterprise.py | 81 +++++++++++++++++++++++++++++- tests/conftest.py | 29 ++++++++++- tests/sample_data/UserRemoved.json | 40 +++++++++++++++ tests/test_api_enterprise.py | 25 +++++++++ tests/test_models_schema.py | 27 +--------- 6 files changed, 182 insertions(+), 28 deletions(-) create mode 100644 tests/sample_data/UserRemoved.json diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index 184d0e3a..20dbae0f 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -132,3 +132,11 @@ schema information, you might consider calling :meth:`~pyairtable.Api.request` d >>> r.invite_creation = "unrestricted" >>> r.share_creation = "onlyOwners" >>> r.save() + + +Managing users +------------------- + +`Remove user from enterprise `__ + + >>> enterprise.remove_user("usrUserId", replacement="usrOtherUserId") diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 5a0d8ba9..16a9641d 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,6 +1,7 @@ import datetime -from typing import Iterable, Iterator, List, Optional, Union +from typing import Any, Dict, Iterable, Iterator, List, Optional, Union +from pyairtable.models._base import AirtableModel, update_forward_refs from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import ( @@ -224,6 +225,84 @@ def handle_event(event): if page_limit is not None and count >= page_limit: return + def remove_user( + self, + user_id: str, + replacement: Optional[str] = None, + ) -> "UserRemoved": + """ + Unshare a user from all enterprise workspaces, bases, and interfaces. + If applicable, the user will also be removed from as an enterprise admin. + + See `Remove user from enterprise `__ + for more information. + + Args: + user_id: The user ID. + replacement: If the user is the sole owner of any workspaces, you must + specify a replacement user ID to be added as the new owner of such + workspaces. If the user is not the sole owner of any workspaces, + this is optional and will be ignored if provided. + """ + url = f"{self.url}/users/{user_id}/remove" + payload: Dict[str, Any] = {"isDryRun": False} + if replacement: + payload["replacementOwnerId"] = replacement + response = self.api.post(url, json=payload) + return UserRemoved.from_api(response, self.api, context=self) + + +class UserRemoved(AirtableModel): + was_user_removed_as_admin: bool + shared: "UserRemoved.Shared" + unshared: "UserRemoved.Unshared" + + class Shared(AirtableModel): + workspaces: List["UserRemoved.Shared.Workspace"] + + class Workspace(AirtableModel): + permission_level: str + workspace_id: str + workspace_name: str + user_id: str = "" + + class Unshared(AirtableModel): + bases: List["UserRemoved.Unshared.Base"] + interfaces: List["UserRemoved.Unshared.Interface"] + workspaces: List["UserRemoved.Unshared.Workspace"] + + class Base(AirtableModel): + user_id: str + base_id: str + base_name: str + former_permission_level: str + + class Interface(AirtableModel): + user_id: str + base_id: str + interface_id: str + interface_name: str + former_permission_level: str + + class Workspace(AirtableModel): + user_id: str + former_permission_level: str + workspace_id: str + workspace_name: str + + +class ClaimUsersResponse(AirtableModel): + errors: List["ClaimUsersResponse.Error"] + + class Error(AirtableModel): + id: Optional[str] = None + email: Optional[str] = None + type: str + message: str + + +update_forward_refs(vars()) + # These are at the bottom of the module to avoid circular imports import pyairtable.api.api # noqa diff --git a/tests/conftest.py b/tests/conftest.py index d5179693..06119da0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from collections import OrderedDict from pathlib import Path from posixpath import join as urljoin -from typing import Callable +from typing import Any, Callable from urllib.parse import quote, urlencode import pytest @@ -151,3 +151,30 @@ def _get_sample_json(name): return json.load(fp) return _get_sample_json + + +@pytest.fixture +def schema_obj(api, sample_json): + """ + Test fixture that provides a callable function which retrieves + an object generated from tests/sample_data, and optionally + retrieves an attribute of that object. + """ + + def _get_schema_obj(name: str, *, context: Any = None) -> Any: + from pyairtable.models import schema + + obj_name, _, obj_path = name.partition(".") + obj_data = sample_json(obj_name) + obj_cls = getattr(schema, obj_name) + + if context: + obj = obj_cls.from_api(obj_data, api, context=context) + else: + obj = obj_cls.parse_obj(obj_data) + + if obj_path: + obj = eval(f"obj.{obj_path}", None, {"obj": obj}) + return obj + + return _get_schema_obj diff --git a/tests/sample_data/UserRemoved.json b/tests/sample_data/UserRemoved.json new file mode 100644 index 00000000..006bea41 --- /dev/null +++ b/tests/sample_data/UserRemoved.json @@ -0,0 +1,40 @@ +{ + "shared": { + "workspaces": [ + { + "permissionLevel": "owner", + "userId": "usrL2PNC5o3H4lBEi", + "workspaceId": "wsp00000000000000", + "workspaceName": "Workspace name" + } + ] + }, + "unshared": { + "bases": [ + { + "baseId": "app00000000000000", + "baseName": "Base name", + "formerPermissionLevel": "create", + "userId": "usr00000000000000" + } + ], + "interfaces": [ + { + "baseId": "app00000000000000", + "formerPermissionLevel": "create", + "interfaceId": "pgb00000000000000", + "interfaceName": "Interface name", + "userId": "usr00000000000000" + } + ], + "workspaces": [ + { + "formerPermissionLevel": "owner", + "userId": "usr00000000000000", + "workspaceId": "wsp00000000000000", + "workspaceName": "Workspace name" + } + ] + }, + "wasUserRemovedAsAdmin": true +} diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 61ea163c..33fb6359 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -47,6 +47,11 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): for n in range(N_AUDIT_PAGES) ], ) + m.remove_user = requests_mock.post( + enterprise.url + f"/users/{m.user_id}/remove", + json=sample_json("UserRemoved"), + ) + return m @@ -210,3 +215,23 @@ def test_audit_log__sortorder( request = enterprise_mocks.get_audit_log.last_request assert request.qs["sortorder"] == [sortorder] assert m.mock_calls[-1].kwargs["offset_field"] == offset_field + + +@pytest.mark.parametrize( + "kwargs,expected", + [ + ( + {}, + {"isDryRun": False}, + ), + ( + {"replacement": "otherUser"}, + {"isDryRun": False, "replacementOwnerId": "otherUser"}, + ), + ], +) +def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): + removed = enterprise.remove_user(enterprise_mocks.user_id, **kwargs) + assert enterprise_mocks.remove_user.call_count == 1 + assert enterprise_mocks.remove_user.last_request.json() == expected + assert removed.shared.workspaces[0].user_id == "usrL2PNC5o3H4lBEi" diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index dfbd26ef..ad9a23c3 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -1,5 +1,5 @@ from operator import attrgetter -from typing import Any, List, Optional +from typing import List, Optional import mock import pytest @@ -9,31 +9,6 @@ from pyairtable.testing import fake_id -@pytest.fixture -def schema_obj(api, sample_json): - """ - Test fixture that provides a callable function which retrieves - an object generated from tests/sample_data, and optionally - retrieves an attribute of that object. - """ - - def _get_schema_obj(name: str, *, context: Any = None) -> Any: - obj_name, _, obj_path = name.partition(".") - obj_data = sample_json(obj_name) - obj_cls = getattr(schema, obj_name) - - if context: - obj = obj_cls.from_api(obj_data, api, context=context) - else: - obj = obj_cls.parse_obj(obj_data) - - if obj_path: - obj = eval(f"obj.{obj_path}", None, {"obj": obj}) - return obj - - return _get_schema_obj - - @pytest.fixture def mock_base_metadata(base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") From f50d937cf8acf184b21e625efcea70d46dfaff44 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 10 Feb 2024 22:33:11 -0800 Subject: [PATCH 070/272] Manage and delete user --- docs/source/enterprise.rst | 12 ++++++++++++ pyairtable/models/schema.py | 7 ++++++- tests/test_api_enterprise.py | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index 20dbae0f..b5643db4 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -140,3 +140,15 @@ Managing users `Remove user from enterprise `__ >>> enterprise.remove_user("usrUserId", replacement="usrOtherUserId") + +`Manage user `__ + + >>> u = enterprise.user("usrUserId") + >>> u.state = "deactivated" + >>> u.email = u.email.replace("@", "+deactivated@") + >>> u.save() + +`Delete user by id `__ + + >>> u = enterprise.user("usrUserId") + >>> u.delete() diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 6256f2e1..b2a406c8 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -541,7 +541,12 @@ class WorkspaceCollaboration(AirtableModel): permission_level: str -class UserInfo(AirtableModel): +class UserInfo( + CanUpdateModel, + CanDeleteModel, + url="{enterprise.url}/users/{self.id}", + writable=["state", "email", "first_name", "last_name"], +): """ Detailed information about a user. diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 33fb6359..21a2ff21 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -26,6 +26,9 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): m.user_id = m.json_user["id"] m.group_id = m.json_group["id"] m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo")) + m.get_user = requests_mock.get( + f"{enterprise.url}/users/{m.user_id}", json=m.json_user + ) m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.json_users) m.get_group = requests_mock.get( enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"), @@ -235,3 +238,18 @@ def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): assert enterprise_mocks.remove_user.call_count == 1 assert enterprise_mocks.remove_user.last_request.json() == expected assert removed.shared.workspaces[0].user_id == "usrL2PNC5o3H4lBEi" + + +def test_delete_user(enterprise, enterprise_mocks, requests_mock): + user_info = enterprise.user(enterprise_mocks.user_id) + m = requests_mock.delete(f"{enterprise.url}/users/{user_info.id}") + user_info.delete() + assert m.call_count == 1 + + +def test_manage_user(enterprise, enterprise_mocks, requests_mock): + user_info = enterprise.user(enterprise_mocks.user_id) + m = requests_mock.patch(f"{enterprise.url}/users/{user_info.id}") + user_info.save() + assert m.call_count == 1 + assert m.last_request.json() == {"email": "foo@bar.com", "state": "provisioned"} From 7593dd134b4bed7400632d2207323a66596f2be2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 10 Feb 2024 22:38:54 -0800 Subject: [PATCH 071/272] Logout user --- docs/source/enterprise.rst | 15 +++++++++------ pyairtable/models/schema.py | 3 +++ tests/test_api_enterprise.py | 22 +++++++++++++++++----- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index b5643db4..12fa36b6 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -143,12 +143,15 @@ Managing users `Manage user `__ - >>> u = enterprise.user("usrUserId") - >>> u.state = "deactivated" - >>> u.email = u.email.replace("@", "+deactivated@") - >>> u.save() + >>> user = enterprise.user("usrUserId") + >>> user.state = "deactivated" + >>> user.email = u.email.replace("@", "+deactivated@") + >>> user.save() + +`Logout user `__ + + >>> user.logout() `Delete user by id `__ - >>> u = enterprise.user("usrUserId") - >>> u.delete() + >>> user.delete() diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index b2a406c8..0de7056b 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -567,6 +567,9 @@ class UserInfo( groups: List[NestedId] = _FL() collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) + def logout(self) -> None: + self._api.post(self._url + "/logout") + class UserGroup(AirtableModel): """ diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 21a2ff21..2747b37a 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -240,16 +240,28 @@ def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): assert removed.shared.workspaces[0].user_id == "usrL2PNC5o3H4lBEi" -def test_delete_user(enterprise, enterprise_mocks, requests_mock): +@pytest.fixture +def user_info(enterprise, enterprise_mocks): user_info = enterprise.user(enterprise_mocks.user_id) - m = requests_mock.delete(f"{enterprise.url}/users/{user_info.id}") + assert user_info._url == f"{enterprise.url}/users/{user_info.id}" + return user_info + + +def test_delete_user(user_info, requests_mock): + m = requests_mock.delete(user_info._url) user_info.delete() assert m.call_count == 1 -def test_manage_user(enterprise, enterprise_mocks, requests_mock): - user_info = enterprise.user(enterprise_mocks.user_id) - m = requests_mock.patch(f"{enterprise.url}/users/{user_info.id}") +def test_manage_user(user_info, requests_mock): + m = requests_mock.patch(user_info._url) user_info.save() assert m.call_count == 1 assert m.last_request.json() == {"email": "foo@bar.com", "state": "provisioned"} + + +def test_logout_user(user_info, requests_mock): + m = requests_mock.post(user_info._url + "/logout") + user_info.logout() + assert m.call_count == 1 + assert m.last_request.body is None From 5e8e7072a41ebdefc93ab17645556b3a098c530c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 10 Feb 2024 23:12:33 -0800 Subject: [PATCH 072/272] Manage user membership --- docs/source/changelog.rst | 3 ++- docs/source/enterprise.rst | 12 ++++++++---- pyairtable/api/enterprise.py | 29 ++++++++++++++++++++++++++++- tests/test_api_enterprise.py | 21 ++++++++++++++++++++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 326702ef..bea3bfd4 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,7 +5,8 @@ Changelog 2.3.0 (TBD) ------------------------ -* Added support for :ref:`managing permissions and shares`. +* Added support for :ref:`managing permissions and shares` + and :ref:`managing users`. - `PR #337 `_. * Added :meth:`Enterprise.audit_log ` to iterate page-by-page through `audit log events `__. diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index 12fa36b6..44acd75c 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -137,10 +137,6 @@ schema information, you might consider calling :meth:`~pyairtable.Api.request` d Managing users ------------------- -`Remove user from enterprise `__ - - >>> enterprise.remove_user("usrUserId", replacement="usrOtherUserId") - `Manage user `__ >>> user = enterprise.user("usrUserId") @@ -155,3 +151,11 @@ Managing users `Delete user by id `__ >>> user.delete() + +`Remove user from enterprise `__ + + >>> enterprise.remove_user("usrUserId", replacement="usrOtherUserId") + +`Manage user membership `__ + + >>> enterprise.claim_users({"userId": "managed"}) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 16a9641d..d121fb77 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,5 +1,5 @@ import datetime -from typing import Any, Dict, Iterable, Iterator, List, Optional, Union +from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union from pyairtable.models._base import AirtableModel, update_forward_refs from pyairtable.models.audit import AuditLogResponse @@ -251,6 +251,33 @@ def remove_user( response = self.api.post(url, json=payload) return UserRemoved.from_api(response, self.api, context=self) + def claim_users( + self, users: Dict[str, Literal["managed", "unmanaged"]] + ) -> "ClaimUsersResponse": + """ + Batch manage organizations enterprise account users. This endpoint allows you + to change a user's membership status from being unmanaged to being an + organization member, and vice versa. + + See `Manage user membership `__ + for more information. + + Args: + users: A ``dict`` mapping user IDs or emails to the desired state, + either ``"managed"`` or ``"unmanaged"``. + """ + payload = { + "users": [ + { + ("email" if "@" in key else "id"): key, + "state": value, + } + for (key, value) in users.items() + ] + } + response = self.api.post(f"{self.url}/users/claim", json=payload) + return ClaimUsersResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): was_user_removed_as_admin: bool diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 2747b37a..fe72ec4a 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -54,7 +54,10 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): enterprise.url + f"/users/{m.user_id}/remove", json=sample_json("UserRemoved"), ) - + m.claim_users = requests_mock.post( + enterprise.url + "/users/claim", + json={"errors": []}, + ) return m @@ -265,3 +268,19 @@ def test_logout_user(user_info, requests_mock): user_info.logout() assert m.call_count == 1 assert m.last_request.body is None + + +def test_claim_users(enterprise, enterprise_mocks): + enterprise.claim_users( + { + "usrFakeUserId": "managed", + "someone@example.com": "unmanaged", + } + ) + assert enterprise_mocks.claim_users.call_count == 1 + assert enterprise_mocks.claim_users.last_request.json() == { + "users": [ + {"id": "usrFakeUserId", "state": "managed"}, + {"email": "someone@example.com", "state": "unmanaged"}, + ] + } From 63fcd48f88cd76bde78fcf24662777b406c91b02 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 10 Feb 2024 23:35:12 -0800 Subject: [PATCH 073/272] Delete users by email --- docs/source/enterprise.rst | 25 +++++++++++------------ pyairtable/api/enterprise.py | 39 ++++++++++++++++++++++++++++++++++++ tests/test_api_enterprise.py | 30 +++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index 44acd75c..a1d5c439 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -101,7 +101,7 @@ schema information, you might consider calling :meth:`~pyairtable.Api.request` d `Delete interface invite `__ - >>> base.collaborators().interfaces["pbdLkNDICXNqxSDhG"].invite_links[0].delete() + >>> base.collaborators().interfaces[pbd].invite_links[0].delete() `Delete workspace invite `__ @@ -110,17 +110,9 @@ schema information, you might consider calling :meth:`~pyairtable.Api.request` d `Manage share `__ - .. code-block:: python - - >>> share = base.shares()[0] - >>> share.disable() - >>> share.enable() - - :meth:`~pyairtable.models.schema.BaseShares.Info.disable` and - :meth:`~pyairtable.models.schema.BaseShares.Info.enable` are shortcuts for: - - >>> share.state = "enabled" - >>> share.save() + >>> share = base.shares()[0] + >>> share.disable() + >>> share.enable() `Delete share `__ @@ -137,11 +129,14 @@ schema information, you might consider calling :meth:`~pyairtable.Api.request` d Managing users ------------------- +You can use pyAirtable to manage an enterprise's users +via the following methods. + `Manage user `__ >>> user = enterprise.user("usrUserId") >>> user.state = "deactivated" - >>> user.email = u.email.replace("@", "+deactivated@") + >>> user.email = user.email.replace("@", "+deactivated@") >>> user.save() `Logout user `__ @@ -159,3 +154,7 @@ Managing users `Manage user membership `__ >>> enterprise.claim_users({"userId": "managed"}) + +`Delete users by email `__ + + >>> enterprise.delete_users(["foo@example.com", "bar@example.com"]) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d121fb77..5eaa7118 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -278,8 +278,23 @@ def claim_users( response = self.api.post(f"{self.url}/users/claim", json=payload) return ClaimUsersResponse.from_api(response, self.api, context=self) + def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse": + """ + Delete multiple users by email. + + Args: + emails: A list or other iterable of email addresses. + """ + response = self.api.delete(f"{self.url}/users", params={"email": list(emails)}) + return DeleteUsersResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): + """ + Returned from the `Remove user from enterprise `__ + endpoint. + """ + was_user_removed_as_admin: bool shared: "UserRemoved.Shared" unshared: "UserRemoved.Unshared" @@ -318,7 +333,31 @@ class Workspace(AirtableModel): workspace_name: str +class DeleteUsersResponse(AirtableModel): + """ + Returned from the `Delete users by email `__ + endpoint. + """ + + deleted_users: List["DeleteUsersResponse.UserInfo"] + errors: List["DeleteUsersResponse.Error"] + + class UserInfo(AirtableModel): + id: str + email: str + + class Error(AirtableModel): + type: str + email: str + message: Optional[str] = None + + class ClaimUsersResponse(AirtableModel): + """ + Returned from the `Manage user membership `__ + endpoint. + """ + errors: List["ClaimUsersResponse.Error"] class Error(AirtableModel): diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index fe72ec4a..c435bb26 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -3,7 +3,11 @@ import pytest -from pyairtable.api.enterprise import Enterprise +from pyairtable.api.enterprise import ( + ClaimUsersResponse, + DeleteUsersResponse, + Enterprise, +) from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.testing import fake_id @@ -271,12 +275,13 @@ def test_logout_user(user_info, requests_mock): def test_claim_users(enterprise, enterprise_mocks): - enterprise.claim_users( + result = enterprise.claim_users( { "usrFakeUserId": "managed", "someone@example.com": "unmanaged", } ) + assert isinstance(result, ClaimUsersResponse) assert enterprise_mocks.claim_users.call_count == 1 assert enterprise_mocks.claim_users.last_request.json() == { "users": [ @@ -284,3 +289,24 @@ def test_claim_users(enterprise, enterprise_mocks): {"email": "someone@example.com", "state": "unmanaged"}, ] } + + +def test_delete_users(enterprise, requests_mock): + response = { + "deletedUsers": [{"email": "foo@bar.com", "id": "usrL2PNC5o3H4lBEi"}], + "errors": [ + { + "email": "bar@bam.com", + "message": "Invalid permissions", + "type": "INVALID_PERMISSIONS", + } + ], + } + emails = [f"foo{n}@bar.com" for n in range(5)] + m = requests_mock.delete(enterprise.url + "/users", json=response) + parsed = enterprise.delete_users(emails) + assert m.call_count == 1 + assert m.last_request.qs == {"email": emails} + assert isinstance(parsed, DeleteUsersResponse) + assert parsed.deleted_users[0].email == "foo@bar.com" + assert parsed.errors[0].type == "INVALID_PERMISSIONS" From d144368e5d839c6a53df94403cdae774b20d3659 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 23 Feb 2024 21:54:51 -0800 Subject: [PATCH 074/272] Release 2.3.0 --- docs/source/changelog.rst | 4 ++-- pyairtable/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index bea3bfd4..1512f1c7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,7 +2,7 @@ Changelog ========= -2.3.0 (TBD) +2.3.0 (2024-02-25) ------------------------ * Added support for :ref:`managing permissions and shares` @@ -20,7 +20,7 @@ Changelog without having to perform expensive network overhead each time. - `PR #336 `_. -2.2.2 (2023-01-28) +2.2.2 (2024-01-28) ------------------------ * Enterprise methods :meth:`~pyairtable.Enterprise.user`, diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 50d52dc1..d7ec61f7 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.2.2" +__version__ = "2.3.0" from .api import Api, Base, Table from .api.enterprise import Enterprise From 0f71c19ce9286e32b65e5c25ca2f59c7e147b09f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 25 Feb 2024 01:06:58 -0800 Subject: [PATCH 075/272] Update PyPI publish workflow --- .github/workflows/test_lint_deploy.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml index 0f747dd7..cc89d891 100644 --- a/.github/workflows/test_lint_deploy.yml +++ b/.github/workflows/test_lint_deploy.yml @@ -42,6 +42,12 @@ jobs: publish: runs-on: ubuntu-latest needs: test + + # See https://docs.pypi.org/trusted-publishers/using-a-publisher/ + environment: release + permissions: + id-token: write + # Only Publish if it's a tagged commit if: >- startsWith(github.ref, 'refs/tags/') @@ -70,9 +76,6 @@ jobs: --wheel --outdir dist/ . + - name: Publish distribution ๐Ÿ“ฆ to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - repository_url: https://upload.pypi.org/legacy/ From fbae8ac5c89b4050ed565ef521fc12c0866060ba Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 28 Feb 2024 12:05:45 -0800 Subject: [PATCH 076/272] Note the accidental breaking change in 2.3 --- docs/source/changelog.rst | 2 ++ docs/source/migrations.rst | 25 +++++++++++++++++++++++++ pyairtable/__init__.py | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1512f1c7..e17b32c2 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,6 +5,8 @@ Changelog 2.3.0 (2024-02-25) ------------------------ +* A breaking API change was accidentally introduced. + Read more in :ref:`Migrating from 2.2 to 2.3`. * Added support for :ref:`managing permissions and shares` and :ref:`managing users`. - `PR #337 `_. diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index c7492060..69495a45 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -6,6 +6,31 @@ Migration Guide ***************** +Migrating from 2.2 to 2.3 +============================ + +A breaking API change was accidentally introduced into the 2.3 minor release +by renaming some nested fields of :class:`~pyairtable.models.schema.BaseCollaborators` +and :class:`~pyairtable.models.schema.WorkspaceCollaborators`. + + * - | ``base.collaborators().invite_links.base_invite_links`` + | has become ``base.collaborators().invite_links.via_base`` + * - | ``base.collaborators().invite_links.workspace_invite_links`` + | has become ``base.collaborators().invite_links.via_workspace`` + * - | ``ws.collaborators().invite_links.base_invite_links`` + | has become ``ws.collaborators().invite_links.via_base`` + * - | ``ws.collaborators().invite_links.workspace_invite_links`` + | has become ``ws.collaborators().invite_links.via_workspace`` + * - | ``ws.collaborators().individual_collaborators.base_collaborators`` + | has become ``ws.collaborators().individual_collaborators.via_base`` + * - | ``ws.collaborators().individual_collaborators.workspace_collaborators`` + | has become ``ws.collaborators().individual_collaborators.via_workspace`` + * - | ``ws.collaborators().group_collaborators.base_collaborators`` + | has become ``ws.collaborators().group_collaborators.via_base`` + * - | ``ws.collaborators().group_collaborators.workspace_collaborators`` + | has become ``ws.collaborators().group_collaborators.via_workspace`` + + Migrating from 1.x to 2.0 ============================ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index d7ec61f7..f4fc3536 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.0" +__version__ = "2.3.0.post1" from .api import Api, Base, Table from .api.enterprise import Enterprise From 7661e682c9885e11b6396f72d9bac3fefb5a7fe3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 4 Mar 2024 09:30:04 -0800 Subject: [PATCH 077/272] Update pre-commit hook versions --- .pre-commit-config.yaml | 8 ++++---- pyairtable/api/table.py | 9 +++------ pyairtable/orm/fields.py | 13 +++++-------- pyairtable/testing.py | 1 + pyairtable/utils.py | 6 ++---- tests/test_api_retrying.py | 1 + tests/test_params.py | 2 +- tests/test_typing.py | 1 + tox.ini | 3 ++- 9 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9e7b5cf..4729eaef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: entry: python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]" files: \.py$ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-json @@ -19,15 +19,15 @@ repos: - id: fix-byte-order-marker - id: trailing-whitespace - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort args: [--profile, black] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 24.2.0 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 7.0.0 hooks: - id: flake8 diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 0145ce5f..e4c39a5c 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -49,8 +49,7 @@ def __init__( timeout: Optional["pyairtable.api.api.TimeoutTuple"] = None, retry_strategy: Optional[Retry] = None, endpoint_url: str = "https://api.airtable.com", - ): - ... + ): ... @overload def __init__( @@ -58,8 +57,7 @@ def __init__( api_key: None, base_id: "pyairtable.api.base.Base", table_name: str, - ): - ... + ): ... @overload def __init__( @@ -67,8 +65,7 @@ def __init__( api_key: None, base_id: "pyairtable.api.base.Base", table_name: TableSchema, - ): - ... + ): ... def __init__( self, diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 7339c4b8..e3b9f499 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -24,6 +24,7 @@ } } """ + import abc import importlib from datetime import date, datetime, timedelta @@ -144,13 +145,11 @@ def _description(self) -> str: # Model.field will call __get__(instance=None, owner=Model) @overload - def __get__(self, instance: None, owner: Type[Any]) -> SelfType: - ... + def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... # obj.field will call __get__(instance=obj, owner=Model) @overload - def __get__(self, instance: "Model", owner: Type[Any]) -> Optional[T_ORM]: - ... + def __get__(self, instance: "Model", owner: Type[Any]) -> Optional[T_ORM]: ... def __get__( self, instance: Optional["Model"], owner: Type[Any] @@ -396,12 +395,10 @@ class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM]]): # have to overload the type annotations for __get__ @overload - def __get__(self, instance: None, owner: Type[Any]) -> SelfType: - ... + 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]) -> List[T_ORM]: ... def __get__( self, instance: Optional["Model"], owner: Type[Any] diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 93f17cb0..69158f08 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -1,6 +1,7 @@ """ Helper functions for writing tests that use the pyairtable library. """ + import datetime import random import string diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 8588ea7a..3f401659 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -192,11 +192,9 @@ def _append_docstring_text(obj: Any, text: str) -> None: class FetchMethod(Protocol, Generic[R]): - def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: - ... # pragma: no cover + def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: ... - def __call__(self_, self: Any, *, force: bool = False) -> R: - ... # pragma: no cover + def __call__(self_, self: Any, *, force: bool = False) -> R: ... def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: diff --git a/tests/test_api_retrying.py b/tests/test_api_retrying.py index d519b9c2..e2122b6b 100644 --- a/tests/test_api_retrying.py +++ b/tests/test_api_retrying.py @@ -3,6 +3,7 @@ Instead we use a real HTTP server running in a separate thread, which we can program to respond with various HTTP status codes. """ + import json import threading import time diff --git a/tests/test_params.py b/tests/test_params.py index 1ca0430d..bdf4e913 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -97,7 +97,7 @@ def test_params_integration(table, mock_records, mock_response_iterator): [ "time_zone", "America/Chicago", - "?timeZone=America%2FChicago" + "?timeZone=America%2FChicago", # '?timeZone=America/Chicago' ], ["return_fields_by_field_id", True, "?returnFieldsByFieldId=1"], diff --git a/tests/test_typing.py b/tests/test_typing.py index 373ac78a..f8f0f2cb 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -1,6 +1,7 @@ """ Tests that pyairtable.api functions/methods return appropriately typed responses. """ + import datetime from typing import TYPE_CHECKING, Iterator, List, Optional, Union diff --git a/tox.ini b/tox.ini index 9d762972..5f14a013 100644 --- a/tox.ini +++ b/tox.ini @@ -54,7 +54,7 @@ markers = filename = *.py count = True # See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html -ignore = E203, E266, E501, W503 +ignore = E203, E266, E501, E704, W503 select = B,C,E,F,W,T4,B9 max-line-length = 88 max-complexity = 15 @@ -76,3 +76,4 @@ omit = exclude_also = @overload if (typing\.)?TYPE_CHECKING: + \)( -> .+)?: \.\.\.$ From f007d3d32d3fc7edaa7c56feb06c6e3cc276c342 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 27 Oct 2023 18:02:22 -0700 Subject: [PATCH 078/272] Rewrite of pyairtable.formulas --- MANIFEST.in | 1 + docs/source/_substitutions.rst | 9 +- docs/source/changelog.rst | 7 +- docs/source/formulas.rst | 95 ++ docs/source/index.rst | 1 + docs/source/migrations.rst | 41 + docs/source/orm.rst | 30 +- docs/source/tables.rst | 27 +- pyairtable/api/table.py | 3 + pyairtable/formulas.py | 1135 ++++++++++++++++++--- pyairtable/formulas.txt | 83 ++ pyairtable/orm/fields.py | 47 +- pyairtable/orm/model.py | 14 +- tests/integration/test_integration_api.py | 26 +- tests/test_api_table.py | 19 + tests/test_formulas.py | 331 ++++-- tests/test_orm_model.py | 33 +- tox.ini | 3 +- 18 files changed, 1613 insertions(+), 292 deletions(-) create mode 100644 docs/source/formulas.rst create mode 100644 pyairtable/formulas.txt diff --git a/MANIFEST.in b/MANIFEST.in index e665cfe9..ecb77817 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include *.rst include tox.ini include LICENSE include README.md +exclude pyairtable/formulas.txt diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index c95a5626..67309dde 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -32,8 +32,7 @@ .. |kwarg_formula| replace:: An Airtable formula. The formula will be evaluated for each record, and if the result is none of ``0``, ``false``, ``""``, ``NaN``, ``[]``, or ``#Error!`` the record will be included in the response. If combined with view, only records in that view which satisfy the - formula will be returned. For example, to only include records where - ``COLUMN_A`` isn't empty, pass in ``formula="{COLUMN_A}"``. + formula will be returned. Read more at :doc:`formulas`. .. |kwarg_typecast| replace:: The Airtable API will perform best-effort automatic data conversion from string values. @@ -46,17 +45,17 @@ .. |kwarg_user_locale| replace:: The user locale that should be used to format dates when using `string` as the `cell_format`. See - https://support.airtable.com/hc/en-us/articles/220340268-Supported-locale-modifiers-for-SET-LOCALE + `Supported SET_LOCALE modifiers `__ for valid values. .. |kwarg_time_zone| replace:: The time zone that should be used to format dates when using `string` as the `cell_format`. See - https://support.airtable.com/hc/en-us/articles/216141558-Supported-timezones-for-SET-TIMEZONE + `Supported SET_TIMEZONE timezones `__ for valid values. .. |kwarg_replace| replace:: If ``True``, record is replaced in its entirety by provided fields; if a field is not included its value will - bet set to null. If False, only provided fields are updated. + bet set to null. If ``False``, only provided fields are updated. .. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the key is the field id. This defaults to `false`, which returns field objects where the key is the field name. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e17b32c2..cb001881 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +3.0 +------------------------ + +* Rewrite of :mod:`pyairtable.formulas` module. + 2.3.0 (2024-02-25) ------------------------ @@ -36,7 +41,7 @@ Changelog :func:`~pyairtable.formulas.GREATER_EQUAL`, and :func:`~pyairtable.formulas.NOT_EQUAL`. - - `PR #323 `_. + - `PR #323 `_ 2.2.1 (2023-11-28) ------------------------ diff --git a/docs/source/formulas.rst b/docs/source/formulas.rst new file mode 100644 index 00000000..a9ad11ad --- /dev/null +++ b/docs/source/formulas.rst @@ -0,0 +1,95 @@ +Building Formulas +================= + +pyAirtable lets you construct formulas at runtime using Python syntax, +and will convert those formula objects into the appropriate strings when +sending them to the Airtable API. + + +Basics +-------------------------- + +In cases where you want to find records with fields matching a computed value, +this library provides the :func:`~pyairtable.formulas.match` function, which +returns a formula that can be passed to methods like :func:`Table.all `: + +.. autofunction:: pyairtable.formulas.match + :noindex: + + +Compound conditions +-------------------------- + +Formulas and conditions can be chained together if you need to create +more complex criteria: + + >>> from datetime import date + >>> from pyairtable.formulas import AND, GTE, Field, match + >>> formula = AND( + ... match("Customer", 'Alice'), + ... GTE(Field("Delivery Date"), date.today()) + ... ) + >>> formula + AND(EQ(Field('Customer'), 'Alice'), + GTE(Field('Delivery Date'), datetime.date(2023, 12, 10))) + >>> str(formula) + "AND({Customer}='Alice', {Delivery Date}>=DATETIME_PARSE('2023-12-10'))" + +pyAirtable has support for the following comparisons: + + .. list-table:: + + * - :class:`pyairtable.formulas.EQ` + - ``lval = rval`` + * - :class:`pyairtable.formulas.NE` + - ``lval != rval`` + * - :class:`pyairtable.formulas.GT` + - ``lval > rval`` + * - :class:`pyairtable.formulas.GTE` + - ``lval >= rval`` + * - :class:`pyairtable.formulas.LT` + - ``lval < rval`` + * - :class:`pyairtable.formulas.LTE` + - ``lval <= rval`` + +These are also implemented as convenience methods on all instances +of :class:`~pyairtable.formulas.Formula`, so that the following are equivalent: + + >>> EQ(Field("Customer"), "Alice") + >>> match({"Customer": "Alice"}) + >>> Field("Customer").eq("Alice") + +pyAirtable exports ``AND``, ``OR``, ``NOT``, and ``XOR`` for chaining conditions. +You can also use Python operators to modify and combine formulas: + + >>> from pyairtable.formulas import match + >>> match({"Customer": "Bob"}) & ~match({"Product": "TEST"}) + AND(EQ(Field('Customer'), 'Bob'), + NOT(EQ(Field('Product'), 'TEST'))) + + .. list-table:: + :header-rows: 1 + + * - Python operator + - `Airtable equivexpressionalent `__ + * - ``lval & rval`` + - ``AND(lval, rval)`` + * - ``lval | rval`` + - ``OR(lval, rval)`` + * - ``~rval`` + - ``NOT(rval)`` + * - ``lval ^ rval`` + - ``XOR(lval, rval)`` + +Calling functions +-------------------------- + +pyAirtable also exports functions that act as placeholders for calling +Airtable formula functions: + + >>> from pyairtable.formulas import Field, GTE, DATETIME_DIFF, TODAY + >>> formula = GTE(DATETIME_DIFF(TODAY(), Field("Purchase Date"), "days"), 7) + >>> str(formula) + "DATETIME_DIFF(TODAY(), {Purchase Date}, 'days')>=7" + +All supported functions are listed in the :mod:`pyairtable.formulas` API reference. diff --git a/docs/source/index.rst b/docs/source/index.rst index ec4ca998..0cb16ec9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -26,6 +26,7 @@ pyAirtable getting-started tables + formulas orm metadata webhooks diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 69495a45..c8e7f3a5 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -6,6 +6,47 @@ Migration Guide ***************** +Migrating from 2.x to 3.0 +============================ + +In this release we've made breaking changes to the :mod:`pyairtable.formulas` module. +In general, most functions and methods in this module will return instances of +:class:`~pyairtable.formulas.Formula`, which can be chained, combined, and eventually +passed to the ``formula=`` keyword argument to methods like :meth:`~pyairtable.Table.all`. + +The full list of breaking changes is below: + +.. list-table:: + :header-rows: 1 + + * - Function + - Changes + * - ``to_airtable_value()`` + - Removed. Use :func:`~pyairtable.formulas.to_formula_str` instead. + * - ``EQUAL()`` + - Removed. Use :class:`~pyairtable.formulas.EQ` instead. + * - ``NOT_EQUAL()`` + - Removed. Use :class:`~pyairtable.formulas.NE` instead. + * - ``LESS()`` + - Removed. Use :class:`~pyairtable.formulas.LT` instead. + * - ``LESS_EQUAL()`` + - Removed. Use :class:`~pyairtable.formulas.LTE` instead. + * - ``GREATER()`` + - Removed. Use :class:`~pyairtable.formulas.GT` instead. + * - ``GREATER_EQUAL()`` + - Removed. Use :class:`~pyairtable.formulas.GTE` instead. + * - ``FIELD()`` + - Removed. Use :class:`~pyairtable.formulas.Field` or :func:`~pyairtable.formulas.field_name`. + * - ``STR_VALUE()`` + - Removed. Use :func:`~pyairtable.formulas.quoted` instead. + * - :func:`~pyairtable.formulas.AND`, :func:`~pyairtable.formulas.OR` + - These no longer return ``str``, and instead return instances of + :class:`~pyairtable.formulas.Comparison`. + * - :func:`~pyairtable.formulas.IF`, :func:`~pyairtable.formulas.FIND`, :func:`~pyairtable.formulas.LOWER` + - These no longer return ``str``, and instead return instances of + :class:`~pyairtable.formulas.FunctionCall`. + + Migrating from 2.2 to 2.3 ============================ diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 86a97ed8..d0e2a8f6 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -28,9 +28,13 @@ The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for api_key = "keyapikey" -Once you have a model, you can create new objects to represent your -Airtable records. Call :meth:`~pyairtable.orm.Model.save` to save the -newly created object to the Airtable API. +Once you have a model, you can query for existing records using the +``first()`` and ``all()`` methods, which take the same arguments as +:meth:`Table.first ` and :meth:`Table.all `. + +You can also create new objects to represent Airtable records you wish +to create and save. Call :meth:`~pyairtable.orm.Model.save` to save the +newly created object back to Airtable. >>> contact = Contact( ... first_name="Mike", @@ -47,7 +51,6 @@ newly created object to the Airtable API. >>> contact.id 'recS6qSLw0OCA6Xul' - You can read and modify attributes, then call :meth:`~pyairtable.orm.Model.save` when you're ready to save your changes to the API. @@ -63,7 +66,7 @@ To refresh a record from the API, use :meth:`~pyairtable.orm.Model.fetch`: >>> contact.is_registered True -Finally, you can use :meth:`~pyairtable.orm.Model.delete` to delete the record: +Use :meth:`~pyairtable.orm.Model.delete` to delete the record: >>> contact.delete() True @@ -77,6 +80,21 @@ create, modify, or delete several records at once: >>> Contact.batch_save(contacts) >>> Contact.batch_delete(contacts) +You can use your model's fields in :doc:`formula expressions `. +ORM models' fields also provide shortcut methods +:meth:`~pyairtable.orm.fields.Field.eq`, +:meth:`~pyairtable.orm.fields.Field.ne`, +:meth:`~pyairtable.orm.fields.Field.gt`, +:meth:`~pyairtable.orm.fields.Field.gte`, +:meth:`~pyairtable.orm.fields.Field.lt`, and +:meth:`~pyairtable.orm.fields.Field.lte`: + + >>> formula = Contact.last_name.eq("Smith") & Contact.is_registered + >>> str(formula) + "AND({Last Name}='Smith', {Registered})" + >>> results = Contact.all(formula=formula) + [...] + Supported Field Types ----------------------------- @@ -176,7 +194,7 @@ read `Field types and cell values `_. +Methods like :meth:`~pyairtable.Table.all` or :meth:`~pyairtable.Table.first` +accept a ``formula=`` keyword argument so you can filter results using an +`Airtable formula `_. -* :func:`~pyairtable.formulas.match` checks field values from a Python ``dict``: +The simplest option is to pass your formula as a string; however, if your use case +is complex and you want to avoid lots of f-strings and escaping, use +:func:`~pyairtable.formulas.match` to check field values from a ``dict``: .. code-block:: python >>> from pyairtable.formulas import match - >>> formula = match({"First Name": "John", "Age": 21}) - >>> formula - "AND({First Name}='John',{Age}=21)" - >>> table.first(formula=formula) + >>> table.first(formula=match({"First Name": "John", "Age": 21})) {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}} -* :func:`~pyairtable.formulas.to_airtable_value` converts a Python value - to an expression that can be included in a formula: - - .. code-block:: python - - >>> from pyairtable.formulas import to_airtable_value - >>> to_airtable_value(1) - 1 - >>> to_airtable_value(datetime.date.today()) - '2023-06-13' - -For more on generating formulas, look over the :mod:`pyairtable.formulas` API reference. +For more on generating formulas, read the :doc:`formulas` documentation. Retries diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 0145ce5f..cb8a3840 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -16,6 +16,7 @@ assert_typed_dict, assert_typed_dicts, ) +from pyairtable.formulas import Formula, to_formula_str from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema from pyairtable.utils import is_table_id @@ -230,6 +231,8 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: time_zone: |kwarg_time_zone| return_fields_by_field_id: |kwarg_return_fields_by_field_id| """ + if isinstance(formula := options.get("formula"), Formula): + options["formula"] = to_formula_str(formula) for page in self.api.iterate_requests( method="get", url=self.url, diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 734a4e4b..aa20a92f 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -1,270 +1,1087 @@ +""" +This module exports building blocks for constructing Airtable formulas, +including function call proxies for all formula functions as of Dec '23. + +See :doc:`formulas` for more information. +""" + +import datetime import re -from datetime import date, datetime -from typing import Any +from decimal import Decimal +from fractions import Fraction +from typing import Any, ClassVar, Iterable, List, Optional, Set, Union + +from typing_extensions import Self as SelfType from pyairtable.api.types import Fields +from pyairtable.utils import date_to_iso_str, datetime_to_iso_str + + +class Formula: + """ + Represents an Airtable formula that can be combined with other formulas + or converted to a string. On its own, this class simply wraps a ``str`` + so that it will be not be modified or escaped as if it were a value. + + >>> Formula("{Column} = 1") + Formula('{Column} = 1') + >>> str(_) + '{Column} = 1' + """ + + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.value!r})" + + def __and__(self, other: "Formula") -> "Formula": + return AND(self, other) + + def __or__(self, other: "Formula") -> "Formula": + return OR(self, other) + + def __xor__(self, other: "Formula") -> "Formula": + return XOR(self, other) -from .utils import date_to_iso_str, datetime_to_iso_str + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Formula): + return False + return other.value == self.value + def __invert__(self) -> "Formula": + return NOT(self) -def match(dict_values: Fields, *, match_any: bool = False) -> str: + def flatten(self) -> "Formula": + return self + + def eq(self, value: Any) -> "Comparison": + """ + Build an :class:`~pyairtable.formulas.EQ` comparison using this field. + """ + return EQ(self, value) + + def ne(self, value: Any) -> "Comparison": + """ + Build an :class:`~pyairtable.formulas.NE` comparison using this field. + """ + return NE(self, value) + + def gt(self, value: Any) -> "Comparison": + """ + Build a :class:`~pyairtable.formulas.GT` comparison using this field. + """ + return GT(self, value) + + def lt(self, value: Any) -> "Comparison": + """ + Build an :class:`~pyairtable.formulas.LT` comparison using this field. + """ + return LT(self, value) + + def gte(self, value: Any) -> "Comparison": + """ + Build a :class:`~pyairtable.formulas.GTE` comparison using this field. + """ + return GTE(self, value) + + def lte(self, value: Any) -> "Comparison": + """ + Build an :class:`~pyairtable.formulas.LTE` comparison using this field. + """ + return LTE(self, value) + + +class Field(Formula): + """ + Represents a field name. """ - Create one or more ``EQUAL()`` expressions for each provided dict value. - If more than one assetions is included, the expressions are - groupped together into using ``AND()`` (all values must match). - If ``match_any=True``, expressions are grouped with ``OR()``, record is return - if any of the values match. + def __str__(self) -> str: + return "{%s}" % escape_quotes(self.value) - This function also handles escaping field names and casting python values - to the appropriate airtable types using :func:`to_airtable_value` on all - provided values to help generate the expected formula syntax. - If you need more advanced matching you can build similar expressions using lower - level forumula primitives. +class Comparison(Formula): + """ + Represents a logical condition that compares two expressions. + """ + operator: ClassVar[str] = "" - Args: - dict_values: dictionary containing column names and values + def __init__(self, lval: Any, rval: Any): + if not self.operator: + raise NotImplementedError( + f"{self.__class__.__name__}.operator is not defined" + ) + self.lval = lval + self.rval = rval - Keyword Args: - match_any (``bool``, default: ``False``): - If ``True``, matches if **any** of the provided values match. - Otherwise, all values must match. + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Comparison): + return False + return (self.lval, self.operator, self.rval) == ( + other.lval, + other.operator, + other.rval, + ) + + def __str__(self) -> str: + lval, rval = (to_formula_str(v) for v in (self.lval, self.rval)) + lval = f"({lval})" if isinstance(self.lval, Comparison) else lval + rval = f"({rval})" if isinstance(self.rval, Comparison) else rval + return f"{lval}{self.operator}{rval}" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.lval!r}, {self.rval!r})" + + +class EQ(Comparison): + """ + Produces an ``lval = rval`` formula. + """ + + operator = "=" + + +class NE(Comparison): + """ + Produces an ``lval != rval`` formula. + """ + + operator = "!=" + + +class GT(Comparison): + """ + Produces an ``lval > rval`` formula. + """ + + operator = ">" + + +class GTE(Comparison): + """ + Produces an ``lval >= rval`` formula. + """ + + operator = ">=" + + +class LT(Comparison): + """ + Produces an ``lval < rval`` formula. + """ + + operator = "<" + + +class LTE(Comparison): + """ + Produces an ``lval <= rval`` formula. + """ + + operator = "<=" + + +COMPARISONS_BY_OPERATOR = {cls.operator: cls for cls in (EQ, NE, GT, GTE, LT, LTE)} + + +class Compound(Formula): + """ + Represents a boolean logical operator (AND, OR, etc.) wrapping around + one or more component formulas. + """ + + operator: str + components: List[Formula] + + def __init__( + self, + operator: str, + components: Iterable[Formula], + ) -> None: + if not isinstance(components, list): + components = list(components) + if len(components) == 0: + raise ValueError("Compound() requires at least one component") + + self.operator = operator + self.components = components + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Compound): + return False + return (self.operator, self.components) == (other.operator, other.components) + + def __str__(self) -> str: + joined_components = ", ".join(str(c) for c in self.components) + return f"{self.operator}({joined_components})" + + def __repr__(self) -> str: + return f"{self.operator}({repr(self.components)[1:-1]})" + + def flatten(self, /, memo: Optional[Set[int]] = None) -> "Compound": + """ + Reduces the depth of nested AND, OR, and NOT statements. + """ + memo = memo if memo else set() + memo.add(id(self)) + flattened: List[Formula] = [] + for item in self.components: + if id(item) in memo: + raise CircularDependency(item) + if isinstance(item, Compound) and item.operator == self.operator: + flattened.extend(item.flatten(memo=memo).components) + else: + flattened.append(item.flatten()) + + return Compound(self.operator, flattened) + + @classmethod + def build(cls, operator: str, *components: Any, **fields: Any) -> SelfType: + items = list(components) + if len(items) == 1 and hasattr(first := items[0], "__iter__"): + items = [first] if isinstance(first, str) else list(first) + if fields: + items.extend(EQ(Field(k), v) for (k, v) in fields.items()) + return cls(operator, items) + + +def AND(*components: Union[Formula, Iterable[Formula]], **fields: Any) -> Compound: + """ + Join one or more logical conditions into an AND compound condition. + Keyword arguments will be treated as field names. + + >>> AND(EQ("foo", 1), EQ(Field("bar"), 2), baz=3) + AND(EQ('foo', 1), EQ(Field('bar'), 2), EQ(Field('baz'), 3)) + """ + return Compound.build("AND", *components, **fields) + + +def OR(*components: Union[Formula, Iterable[Formula]], **fields: Any) -> Compound: + """ + Join one or more logical conditions into an OR compound condition. + Keyword arguments will be treated as field names. + + >>> OR(EQ("foo", 1), EQ(Field("bar"), 2), baz=3) + OR(EQ('foo', 1), EQ(Field('bar'), 2), EQ(Field('baz'), 3)) + """ + return Compound.build("OR", *components, **fields) + + +def NOT(component: Optional[Formula] = None, /, **fields: Any) -> Compound: + """ + Wrap one logical condition in a negation compound. + Keyword arguments will be treated as field names. + + Can be called with either a formula or with a single + kewyord argument, but not both. + + >>> NOT(EQ("foo", 1)) + NOT(EQ('foo', 1)) + + >>> NOT(foo=1) + NOT(EQ(Field('foo'), 1)) + + If not called with exactly one condition, will throw an exception: + + >>> NOT(EQ("foo", 1), EQ("bar", 2)) + Traceback (most recent call last): + TypeError: NOT() takes from 0 to 1 positional arguments but 2 were given + + >>> NOT(EQ("foo", 1), bar=2) + Traceback (most recent call last): + ValueError: NOT() requires exactly one condition; got 2 + + >>> NOT(foo=1, bar=2) + Traceback (most recent call last): + ValueError: NOT() requires exactly one condition; got 2 + + >>> NOT() + Traceback (most recent call last): + ValueError: NOT() requires exactly one condition; got 0 + """ + items: List[Formula] = [EQ(Field(k), v) for (k, v) in fields.items()] + if component: + items.append(component) + if (count := len(items)) != 1: + raise ValueError(f"NOT() requires exactly one condition; got {count}") + return Compound.build("NOT", items) + + +class CircularDependency(RecursionError): + """ + A circular dependency was encountered when flattening nested conditions. + """ + + +def match(field_values: Fields, *, match_any: bool = False) -> Optional[Formula]: + r""" + Create one or more equality expressions for each provided value, + treating keys as field names and values as values (not formula expressions). + + If more than one assertion is included, the expressions are + grouped together into using ``AND()`` (all values must match). + If ``match_any=True``, expressions are grouped with ``OR()``. - Usage: >>> match({"First Name": "John", "Age": 21}) - "AND({First Name}='John',{Age}=21)" + AND(EQ(Field('First Name'), 'John'), + EQ(Field('Age'), 21)) + >>> match({"First Name": "John", "Age": 21}, match_any=True) - "OR({First Name}='John',{Age}=21)" - >>> match({"First Name": "John"}) - "{First Name}='John'" - >>> match({"Registered": True}) - "{Registered}=1" - >>> match({"Owner's Name": "Mike"}) - "{Owner\\'s Name}='Mike'" + OR(EQ(Field('First Name'), 'John'), + EQ(Field('Age'), 21)) + + To use comparisons other than equality, use a 2-tuple of ``(operator, value)`` + as the value for a particular field. For example: + + >>> match({"First Name": "John", "Age": (">=", 21)}) + AND(EQ(Field('First Name'), 'John'), + GTE(Field('Age'), 21)) + If you need more advanced matching you can build formula expressions using lower + level primitives. + + Args: + field_values: mapping of column names to values + (or to 2-tuples of the format ``(operator, value)``). + + match_any: + If ``True``, matches if *any* of the provided values match. + Otherwise, all values must match. """ - expressions = [] - for key, value in dict_values.items(): - expression = EQUAL(FIELD(key), to_airtable_value(value)) - expressions.append(expression) + expressions: List[Formula] = [] + + for key, val in field_values.items(): + if isinstance(val, tuple) and len(val) == 2: + cmp, val = COMPARISONS_BY_OPERATOR[val[0]], val[1] + else: + cmp = EQ + expressions.append(cmp(Field(key), val)) if len(expressions) == 0: - return "" - elif len(expressions) == 1: + return None + if len(expressions) == 1: return expressions[0] - else: - if not match_any: - return AND(*expressions) - else: - return OR(*expressions) + if match_any: + return OR(*expressions) + return AND(*expressions) + + +def to_formula_str(value: Any) -> str: + """ + Converts the given value into a string representation that can be used + in an Airtable formula expression. + + >>> to_formula_str(EQ(F.Formula("a"), "b")) + "a='b'" + >>> to_formula_str(True) + 'TRUE()' + >>> to_formula_str(False) + 'FALSE()' + >>> to_formula_str(3) + '3' + >>> to_formula_str(3.5) + '3.5' + >>> to_formula_str(Decimal("3.14159265")) + '3.14159265' + >>> to_formula_str(Fraction("4/19")) + '4/19' + >>> to_formula_str("asdf") + "'asdf'" + >>> to_formula_str("Jane's") + "'Jane\\'s'" + >>> to_formula_str(datetime.date(2023, 12, 1)) + "DATETIME_PARSE('2023-12-01')" + >>> to_formula_str(datetime.datetime(2023, 12, 1, 12, 34, 56)) + "DATETIME_PARSE('2023-12-01T12:34:56.000Z')" + """ + # Runtime import to avoid circular dependency + from pyairtable import orm + + if isinstance(value, Formula): + return str(value) + if isinstance(value, bool): + return "TRUE()" if value else "FALSE()" + if isinstance(value, (int, float, Decimal, Fraction)): + return str(value) + if isinstance(value, str): + return "'{}'".format(escape_quotes(value)) + if isinstance(value, datetime.datetime): + return str(DATETIME_PARSE(datetime_to_iso_str(value))) + if isinstance(value, datetime.date): + return str(DATETIME_PARSE(date_to_iso_str(value))) + if isinstance(value, orm.fields.Field): + return field_name(value.field_name) + raise TypeError(value, type(value)) + + +def quoted(value: str) -> str: + r""" + Wrap string in quotes. This is needed when referencing a string inside a formula. + Quotes are escaped. + + >>> quoted("John") + "'John'" + >>> quoted("Guest's Name") + "'Guest\\'s Name'" + """ + return "'{}'".format(escape_quotes(str(value))) def escape_quotes(value: str) -> str: r""" - Ensures any quotes are escaped. Already escaped quotes are ignored. + Ensure any quotes are escaped. Already escaped quotes are ignored. Args: value: text to be escaped Usage: - >>> escape_quotes("Player's Name") - Player\'s Name - >>> escape_quotes("Player\'s Name") - Player\'s Name + >>> escape_quotes(r"Player's Name") + "Player\\'s Name" + >>> escape_quotes(r"Player\'s Name") + "Player\\'s Name" """ escaped_value = re.sub("(? Any: +def field_name(name: str) -> str: + r""" + Create a reference to a field. Quotes are escaped. + + Args: + name: field name + + Usage: + >>> field_name("First Name") + '{First Name}' + >>> field_name("Guest's Name") + "{Guest\\'s Name}" """ - Cast value to appropriate airtable types and format. - For example, to check ``bool`` values in formulas, you actually to compare - to 0 and 1. + return "{%s}" % escape_quotes(name) - .. list-table:: - :widths: 25 75 - :header-rows: 1 - * - Input - - Output - * - ``bool`` - - ``int`` - * - ``str`` - - ``str``; text is wrapped in `'single quotes'`; existing quotes are escaped. - * - all others - - unchanged +class FunctionCall(Formula): + """ + Represents a function call in an Airtable formula, and converts + all arguments to that function into Airtable formula expressions. - Args: - value: value to be cast. + >>> FunctionCall("WEEKDAY", datetime.date(2024, 1, 1)) + WEEKDAY(datetime.date(2024, 1, 1)) + >>> str(_) + "WEEKDAY(DATETIME_PARSE('2024-01-01'))" + pyAirtable exports shortcuts like :meth:`~pyairtable.formulas.WEEKDAY` + for all formula functions known at time of publishing. """ - if isinstance(value, bool): - return int(value) - elif isinstance(value, (int, float)): - return value - elif isinstance(value, str): - return STR_VALUE(value) - elif isinstance(value, datetime): - return datetime_to_iso_str(value) - elif isinstance(value, date): - return date_to_iso_str(value) - else: - return value + def __init__(self, name: str, *args: List[Any]): + self.name = name + self.args = args + + def __str__(self) -> str: + joined_args = ", ".join(to_formula_str(v) for v in self.args) + return f"{self.name}({joined_args})" + + def __repr__(self) -> str: + joined_args_repr = ", ".join(repr(v) for v in self.args) + return f"{self.name}({joined_args_repr})" -def EQUAL(left: Any, right: Any) -> str: + +# fmt: off +r"""[[[cog]]] + +import re +from pathlib import Path + +definitions = [ + line.strip() + for line in Path(cog.inFile).with_suffix(".txt").read_text().splitlines() + if line.strip() + and not line.startswith("#") +] + +cog.outl("\n") + +for definition in definitions: + name, argspec = definition.rstrip(")").split("(") + if name in ("AND", "OR", "NOT"): + continue + + args = [ + re.sub( + "([a-z])([A-Z])", + lambda m: m[1] + "_" + m[2].lower(), + name.strip() + ) + for name in argspec.split(",") + ] + + required = [arg for arg in args if arg and not arg.startswith("[")] + optional = [arg.strip("[]") for arg in args if arg.startswith("[") and arg.endswith("]")] + signature = [f"{arg}: Any" for arg in required] + params = [*required] + splat = optional.pop().rstrip(".") if optional and optional[-1].endswith("...") else None + + if optional: + signature += [f"{arg}: Optional[Any] = None" for arg in optional] + params += ["*(v for v in [" + ", ".join(optional) + "] if v is not None)"] + + if required or optional: + signature += ["/"] + + if splat: + signature += [f"*{splat}: Any"] + params += [f"*{splat}"] + + joined_signature = ", ".join(signature) + joined_params = (", " + ", ".join(params)) if params else "" + + cog.outl(f"def {name}({joined_signature}) -> FunctionCall: # pragma: no cover") + cog.outl(f" \"\"\"") + cog.outl(f" Produce a formula that calls ``{name}()``") + cog.outl(f" \"\"\"") + cog.outl(f" return FunctionCall({name!r}{joined_params})") + cog.outl("\n") + +[[[out]]]""" + + +def ABS(value: Any, /) -> FunctionCall: # pragma: no cover """ - Create an equality assertion + Produce a formula that calls ``ABS()`` + """ + return FunctionCall('ABS', value) + - >>> EQUAL(2,2) - '2=2' +def AVERAGE(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``AVERAGE()`` """ - return "{}={}".format(left, right) + return FunctionCall('AVERAGE', number1, *numbers) -def FIELD(name: str) -> str: +def BLANK() -> FunctionCall: # pragma: no cover """ - Create a reference to a field. Quotes are escaped. + Produce a formula that calls ``BLANK()`` + """ + return FunctionCall('BLANK') - Args: - name: field name - Usage: - >>> FIELD("First Name") - '{First Name}' - >>> FIELD("Guest's Name") - "{Guest\\'s Name}" +def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover """ - return "{%s}" % escape_quotes(name) + Produce a formula that calls ``CEILING()`` + """ + return FunctionCall('CEILING', value, *(v for v in [significance] if v is not None)) -def STR_VALUE(value: str) -> str: +def CONCATENATE(text1: Any, /, *texts: Any) -> FunctionCall: # pragma: no cover """ - Wrap string in quotes. This is needed when referencing a string inside a formula. - Quotes are escaped. + Produce a formula that calls ``CONCATENATE()`` + """ + return FunctionCall('CONCATENATE', text1, *texts) - >>> STR_VALUE("John") - "'John'" - >>> STR_VALUE("Guest's Name") - "'Guest\\'s Name'" - >>> EQUAL(STR_VALUE("John"), FIELD("First Name")) - "'John'={First Name}" + +def COUNT(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ - return "'{}'".format(escape_quotes(str(value))) + Produce a formula that calls ``COUNT()`` + """ + return FunctionCall('COUNT', number1, *numbers) + + +def COUNTA(text_or_number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``COUNTA()`` + """ + return FunctionCall('COUNTA', text_or_number1, *numbers) -def IF(logical: str, value1: str, value2: str) -> str: +def COUNTALL(text_or_number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ - Create an IF statement + Produce a formula that calls ``COUNTALL()`` + """ + return FunctionCall('COUNTALL', text_or_number1, *numbers) + - >>> IF(1=1, 0, 1) - 'IF(1=1, 0, 1)' +def CREATED_TIME() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``CREATED_TIME()`` """ - return "IF({}, {}, {})".format(logical, value1, value2) + return FunctionCall('CREATED_TIME') -def FIND(what: str, where: str, start_position: int = 0) -> str: +def DATEADD(date: Any, number: Any, units: Any, /) -> FunctionCall: # pragma: no cover """ - Create a FIND statement + Produce a formula that calls ``DATEADD()`` + """ + return FunctionCall('DATEADD', date, number, units) - >>> FIND(STR_VALUE(2021), FIELD('DatetimeCol')) - "FIND('2021', {DatetimeCol})" - Args: - what: String to search for - where: Where to search. Could be a string, or a field reference. - start_position: Index of where to start search. Default is 0. +def DATESTR(date: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``DATESTR()`` + """ + return FunctionCall('DATESTR', date) + +def DATETIME_DIFF(date1: Any, date2: Any, units: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``DATETIME_DIFF()`` """ - if start_position: - return "FIND({}, {}, {})".format(what, where, start_position) - else: - return "FIND({}, {})".format(what, where) + return FunctionCall('DATETIME_DIFF', date1, date2, units) -def AND(*args: str) -> str: +def DATETIME_FORMAT(date: Any, output_format: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover """ - Create an AND Statement + Produce a formula that calls ``DATETIME_FORMAT()`` + """ + return FunctionCall('DATETIME_FORMAT', date, *(v for v in [output_format] if v is not None)) + - >>> AND(1, 2, 3) - 'AND(1, 2, 3)' +def DATETIME_PARSE(date: Any, input_format: Optional[Any] = None, locale: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``DATETIME_PARSE()`` """ - return "AND({})".format(",".join(args)) + return FunctionCall('DATETIME_PARSE', date, *(v for v in [input_format, locale] if v is not None)) -def OR(*args: str) -> str: +def DAY(date: Any, /) -> FunctionCall: # pragma: no cover """ - .. versionadded:: 1.2.0 + Produce a formula that calls ``DAY()`` + """ + return FunctionCall('DAY', date) - Creates an OR Statement - >>> OR(1, 2, 3) - 'OR(1, 2, 3)' +def ENCODE_URL_COMPONENT(component_string: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ENCODE_URL_COMPONENT()`` """ - return "OR({})".format(",".join(args)) + return FunctionCall('ENCODE_URL_COMPONENT', component_string) -def LOWER(value: str) -> str: +def ERROR() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ERROR()`` """ - .. versionadded:: 1.3.0 + return FunctionCall('ERROR') - Creates the LOWER function, making a string lowercase. - Can be used on a string or a field name and will lower all the strings in the field. - >>> LOWER("TestValue") - "LOWER(TestValue)" +def EVEN(value: Any, /) -> FunctionCall: # pragma: no cover """ - return "LOWER({})".format(value) + Produce a formula that calls ``EVEN()`` + """ + return FunctionCall('EVEN', value) -def NOT_EQUAL(left: Any, right: Any) -> str: +def EXP(power: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``EXP()`` """ - Create an inequality assertion + return FunctionCall('EXP', power) + - >>> NOT_EQUAL(2,2) - '2!=2' +def FALSE() -> FunctionCall: # pragma: no cover """ - return "{}!={}".format(left, right) + Produce a formula that calls ``FALSE()`` + """ + return FunctionCall('FALSE') -def LESS_EQUAL(left: Any, right: Any) -> str: +def FIND(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``FIND()`` """ - Create a less than assertion + return FunctionCall('FIND', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) + - >>> LESS_EQUAL(2,2) - '2<=2' +def FLOOR(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover """ - return "{}<={}".format(left, right) + Produce a formula that calls ``FLOOR()`` + """ + return FunctionCall('FLOOR', value, *(v for v in [significance] if v is not None)) -def GREATER_EQUAL(left: Any, right: Any) -> str: +def FROMNOW(date: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``FROMNOW()`` """ - Create a greater than assertion + return FunctionCall('FROMNOW', date) + - >>> GREATER_EQUAL(2,2) - '2>=2' +def HOUR(datetime: Any, /) -> FunctionCall: # pragma: no cover """ - return "{}>={}".format(left, right) + Produce a formula that calls ``HOUR()`` + """ + return FunctionCall('HOUR', datetime) -def LESS(left: Any, right: Any) -> str: +def IF(expression: Any, value1: Any, value2: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``IF()`` """ - Create a less assertion + return FunctionCall('IF', expression, value1, value2) + - >>> LESS(2,2) - '2<2' +def INT(value: Any, /) -> FunctionCall: # pragma: no cover """ - return "{}<{}".format(left, right) + Produce a formula that calls ``INT()`` + """ + return FunctionCall('INT', value) -def GREATER(left: Any, right: Any) -> str: +def ISERROR(expr: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ISERROR()`` """ - Create a greater assertion + return FunctionCall('ISERROR', expr) + - >>> GREATER(2,2) - '2>2' +def IS_AFTER(date1: Any, date2: Any, /) -> FunctionCall: # pragma: no cover """ - return "{}>{}".format(left, right) + Produce a formula that calls ``IS_AFTER()`` + """ + return FunctionCall('IS_AFTER', date1, date2) + + +def IS_BEFORE(date1: Any, date2: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``IS_BEFORE()`` + """ + return FunctionCall('IS_BEFORE', date1, date2) + + +def IS_SAME(date1: Any, date2: Any, unit: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``IS_SAME()`` + """ + return FunctionCall('IS_SAME', date1, date2, unit) + + +def LAST_MODIFIED_TIME(*fields: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``LAST_MODIFIED_TIME()`` + """ + return FunctionCall('LAST_MODIFIED_TIME', *fields) + + +def LEFT(string: Any, how_many: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``LEFT()`` + """ + return FunctionCall('LEFT', string, how_many) + + +def LEN(string: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``LEN()`` + """ + return FunctionCall('LEN', string) + + +def LOG(number: Any, base: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``LOG()`` + """ + return FunctionCall('LOG', number, *(v for v in [base] if v is not None)) + + +def LOWER(string: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``LOWER()`` + """ + return FunctionCall('LOWER', string) + + +def MAX(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MAX()`` + """ + return FunctionCall('MAX', number1, *numbers) + + +def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MID()`` + """ + return FunctionCall('MID', string, where_to_start, count) + + +def MIN(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MIN()`` + """ + return FunctionCall('MIN', number1, *numbers) + + +def MINUTE(datetime: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MINUTE()`` + """ + return FunctionCall('MINUTE', datetime) + + +def MOD(value1: Any, divisor: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MOD()`` + """ + return FunctionCall('MOD', value1, divisor) + + +def MONTH(date: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``MONTH()`` + """ + return FunctionCall('MONTH', date) + + +def NOW() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``NOW()`` + """ + return FunctionCall('NOW') + + +def ODD(value: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ODD()`` + """ + return FunctionCall('ODD', value) + + +def POWER(base: Any, power: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``POWER()`` + """ + return FunctionCall('POWER', base, power) + + +def RECORD_ID() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``RECORD_ID()`` + """ + return FunctionCall('RECORD_ID') + + +def REGEX_EXTRACT(string: Any, regex: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``REGEX_EXTRACT()`` + """ + return FunctionCall('REGEX_EXTRACT', string, regex) + + +def REGEX_MATCH(string: Any, regex: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``REGEX_MATCH()`` + """ + return FunctionCall('REGEX_MATCH', string, regex) + + +def REGEX_REPLACE(string: Any, regex: Any, replacement: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``REGEX_REPLACE()`` + """ + return FunctionCall('REGEX_REPLACE', string, regex, replacement) + + +def REPLACE(string: Any, start_character: Any, number_of_characters: Any, replacement: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``REPLACE()`` + """ + return FunctionCall('REPLACE', string, start_character, number_of_characters, replacement) + + +def REPT(string: Any, number: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``REPT()`` + """ + return FunctionCall('REPT', string, number) + + +def RIGHT(string: Any, how_many: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``RIGHT()`` + """ + return FunctionCall('RIGHT', string, how_many) + + +def ROUND(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ROUND()`` + """ + return FunctionCall('ROUND', value, precision) + + +def ROUNDDOWN(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ROUNDDOWN()`` + """ + return FunctionCall('ROUNDDOWN', value, precision) + + +def ROUNDUP(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``ROUNDUP()`` + """ + return FunctionCall('ROUNDUP', value, precision) + + +def SEARCH(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SEARCH()`` + """ + return FunctionCall('SEARCH', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) + + +def SECOND(datetime: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SECOND()`` + """ + return FunctionCall('SECOND', datetime) + + +def SET_LOCALE(date: Any, locale_modifier: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SET_LOCALE()`` + """ + return FunctionCall('SET_LOCALE', date, locale_modifier) + + +def SET_TIMEZONE(date: Any, tz_identifier: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SET_TIMEZONE()`` + """ + return FunctionCall('SET_TIMEZONE', date, tz_identifier) + + +def SQRT(value: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SQRT()`` + """ + return FunctionCall('SQRT', value) + + +def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SUBSTITUTE()`` + """ + return FunctionCall('SUBSTITUTE', string, old_text, new_text, *(v for v in [index] if v is not None)) + + +def SUM(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SUM()`` + """ + return FunctionCall('SUM', number1, *numbers) + + +def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``SWITCH()`` + """ + return FunctionCall('SWITCH', expression, pattern, result, *pattern_results) + + +def T(value1: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``T()`` + """ + return FunctionCall('T', value1) + + +def TIMESTR(timestamp: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``TIMESTR()`` + """ + return FunctionCall('TIMESTR', timestamp) + + +def TODAY() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``TODAY()`` + """ + return FunctionCall('TODAY') + + +def TONOW(date: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``TONOW()`` + """ + return FunctionCall('TONOW', date) + + +def TRIM(string: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``TRIM()`` + """ + return FunctionCall('TRIM', string) + + +def TRUE() -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``TRUE()`` + """ + return FunctionCall('TRUE') + + +def UPPER(string: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``UPPER()`` + """ + return FunctionCall('UPPER', string) + + +def VALUE(text: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``VALUE()`` + """ + return FunctionCall('VALUE', text) + + +def WEEKDAY(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``WEEKDAY()`` + """ + return FunctionCall('WEEKDAY', date, *(v for v in [start_day_of_week] if v is not None)) + + +def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``WEEKNUM()`` + """ + return FunctionCall('WEEKNUM', date, *(v for v in [start_day_of_week] if v is not None)) + + +def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``WORKDAY_DIFF()`` + """ + return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None)) + + +def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``WORKDAY()`` + """ + return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None)) + + +def XOR(expression1: Any, /, *expressions: Any) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``XOR()`` + """ + return FunctionCall('XOR', expression1, *expressions) + + +def YEAR(date: Any, /) -> FunctionCall: # pragma: no cover + """ + Produce a formula that calls ``YEAR()`` + """ + return FunctionCall('YEAR', date) + + +# [[[end]]] (checksum: 428ee7de15bc4cd4dd46f2d4eb8b4043) +# fmt: on diff --git a/pyairtable/formulas.txt b/pyairtable/formulas.txt new file mode 100644 index 00000000..0ed88168 --- /dev/null +++ b/pyairtable/formulas.txt @@ -0,0 +1,83 @@ +# Retrieved from https://support.airtable.com/docs/formula-field-reference#text-operators-and-functions +# with $('table > tbody > tr > td:first-child > p > code:only-child').toArray().map(x => $(x).text()).filter(x => x.match(/^[A-Z]/) && !x.match(/^ARRAY/)).sort().join("\n") +# and then edited by hand for consistency and correctness. + +ABS(value) +AND(expression, [expressions...]) +AVERAGE(number1, [numbers...]) +BLANK() +CEILING(value, [significance]) +CONCATENATE(text1, [texts...]) +COUNT(number1, [numbers...]) +COUNTA(textOrNumber1, [numbers...]) +COUNTALL(textOrNumber1, [numbers...]) +CREATED_TIME() +DATEADD(date, number, units) +DATESTR(date) +DATETIME_DIFF(date1, date2, units) +DATETIME_FORMAT(date, [output_format]) +DATETIME_PARSE(date, [input_format], [locale]) +DAY(date) +ENCODE_URL_COMPONENT(component_string) +ERROR() +EVEN(value) +EXP(power) +FALSE() +FIND(stringToFind, whereToSearch, [startFromPosition]) +FLOOR(value, [significance]) +FROMNOW(date) +HOUR(datetime) +IF(expression, value1, value2) +INT(value) +ISERROR(expr) +IS_AFTER(date1, date2) +IS_BEFORE(date1, date2) +IS_SAME(date1, date2, unit) +LAST_MODIFIED_TIME([fields...]) +LEFT(string, howMany) +LEN(string) +LOG(number, [base]) +LOWER(string) +MAX(number1, [numbers...]) +MID(string, whereToStart, count) +MIN(number1, [numbers...]) +MINUTE(datetime) +MOD(value1, divisor) +MONTH(date) +NOT(expression) +NOW() +ODD(value) +OR(expression, [expressions...]) +POWER(base, power) +RECORD_ID() +REGEX_EXTRACT(string, regex) +REGEX_MATCH(string, regex) +REGEX_REPLACE(string, regex, replacement) +REPLACE(string, start_character, number_of_characters, replacement) +REPT(string, number) +RIGHT(string, howMany) +ROUND(value, precision) +ROUNDDOWN(value, precision) +ROUNDUP(value, precision) +SEARCH(stringToFind, whereToSearch, [startFromPosition]) +SECOND(datetime) +SET_LOCALE(date, locale_modifier) +SET_TIMEZONE(date, tz_identifier) +SQRT(value) +SUBSTITUTE(string, old_text, new_text, [index]) +SUM(number1, [numbers...]) +SWITCH(expression, pattern, result, [pattern_results...]) +T(value1) +TIMESTR(timestamp) +TODAY() +TONOW(date) +TRIM(string) +TRUE() +UPPER(string) +VALUE(text) +WEEKDAY(date, [startDayOfWeek]) +WEEKNUM(date, [startDayOfWeek]) +WORKDAY_DIFF(startDate, endDate, [holidays]) +WORKDAY(startDate, numDays, [holidays])ย  +XOR(expression1, [expressions...]) +YEAR(date) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 7339c4b8..b7e6b430 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -1,5 +1,5 @@ """ -Field are used to define the Airtable column type for your pyAirtable models. +Fields define how you'll interact with your data when using the :doc:`orm`. Internally these are implemented as `descriptors `_, which allows us to define methods and type annotations for getting and setting attribute values. @@ -213,6 +213,42 @@ def _repr_fields(self) -> List[Tuple[str, Any]]: ("validate_type", self.validate_type), ] + def eq(self, value: Any) -> "formulas.Comparison": + """ + Build an :class:`~pyairtable.formulas.EQ` comparison using this field. + """ + return formulas.EQ(self, value) + + def ne(self, value: Any) -> "formulas.Comparison": + """ + Build an :class:`~pyairtable.formulas.NE` comparison using this field. + """ + return formulas.NE(self, value) + + def gt(self, value: Any) -> "formulas.Comparison": + """ + Build a :class:`~pyairtable.formulas.GT` comparison using this field. + """ + return formulas.GT(self, value) + + def lt(self, value: Any) -> "formulas.Comparison": + """ + Build an :class:`~pyairtable.formulas.LT` comparison using this field. + """ + return formulas.LT(self, value) + + def gte(self, value: Any) -> "formulas.Comparison": + """ + Build a :class:`~pyairtable.formulas.GTE` comparison using this field. + """ + return formulas.GTE(self, value) + + def lte(self, value: Any) -> "formulas.Comparison": + """ + Build an :class:`~pyairtable.formulas.LTE` comparison using this field. + """ + return formulas.LTE(self, value) + #: A generic Field whose internal and API representations are the same type. _BasicField: TypeAlias = Field[T, T] @@ -896,13 +932,14 @@ class UrlField(TextField): names = sorted(classes + constants + extras) cog.outl("\n\n__all__ = [") -for name in names: +for name in ["Field", *names]: cog.outl(f' "{name}",') cog.outl("]") [[[out]]]""" __all__ = [ + "Field", "AITextField", "AttachmentsField", "AutoNumberField", @@ -937,4 +974,8 @@ class UrlField(TextField): "TextField", "UrlField", ] -# [[[end]]] (checksum: 4722c0951e598ac999d3c16ebd3d8c1c) +# [[[end]]] (checksum: 3fa8c12315457baf170f9766fd8c9f8e) + + +# Delayed import to avoid circular dependency +from pyairtable import formulas # noqa diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index f02e5d6c..9255ca28 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -13,7 +13,7 @@ UpdateRecordDict, WritableFields, ) -from pyairtable.formulas import OR, STR_VALUE +from pyairtable.formulas import EQ, OR, RECORD_ID from pyairtable.models import Comment from pyairtable.orm.fields import AnyField, Field @@ -350,14 +350,12 @@ def from_ids( record_ids = list(record_ids) if not fetch: return [cls.from_id(record_id, fetch=False) for record_id in record_ids] - formula = OR( - *[f"RECORD_ID()={STR_VALUE(record_id)}" for record_id in record_ids] - ) - records = [ - cls.from_record(record) for record in cls.get_table().all(formula=formula) - ] - records_by_id = {record.id: record for record in records} + # There's no endpoint to query multiple IDs at once, but we can use a formula. + formula = OR(EQ(RECORD_ID(), record_id) for record_id in record_ids) + record_data = cls.get_table().all(formula=formula) + records = [cls.from_record(record) for record in record_data] # Ensure we return records in the same order, and raise KeyError if any are missing + records_by_id = {record.id: record for record in records} return [records_by_id[record_id] for record_id in record_ids] @classmethod diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 120bb840..5bc2ffa1 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -5,7 +5,7 @@ from pyairtable import Table from pyairtable import formulas as fo -from pyairtable.utils import attachment +from pyairtable.utils import attachment, date_to_iso_str, datetime_to_iso_str pytestmark = [pytest.mark.integration] @@ -223,25 +223,27 @@ def test_batch_upsert(table: Table, cols): def test_integration_formula_datetime(table: Table, cols): - VALUE = datetime.utcnow() - str_value = fo.to_airtable_value(VALUE) - rv_create = table.create({cols.DATETIME: str_value}) - rv_first = table.first(formula=fo.match({cols.DATETIME: str_value})) + now = datetime.utcnow() + formula = fo.match({cols.DATETIME: now}) + rv_create = table.create({cols.DATETIME: datetime_to_iso_str(now)}) + rv_first = table.first(formula=formula) assert rv_first and rv_first["id"] == rv_create["id"] def test_integration_formula_date_filter(table: Table, cols): dt = datetime.utcnow() + dt_str = datetime_to_iso_str(dt) date = dt.date() - date_str = fo.to_airtable_value(date) + date_str = date_to_iso_str(date) created = [] for _ in range(2): - rec = table.create({cols.DATETIME: fo.to_airtable_value(dt)}) + rec = table.create({cols.DATETIME: dt_str}) created.append(rec) - formula = fo.FIND(fo.STR_VALUE(date_str), fo.FIELD(cols.DATETIME)) + formula = fo.FIND(date_str, fo.Field(cols.DATETIME)) rv_all = table.all(formula=formula) + print("repr", repr(formula), "\nstr", str(formula)) assert rv_all assert set([r["id"] for r in rv_all]) == set([r["id"] for r in created]) @@ -265,11 +267,9 @@ def test_integration_formula_composition(table: Table, cols): rv_create = table.create({cols.TEXT: text, cols.NUM: num, cols.BOOL: bool_}) formula = fo.AND( - fo.EQUAL(fo.FIELD(cols.TEXT), fo.to_airtable_value(text)), - fo.EQUAL(fo.FIELD(cols.NUM), fo.to_airtable_value(num)), - fo.EQUAL( - fo.FIELD(cols.BOOL), fo.to_airtable_value(bool_) - ), # not needs to be int() + fo.EQ(fo.Field(cols.TEXT), text), + fo.EQ(fo.Field(cols.NUM), num), + fo.EQ(fo.Field(cols.BOOL), bool_), # not needs to be int() ) rv_first = table.first(formula=formula) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 6fa43c6a..d432676a 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -1,10 +1,12 @@ from posixpath import join as urljoin +from unittest import mock import pytest from requests import Request from requests_mock import Mocker 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.utils import chunked @@ -258,6 +260,23 @@ def test_iterate(table: Table, mock_response_list, mock_records): assert seq_equals(pages[n], response["records"]) +def test_iterate__formula_conversion(table): + """ + Test that .iterate() will convert a Formula to a str. + """ + with mock.patch("pyairtable.Api.iterate_requests") as m: + table.all(formula=AND(EQ(Field("Name"), "Alice"))) + + m.assert_called_once_with( + method="get", + url=table.url, + fallback=mock.ANY, + options={ + "formula": "AND({Name}='Alice')", + }, + ) + + def test_create(table: Table, mock_response_single): with Mocker() as mock: post_data = mock_response_single["fields"] diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 0736da5a..9efae650 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -1,113 +1,302 @@ +import datetime +from decimal import Decimal +from fractions import Fraction + import pytest +from mock import call -from pyairtable.formulas import ( - AND, - EQUAL, - FIELD, - FIND, - GREATER, - GREATER_EQUAL, - IF, - LESS, - LESS_EQUAL, - LOWER, - NOT_EQUAL, - OR, - STR_VALUE, - escape_quotes, - match, -) +from pyairtable import formulas as F +from pyairtable import orm +from pyairtable.formulas import AND, EQ, GT, GTE, LT, LTE, NE, NOT, OR +from pyairtable.testing import fake_meta -def test_equal(): - assert EQUAL("A", "B") == "A=B" +def test_equivalence(): + assert F.Formula("a") == F.Formula("a") + assert F.Formula("a") != F.Formula("b") + assert F.Formula("a") != "b" -def test_field(): - assert FIELD("Name") == "{Name}" - assert FIELD("Guest's Name") == r"{Guest\'s Name}" +def test_operators(): + lft = F.Formula("a") + rgt = F.Formula("b") + assert str(lft) == "a" + assert str(lft & rgt) == "AND(a, b)" + assert str(lft | rgt) == "OR(a, b)" + assert str(~(lft & rgt)) == "NOT(AND(a, b))" + assert repr(lft & rgt) == "AND(Formula('a'), Formula('b'))" + assert repr(lft | rgt) == "OR(Formula('a'), Formula('b'))" + assert repr(~F.Formula("a")) == "NOT(Formula('a'))" + assert lft.flatten() is lft + assert repr(lft ^ rgt) == "XOR(Formula('a'), Formula('b'))" + assert str(lft ^ rgt) == "XOR(a, b)" -def test_and(): - assert AND("A", "B", "C") == "AND(A,B,C)" +@pytest.mark.parametrize( + "cmp,op", + [ + (EQ, "="), + (NE, "!="), + (GT, ">"), + (GTE, ">="), + (LT, "<"), + (LTE, "<="), + ], +) +def test_comparisons(cmp, op): + assert repr(cmp(1, 1)) == f"{cmp.__name__}(1, 1)" + assert str(cmp(1, 1)) == f"1{op}1" + assert str(cmp(F.Formula("Foo"), "Foo")) == f"Foo{op}'Foo'" -def test_or(): - assert OR("A", "B", "C") == "OR(A,B,C)" +@pytest.mark.parametrize( + "target", + [ + F.Formula("X"), # Formula + F.Field("X"), # Field + F.EQ(1, 1), # Comparison + F.TODAY(), # FunctionCall + ], +) +@pytest.mark.parametrize( + "shortcut,cmp", + [ + ("eq", EQ), + ("ne", NE), + ("gt", GT), + ("gte", GTE), + ("lt", LT), + ("lte", LTE), + ], +) +def test_comparison_shortcuts(target, shortcut, cmp): + """ + Test that methods like .eq() are exposed on all subclasses of Formula. + """ + formula = getattr(target, shortcut)("Y") # Field("X").eq("Y") + assert formula == cmp(target, "Y") # EQ(Field("X"), "Y") -def test_if(): - assert IF(1, 0, 1) == "IF(1, 0, 1)" +def test_comparison_equivalence(): + assert EQ(1, 1) == EQ(1, 1) + assert EQ(1, 2) != EQ(2, 1) + assert EQ(1, 1) != NE(1, 1) + assert EQ(1, 1) != F.Formula("1=1") -def test_find(): - rv = FIND(STR_VALUE(2021), FIELD("DatetimeCol")) - assert rv == "FIND('2021', {DatetimeCol})" - rv = FIND(STR_VALUE(2021), FIELD("DatetimeCol"), 2) - assert rv == "FIND('2021', {DatetimeCol}, 2)" +def test_comparison_is_abstract(): + with pytest.raises(NotImplementedError): + F.Comparison("lft", "rgt") -def test_string_value(): - assert STR_VALUE("A") == "'A'" +@pytest.mark.parametrize("op", ("AND", "OR")) +def test_compound(op): + cmp = F.Compound(op, [EQ("foo", 1), EQ("bar", 2)]) + assert repr(cmp) == f"{op}(EQ('foo', 1), EQ('bar', 2))" -def test_combination(): - formula = AND( - EQUAL(FIELD("First Name"), STR_VALUE("A")), - EQUAL(FIELD("Last Name"), STR_VALUE("B")), - EQUAL(FIELD("Age"), STR_VALUE(15)), - ) - assert formula == ("AND({First Name}='A',{Last Name}='B',{Age}='15')") +@pytest.mark.parametrize("op", ("AND", "OR")) +def test_compound_with_iterable(op): + cmp = F.Compound(op, (EQ(f"f{n}", n) for n in range(3))) + assert repr(cmp) == f"{op}(EQ('f0', 0), EQ('f1', 1), EQ('f2', 2))" + +def test_compound_equivalence(): + assert F.Compound("AND", [1]) == F.Compound("AND", [1]) + assert F.Compound("AND", [1]) != F.Compound("AND", [2]) + assert F.Compound("AND", [1]) != F.Compound("OR", [1]) + assert F.Compound("AND", [1]) != [1] + +@pytest.mark.parametrize("cmp", [AND, OR]) @pytest.mark.parametrize( - "dict,kwargs,expected_formula", + "call_args", [ - ({"First Name": "John"}, {"match_any": False}, "{First Name}='John'"), - ({"First Name": "John"}, {"match_any": True}, "{First Name}='John'"), - ({"A": "1", "B": "2"}, {"match_any": False}, "AND({A}='1',{B}='2')"), - ({"A": "1", "B": "2"}, {"match_any": True}, "OR({A}='1',{B}='2')"), - ({}, {"match_any": False}, ""), - ({}, {"match_any": True}, ""), + # mix *components and and **fields + call(EQ("foo", 1), bar=2), + # multiple *components + call(EQ("foo", 1), EQ(F.Field("bar"), 2)), + # one item in *components that is also an iterable + call([EQ("foo", 1), EQ(F.Field("bar"), 2)]), + call((EQ("foo", 1), EQ(F.Field("bar"), 2))), + lambda: call(iter([EQ("foo", 1), EQ(F.Field("bar"), 2)])), + # test that we accept `str` and convert to formulas + call(["'foo'=1", "{bar}=2"]), ], ) -def test_match(dict, kwargs, expected_formula): - rv = match(dict, **kwargs) - assert rv == expected_formula +def test_compound_constructors(cmp, call_args): + if type(call_args) != type(call): + call_args = call_args() + compound = cmp(*call_args.args, **call_args.kwargs) + expected = cmp(EQ("foo", 1), EQ(F.Field("bar"), 2)) + # compare final output expression, since the actual values will not be equal + assert str(compound) == str(expected) + + +@pytest.mark.parametrize("cmp", ["AND", "OR", "NOT"]) +def test_compound_without_parameters(cmp): + with pytest.raises( + ValueError, + match=r"Compound\(\) requires at least one component", + ): + F.Compound(cmp, []) + + +def test_compound_flatten(): + a = EQ("a", "a") + b = EQ("b", "b") + c = EQ("c", "c") + d = EQ("d", "d") + e = EQ("e", "e") + c = (a & b) & (c & (d | e)) + assert repr(c) == repr( + AND( + AND(EQ("a", "a"), EQ("b", "b")), + AND(EQ("c", "c"), OR(EQ("d", "d"), EQ("e", "e"))), + ) + ) + assert repr(c.flatten()) == repr( + AND( + EQ("a", "a"), + EQ("b", "b"), + EQ("c", "c"), + OR(EQ("d", "d"), EQ("e", "e")), + ) + ) + assert repr((~c).flatten()) == repr( + NOT( + AND( + EQ("a", "a"), + EQ("b", "b"), + EQ("c", "c"), + OR(EQ("d", "d"), EQ("e", "e")), + ) + ) + ) + assert str((~c).flatten()) == ( + "NOT(AND('a'='a', 'b'='b', 'c'='c', OR('d'='d', 'e'='e')))" + ) + + +def test_compound_flatten_circular_dependency(): + circular = NOT(F.Formula("x")) + circular.components = [circular] + with pytest.raises(F.CircularDependency): + circular.flatten() @pytest.mark.parametrize( - "text,escaped", + "compound,expected", [ - ("hello", "hello"), - ("player's name", r"player\'s name"), - (r"player\'s name", r"player\'s name"), + (EQ(1, 1).eq(True), "(1=1)=TRUE()"), + (EQ(False, EQ(1, 2)), "FALSE()=(1=2)"), ], ) -def test_escape_quotes(text, escaped): - rv = escape_quotes(text) - assert rv == escaped +def test_compound_with_compound(compound, expected): + assert str(compound) == expected + + +def test_not(): + assert str(NOT(EQ("foo", 1))) == "NOT('foo'=1)" + assert str(NOT(foo=1)) == "NOT({foo}=1)" + + with pytest.raises(TypeError): + NOT(EQ("foo", 1), EQ("bar", 2)) + with pytest.raises(ValueError, match="requires exactly one condition; got 2"): + NOT(EQ("foo", 1), bar=2) -def test_lower(): - assert LOWER("TestValue") == "LOWER(TestValue)" + with pytest.raises(ValueError, match="requires exactly one condition; got 2"): + NOT(foo=1, bar=2) + + with pytest.raises(ValueError, match="requires exactly one condition; got 0"): + NOT() + + +@pytest.mark.parametrize( + "input,expected", + [ + (EQ(F.Formula("a"), "b"), "a='b'"), + (True, "TRUE()"), + (False, "FALSE()"), + (3, "3"), + (3.5, "3.5"), + (Decimal("3.14159265"), "3.14159265"), + (Fraction("4/19"), "4/19"), + ("asdf", "'asdf'"), + ("Jane's", "'Jane\\'s'"), + ([1, 2, 3], TypeError), + ((1, 2, 3), TypeError), + ({1, 2, 3}, TypeError), + ({1: 2, 3: 4}, TypeError), + ( + datetime.date(2023, 12, 1), + "DATETIME_PARSE('2023-12-01')", + ), + ( + datetime.datetime(2023, 12, 1, 12, 34, 56), + "DATETIME_PARSE('2023-12-01T12:34:56.000Z')", + ), + ], +) +def test_to_formula(input, expected): + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + F.to_formula_str(input) + else: + assert F.to_formula_str(input) == expected -def test_greater_equal(): - assert GREATER_EQUAL("A", "B") == "A>=B" +@pytest.mark.parametrize( + "sig,expected", + [ + (call({}), "None"), + (call({"Field": "value"}), "{Field}='value'"), + (call({"A": ("=", 123), "B": ("!=", 123)}), "AND({A}=123, {B}!=123)"), + (call({"A": 123, "B": 123}, match_any=True), "OR({A}=123, {B}=123)"), + (call({"Field": ("<", 123)}), "{Field}<123"), + (call({"Field": ("<=", 123)}), "{Field}<=123"), + (call({"Field": (">", 123)}), "{Field}>123"), + (call({"Field": (">=", 123)}), "{Field}>=123"), + ], +) +def test_match(sig, expected): + assert str(F.match(*sig.args, **sig.kwargs)) == expected -def test_less_equal(): - assert LESS_EQUAL("A", "B") == "A<=B" +def test_function_call(): + fc = F.FunctionCall("IF", 1, True, False) + assert repr(fc) == "IF(1, True, False)" + assert str(fc) == "IF(1, TRUE(), FALSE())" -def test_greater(): - assert GREATER("A", "B") == "A>B" +def test_field_name(): + assert F.field_name("First Name") == "{First Name}" + assert F.field_name("Guest's Name") == "{Guest\\'s Name}" -def test_less(): - assert LESS("A", "B") == "A"), + ("gte", ">="), + ("lt", "<"), + ("lte", "<="), + ], +) +def test_orm_field(methodname, op): + class FakeModel(orm.Model): + Meta = fake_meta() + name = orm.fields.TextField("Name") + age = orm.fields.IntegerField("Age") + + formula = getattr(FakeModel.name, methodname)("Value") + formula &= GTE(FakeModel.age, 21) + assert F.to_formula_str(formula) == f"AND({{Name}}{op}'Value', {{Age}}>=21)" diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 3f069305..a95d5228 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -146,21 +146,30 @@ class Contact(Model): assert contact.name == "Alice" -@mock.patch("pyairtable.Table.all") -def test_from_ids(mock_all): +@mock.patch("pyairtable.Api.iterate_requests") +def test_from_ids(mock_api): fake_records = [fake_record() for _ in range(10)] - mock_all.return_value = fake_records + mock_api.return_value = [{"records": fake_records}] fake_ids = [record["id"] for record in fake_records] contacts = FakeModel.from_ids(fake_ids) - mock_all.assert_called_once() + mock_api.assert_called_once_with( + method="get", + url=FakeModel.get_table().url, + fallback=("post", FakeModel.get_table().url + "/listRecords"), + options={ + "formula": "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in fake_ids) + }, + ) assert len(contacts) == len(fake_records) assert {c.id for c in contacts} == {r["id"] for r in fake_records} + +@mock.patch("pyairtable.Table.all") +def test_from_ids__invalid_id(mock_all): # Should raise KeyError because of the invalid ID - mock_all.reset_mock() with pytest.raises(KeyError): - FakeModel.from_ids(fake_ids + ["recDefinitelyNotValid"]) + FakeModel.from_ids(["recDefinitelyNotValid"]) mock_all.assert_called_once() @@ -173,6 +182,18 @@ def test_from_ids__no_fetch(mock_all): assert set(contact.id for contact in contacts) == set(fake_ids) +@pytest.mark.parametrize("methodname", ("all", "first")) +def test_passthrough(methodname): + """ + Test that .all() and .first() pass through whatever they get. + """ + with mock.patch(f"pyairtable.Table.{methodname}") as mock_endpoint: + method = getattr(FakeModel, methodname) + method(a=1, b=2, c=3) + + mock_endpoint.assert_called_once_with(a=1, b=2, c=3) + + def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide diff --git a/tox.ini b/tox.ini index 9d762972..e64e3842 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,8 @@ passenv = AIRTABLE_ENTERPRISE_ID addopts = -v testpaths = tests -commands = python -m pytest {posargs} +commands = + python -m pytest {posargs} deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg From 90ca3a46770d5255c7abd3b8242bb4eb35161a5e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 25 Jan 2024 23:31:29 -0800 Subject: [PATCH 079/272] Bring formulas.txt in line with https://www.airtable.com/universe/expHF9XTWWwAT299z/ --- pyairtable/formulas.py | 62 ++++++++++++++++++++--------------------- pyairtable/formulas.txt | 29 ++++++++++--------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index aa20a92f..12694bf9 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -558,11 +558,11 @@ def ABS(value: Any, /) -> FunctionCall: # pragma: no cover return FunctionCall('ABS', value) -def AVERAGE(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def AVERAGE(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``AVERAGE()`` """ - return FunctionCall('AVERAGE', number1, *numbers) + return FunctionCall('AVERAGE', number, *numbers) def BLANK() -> FunctionCall: # pragma: no cover @@ -579,32 +579,32 @@ def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: return FunctionCall('CEILING', value, *(v for v in [significance] if v is not None)) -def CONCATENATE(text1: Any, /, *texts: Any) -> FunctionCall: # pragma: no cover +def CONCATENATE(text: Any, /, *texts: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``CONCATENATE()`` """ - return FunctionCall('CONCATENATE', text1, *texts) + return FunctionCall('CONCATENATE', text, *texts) -def COUNT(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def COUNT(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``COUNT()`` """ - return FunctionCall('COUNT', number1, *numbers) + return FunctionCall('COUNT', number, *numbers) -def COUNTA(text_or_number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def COUNTA(value: Any, /, *values: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``COUNTA()`` """ - return FunctionCall('COUNTA', text_or_number1, *numbers) + return FunctionCall('COUNTA', value, *values) -def COUNTALL(text_or_number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def COUNTALL(value: Any, /, *values: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``COUNTALL()`` """ - return FunctionCall('COUNTALL', text_or_number1, *numbers) + return FunctionCall('COUNTALL', value, *values) def CREATED_TIME() -> FunctionCall: # pragma: no cover @@ -719,11 +719,11 @@ def HOUR(datetime: Any, /) -> FunctionCall: # pragma: no cover return FunctionCall('HOUR', datetime) -def IF(expression: Any, value1: Any, value2: Any, /) -> FunctionCall: # pragma: no cover +def IF(expression: Any, if_true: Any, if_false: Any, /) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``IF()`` """ - return FunctionCall('IF', expression, value1, value2) + return FunctionCall('IF', expression, if_true, if_false) def INT(value: Any, /) -> FunctionCall: # pragma: no cover @@ -796,11 +796,11 @@ def LOWER(string: Any, /) -> FunctionCall: # pragma: no cover return FunctionCall('LOWER', string) -def MAX(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def MAX(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``MAX()`` """ - return FunctionCall('MAX', number1, *numbers) + return FunctionCall('MAX', number, *numbers) def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: # pragma: no cover @@ -810,11 +810,11 @@ def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: # pra return FunctionCall('MID', string, where_to_start, count) -def MIN(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def MIN(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``MIN()`` """ - return FunctionCall('MIN', number1, *numbers) + return FunctionCall('MIN', number, *numbers) def MINUTE(datetime: Any, /) -> FunctionCall: # pragma: no cover @@ -824,11 +824,11 @@ def MINUTE(datetime: Any, /) -> FunctionCall: # pragma: no cover return FunctionCall('MINUTE', datetime) -def MOD(value1: Any, divisor: Any, /) -> FunctionCall: # pragma: no cover +def MOD(value: Any, divisor: Any, /) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``MOD()`` """ - return FunctionCall('MOD', value1, divisor) + return FunctionCall('MOD', value, divisor) def MONTH(date: Any, /) -> FunctionCall: # pragma: no cover @@ -971,11 +971,11 @@ def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = return FunctionCall('SUBSTITUTE', string, old_text, new_text, *(v for v in [index] if v is not None)) -def SUM(number1: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def SUM(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``SUM()`` """ - return FunctionCall('SUM', number1, *numbers) + return FunctionCall('SUM', number, *numbers) def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall: # pragma: no cover @@ -985,11 +985,11 @@ def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) return FunctionCall('SWITCH', expression, pattern, result, *pattern_results) -def T(value1: Any, /) -> FunctionCall: # pragma: no cover +def T(value: Any, /) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``T()`` """ - return FunctionCall('T', value1) + return FunctionCall('T', value) def TIMESTR(timestamp: Any, /) -> FunctionCall: # pragma: no cover @@ -1055,25 +1055,25 @@ def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCa return FunctionCall('WEEKNUM', date, *(v for v in [start_day_of_week] if v is not None)) -def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover """ - Produce a formula that calls ``WORKDAY_DIFF()`` + Produce a formula that calls ``WORKDAY()`` """ - return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None)) + return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None)) -def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover """ - Produce a formula that calls ``WORKDAY()`` + Produce a formula that calls ``WORKDAY_DIFF()`` """ - return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None)) + return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None)) -def XOR(expression1: Any, /, *expressions: Any) -> FunctionCall: # pragma: no cover +def XOR(expression: Any, /, *expressions: Any) -> FunctionCall: # pragma: no cover """ Produce a formula that calls ``XOR()`` """ - return FunctionCall('XOR', expression1, *expressions) + return FunctionCall('XOR', expression, *expressions) def YEAR(date: Any, /) -> FunctionCall: # pragma: no cover @@ -1083,5 +1083,5 @@ def YEAR(date: Any, /) -> FunctionCall: # pragma: no cover return FunctionCall('YEAR', date) -# [[[end]]] (checksum: 428ee7de15bc4cd4dd46f2d4eb8b4043) +# [[[end]]] (checksum: 279778aec4334d54d1db5e21a9227b45) # fmt: on diff --git a/pyairtable/formulas.txt b/pyairtable/formulas.txt index 0ed88168..57d6d4e2 100644 --- a/pyairtable/formulas.txt +++ b/pyairtable/formulas.txt @@ -1,16 +1,15 @@ -# Retrieved from https://support.airtable.com/docs/formula-field-reference#text-operators-and-functions -# with $('table > tbody > tr > td:first-child > p > code:only-child').toArray().map(x => $(x).text()).filter(x => x.match(/^[A-Z]/) && !x.match(/^ARRAY/)).sort().join("\n") +# Retrieved from https://www.airtable.com/universe/expHF9XTWWwAT299z # and then edited by hand for consistency and correctness. ABS(value) AND(expression, [expressions...]) -AVERAGE(number1, [numbers...]) +AVERAGE(number, [numbers...]) BLANK() CEILING(value, [significance]) -CONCATENATE(text1, [texts...]) -COUNT(number1, [numbers...]) -COUNTA(textOrNumber1, [numbers...]) -COUNTALL(textOrNumber1, [numbers...]) +CONCATENATE(text, [texts...]) +COUNT(number, [numbers...]) +COUNTA(value, [values...]) +COUNTALL(value, [values...]) CREATED_TIME() DATEADD(date, number, units) DATESTR(date) @@ -27,7 +26,7 @@ FIND(stringToFind, whereToSearch, [startFromPosition]) FLOOR(value, [significance]) FROMNOW(date) HOUR(datetime) -IF(expression, value1, value2) +IF(expression, if_true, if_false) INT(value) ISERROR(expr) IS_AFTER(date1, date2) @@ -38,11 +37,11 @@ LEFT(string, howMany) LEN(string) LOG(number, [base]) LOWER(string) -MAX(number1, [numbers...]) +MAX(number, [numbers...]) MID(string, whereToStart, count) -MIN(number1, [numbers...]) +MIN(number, [numbers...]) MINUTE(datetime) -MOD(value1, divisor) +MOD(value, divisor) MONTH(date) NOT(expression) NOW() @@ -65,9 +64,9 @@ SET_LOCALE(date, locale_modifier) SET_TIMEZONE(date, tz_identifier) SQRT(value) SUBSTITUTE(string, old_text, new_text, [index]) -SUM(number1, [numbers...]) +SUM(number, [numbers...]) SWITCH(expression, pattern, result, [pattern_results...]) -T(value1) +T(value) TIMESTR(timestamp) TODAY() TONOW(date) @@ -77,7 +76,7 @@ UPPER(string) VALUE(text) WEEKDAY(date, [startDayOfWeek]) WEEKNUM(date, [startDayOfWeek]) -WORKDAY_DIFF(startDate, endDate, [holidays]) WORKDAY(startDate, numDays, [holidays])ย  -XOR(expression1, [expressions...]) +WORKDAY_DIFF(startDate, endDate, [holidays]) +XOR(expression, [expressions...]) YEAR(date) From fccd37efe16dd31a6cb0ea6f135e9c12b1f1fc36 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 11 Feb 2024 12:03:24 -0800 Subject: [PATCH 080/272] Test coverage for function call shortcuts --- pyairtable/formulas.py | 319 ++++++++++++++++++++-------------------- pyairtable/formulas.txt | 158 ++++++++++---------- tests/test_formulas.py | 97 ++++++++++++ 3 files changed, 339 insertions(+), 235 deletions(-) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 12694bf9..ce110b59 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -508,7 +508,11 @@ def __repr__(self) -> str: cog.outl("\n") for definition in definitions: - name, argspec = definition.rstrip(")").split("(") + comment = "" + if "#" in definition: + definition, comment = (x.strip() for x in definition.split("#", 1)) + + name, argspec = definition.rstrip(")").split("(", 1) if name in ("AND", "OR", "NOT"): continue @@ -541,9 +545,12 @@ def __repr__(self) -> str: joined_signature = ", ".join(signature) joined_params = (", " + ", ".join(params)) if params else "" - cog.outl(f"def {name}({joined_signature}) -> FunctionCall: # pragma: no cover") + cog.outl(f"def {name}({joined_signature}) -> FunctionCall:") cog.outl(f" \"\"\"") - cog.outl(f" Produce a formula that calls ``{name}()``") + if comment: + cog.outl(f" {comment}") + else: + cog.outl(f" Produce a formula that calls ``{name}()``") cog.outl(f" \"\"\"") cog.outl(f" return FunctionCall({name!r}{joined_params})") cog.outl("\n") @@ -551,537 +558,537 @@ def __repr__(self) -> str: [[[out]]]""" -def ABS(value: Any, /) -> FunctionCall: # pragma: no cover +def ABS(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ABS()`` + Returns the absolute value. """ return FunctionCall('ABS', value) -def AVERAGE(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def AVERAGE(number: Any, /, *numbers: Any) -> FunctionCall: """ - Produce a formula that calls ``AVERAGE()`` + Returns the average of the numbers. """ return FunctionCall('AVERAGE', number, *numbers) -def BLANK() -> FunctionCall: # pragma: no cover +def BLANK() -> FunctionCall: """ - Produce a formula that calls ``BLANK()`` + Returns a blank value. """ return FunctionCall('BLANK') -def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``CEILING()`` + Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed. """ return FunctionCall('CEILING', value, *(v for v in [significance] if v is not None)) -def CONCATENATE(text: Any, /, *texts: Any) -> FunctionCall: # pragma: no cover +def CONCATENATE(text: Any, /, *texts: Any) -> FunctionCall: """ - Produce a formula that calls ``CONCATENATE()`` + Joins together the text arguments into a single text value. """ return FunctionCall('CONCATENATE', text, *texts) -def COUNT(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def COUNT(number: Any, /, *numbers: Any) -> FunctionCall: """ - Produce a formula that calls ``COUNT()`` + Count the number of numeric items. """ return FunctionCall('COUNT', number, *numbers) -def COUNTA(value: Any, /, *values: Any) -> FunctionCall: # pragma: no cover +def COUNTA(value: Any, /, *values: Any) -> FunctionCall: """ - Produce a formula that calls ``COUNTA()`` + Count the number of non-empty values. This function counts both numeric and text values. """ return FunctionCall('COUNTA', value, *values) -def COUNTALL(value: Any, /, *values: Any) -> FunctionCall: # pragma: no cover +def COUNTALL(value: Any, /, *values: Any) -> FunctionCall: """ - Produce a formula that calls ``COUNTALL()`` + Count the number of all elements including text and blanks. """ return FunctionCall('COUNTALL', value, *values) -def CREATED_TIME() -> FunctionCall: # pragma: no cover +def CREATED_TIME() -> FunctionCall: """ - Produce a formula that calls ``CREATED_TIME()`` + Returns the date and time a given record was created. """ return FunctionCall('CREATED_TIME') -def DATEADD(date: Any, number: Any, units: Any, /) -> FunctionCall: # pragma: no cover +def DATEADD(date: Any, number: Any, units: Any, /) -> FunctionCall: """ - Produce a formula that calls ``DATEADD()`` + Adds specified "count" units to a datetime. (See `list of shared unit specifiers `__. For this function we recommend using the full unit specifier for your desired unit.) """ return FunctionCall('DATEADD', date, number, units) -def DATESTR(date: Any, /) -> FunctionCall: # pragma: no cover +def DATESTR(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``DATESTR()`` + Formats a datetime into a string (YYYY-MM-DD). """ return FunctionCall('DATESTR', date) -def DATETIME_DIFF(date1: Any, date2: Any, units: Any, /) -> FunctionCall: # pragma: no cover +def DATETIME_DIFF(date1: Any, date2: Any, units: Any, /) -> FunctionCall: """ - Produce a formula that calls ``DATETIME_DIFF()`` + Returns the difference between datetimes in specified units. The difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative. """ return FunctionCall('DATETIME_DIFF', date1, date2, units) -def DATETIME_FORMAT(date: Any, output_format: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def DATETIME_FORMAT(date: Any, output_format: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``DATETIME_FORMAT()`` + Formats a datetime into a specified string. See an `explanation of how to use this function with date fields `__ or a list of `supported format specifiers `__. """ return FunctionCall('DATETIME_FORMAT', date, *(v for v in [output_format] if v is not None)) -def DATETIME_PARSE(date: Any, input_format: Optional[Any] = None, locale: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def DATETIME_PARSE(date: Any, input_format: Optional[Any] = None, locale: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``DATETIME_PARSE()`` + Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted 'M/D/YYYY h:mm a'. """ return FunctionCall('DATETIME_PARSE', date, *(v for v in [input_format, locale] if v is not None)) -def DAY(date: Any, /) -> FunctionCall: # pragma: no cover +def DAY(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``DAY()`` + Returns the day of the month of a datetime in the form of a number between 1-31. """ return FunctionCall('DAY', date) -def ENCODE_URL_COMPONENT(component_string: Any, /) -> FunctionCall: # pragma: no cover +def ENCODE_URL_COMPONENT(component_string: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ENCODE_URL_COMPONENT()`` + Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: ``-_.~`` """ return FunctionCall('ENCODE_URL_COMPONENT', component_string) -def ERROR() -> FunctionCall: # pragma: no cover +def ERROR() -> FunctionCall: """ - Produce a formula that calls ``ERROR()`` + Returns a generic Error value (``#ERROR!``). """ return FunctionCall('ERROR') -def EVEN(value: Any, /) -> FunctionCall: # pragma: no cover +def EVEN(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``EVEN()`` + Returns the smallest even integer that is greater than or equal to the specified value. """ return FunctionCall('EVEN', value) -def EXP(power: Any, /) -> FunctionCall: # pragma: no cover +def EXP(power: Any, /) -> FunctionCall: """ - Produce a formula that calls ``EXP()`` + Computes **Euler's number** (e) to the specified power. """ return FunctionCall('EXP', power) -def FALSE() -> FunctionCall: # pragma: no cover +def FALSE() -> FunctionCall: """ - Produce a formula that calls ``FALSE()`` + Logical value false. False is represented numerically by a 0. """ return FunctionCall('FALSE') -def FIND(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def FIND(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``FIND()`` + Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0. """ return FunctionCall('FIND', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) -def FLOOR(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def FLOOR(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``FLOOR()`` + Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed. """ return FunctionCall('FLOOR', value, *(v for v in [significance] if v is not None)) -def FROMNOW(date: Any, /) -> FunctionCall: # pragma: no cover +def FROMNOW(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``FROMNOW()`` + Calculates the number of days between the current date and another date. """ return FunctionCall('FROMNOW', date) -def HOUR(datetime: Any, /) -> FunctionCall: # pragma: no cover +def HOUR(datetime: Any, /) -> FunctionCall: """ - Produce a formula that calls ``HOUR()`` + Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm). """ return FunctionCall('HOUR', datetime) -def IF(expression: Any, if_true: Any, if_false: Any, /) -> FunctionCall: # pragma: no cover +def IF(expression: Any, if_true: Any, if_false: Any, /) -> FunctionCall: """ - Produce a formula that calls ``IF()`` + Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make `nested IF statements `__. """ return FunctionCall('IF', expression, if_true, if_false) -def INT(value: Any, /) -> FunctionCall: # pragma: no cover +def INT(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``INT()`` + Returns the greatest integer that is less than or equal to the specified value. """ return FunctionCall('INT', value) -def ISERROR(expr: Any, /) -> FunctionCall: # pragma: no cover +def ISERROR(expr: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ISERROR()`` + Returns true if the expression causes an error. """ return FunctionCall('ISERROR', expr) -def IS_AFTER(date1: Any, date2: Any, /) -> FunctionCall: # pragma: no cover +def IS_AFTER(date1: Any, date2: Any, /) -> FunctionCall: """ - Produce a formula that calls ``IS_AFTER()`` + Determines if [date1] is later than [date2]. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_AFTER', date1, date2) -def IS_BEFORE(date1: Any, date2: Any, /) -> FunctionCall: # pragma: no cover +def IS_BEFORE(date1: Any, date2: Any, /) -> FunctionCall: """ - Produce a formula that calls ``IS_BEFORE()`` + Determines if [date1] is earlier than [date2]. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_BEFORE', date1, date2) -def IS_SAME(date1: Any, date2: Any, unit: Any, /) -> FunctionCall: # pragma: no cover +def IS_SAME(date1: Any, date2: Any, unit: Any, /) -> FunctionCall: """ - Produce a formula that calls ``IS_SAME()`` + Compares two dates up to a unit and determines whether they are identical. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_SAME', date1, date2, unit) -def LAST_MODIFIED_TIME(*fields: Any) -> FunctionCall: # pragma: no cover +def LAST_MODIFIED_TIME(*fields: Any) -> FunctionCall: """ - Produce a formula that calls ``LAST_MODIFIED_TIME()`` + Returns the date and time of the most recent modification made by a user in a non-computed field in the table. """ return FunctionCall('LAST_MODIFIED_TIME', *fields) -def LEFT(string: Any, how_many: Any, /) -> FunctionCall: # pragma: no cover +def LEFT(string: Any, how_many: Any, /) -> FunctionCall: """ - Produce a formula that calls ``LEFT()`` + Extract how many characters from the beginning of the string. """ return FunctionCall('LEFT', string, how_many) -def LEN(string: Any, /) -> FunctionCall: # pragma: no cover +def LEN(string: Any, /) -> FunctionCall: """ - Produce a formula that calls ``LEN()`` + Returns the length of a string. """ return FunctionCall('LEN', string) -def LOG(number: Any, base: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def LOG(number: Any, base: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``LOG()`` + Computes the logarithm of the value in provided base. The base defaults to 10 if not specified. """ return FunctionCall('LOG', number, *(v for v in [base] if v is not None)) -def LOWER(string: Any, /) -> FunctionCall: # pragma: no cover +def LOWER(string: Any, /) -> FunctionCall: """ - Produce a formula that calls ``LOWER()`` + Makes a string lowercase. """ return FunctionCall('LOWER', string) -def MAX(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def MAX(number: Any, /, *numbers: Any) -> FunctionCall: """ - Produce a formula that calls ``MAX()`` + Returns the largest of the given numbers. """ return FunctionCall('MAX', number, *numbers) -def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: # pragma: no cover +def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: """ - Produce a formula that calls ``MID()`` + Extract a substring of count characters starting at whereToStart. """ return FunctionCall('MID', string, where_to_start, count) -def MIN(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def MIN(number: Any, /, *numbers: Any) -> FunctionCall: """ - Produce a formula that calls ``MIN()`` + Returns the smallest of the given numbers. """ return FunctionCall('MIN', number, *numbers) -def MINUTE(datetime: Any, /) -> FunctionCall: # pragma: no cover +def MINUTE(datetime: Any, /) -> FunctionCall: """ - Produce a formula that calls ``MINUTE()`` + Returns the minute of a datetime as an integer between 0 and 59. """ return FunctionCall('MINUTE', datetime) -def MOD(value: Any, divisor: Any, /) -> FunctionCall: # pragma: no cover +def MOD(value: Any, divisor: Any, /) -> FunctionCall: """ - Produce a formula that calls ``MOD()`` + Returns the remainder after dividing the first argument by the second. """ return FunctionCall('MOD', value, divisor) -def MONTH(date: Any, /) -> FunctionCall: # pragma: no cover +def MONTH(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``MONTH()`` + Returns the month of a datetime as a number between 1 (January) and 12 (December). """ return FunctionCall('MONTH', date) -def NOW() -> FunctionCall: # pragma: no cover +def NOW() -> FunctionCall: """ - Produce a formula that calls ``NOW()`` + While similar to the TODAY() function, NOW() returns the current date AND time. """ return FunctionCall('NOW') -def ODD(value: Any, /) -> FunctionCall: # pragma: no cover +def ODD(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ODD()`` + Rounds positive value up the the nearest odd number and negative value down to the nearest odd number. """ return FunctionCall('ODD', value) -def POWER(base: Any, power: Any, /) -> FunctionCall: # pragma: no cover +def POWER(base: Any, power: Any, /) -> FunctionCall: """ - Produce a formula that calls ``POWER()`` + Computes the specified base to the specified power. """ return FunctionCall('POWER', base, power) -def RECORD_ID() -> FunctionCall: # pragma: no cover +def RECORD_ID() -> FunctionCall: """ - Produce a formula that calls ``RECORD_ID()`` + Returns the ID of the current record. """ return FunctionCall('RECORD_ID') -def REGEX_EXTRACT(string: Any, regex: Any, /) -> FunctionCall: # pragma: no cover +def REGEX_EXTRACT(string: Any, regex: Any, /) -> FunctionCall: """ - Produce a formula that calls ``REGEX_EXTRACT()`` + Returns the first substring that matches a regular expression. """ return FunctionCall('REGEX_EXTRACT', string, regex) -def REGEX_MATCH(string: Any, regex: Any, /) -> FunctionCall: # pragma: no cover +def REGEX_MATCH(string: Any, regex: Any, /) -> FunctionCall: """ - Produce a formula that calls ``REGEX_MATCH()`` + Returns whether the input text matches a regular expression. """ return FunctionCall('REGEX_MATCH', string, regex) -def REGEX_REPLACE(string: Any, regex: Any, replacement: Any, /) -> FunctionCall: # pragma: no cover +def REGEX_REPLACE(string: Any, regex: Any, replacement: Any, /) -> FunctionCall: """ - Produce a formula that calls ``REGEX_REPLACE()`` + Substitutes all matching substrings with a replacement string value. """ return FunctionCall('REGEX_REPLACE', string, regex, replacement) -def REPLACE(string: Any, start_character: Any, number_of_characters: Any, replacement: Any, /) -> FunctionCall: # pragma: no cover +def REPLACE(string: Any, start_character: Any, number_of_characters: Any, replacement: Any, /) -> FunctionCall: """ - Produce a formula that calls ``REPLACE()`` + Replaces the number of characters beginning with the start character with the replacement text. """ return FunctionCall('REPLACE', string, start_character, number_of_characters, replacement) -def REPT(string: Any, number: Any, /) -> FunctionCall: # pragma: no cover +def REPT(string: Any, number: Any, /) -> FunctionCall: """ - Produce a formula that calls ``REPT()`` + Repeats string by the specified number of times. """ return FunctionCall('REPT', string, number) -def RIGHT(string: Any, how_many: Any, /) -> FunctionCall: # pragma: no cover +def RIGHT(string: Any, how_many: Any, /) -> FunctionCall: """ - Produce a formula that calls ``RIGHT()`` + Extract howMany characters from the end of the string. """ return FunctionCall('RIGHT', string, how_many) -def ROUND(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover +def ROUND(value: Any, precision: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ROUND()`` + Rounds the value to the number of decimal places given by "precision." (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by `rounding half up toward positive infinity `__.) """ return FunctionCall('ROUND', value, precision) -def ROUNDDOWN(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover +def ROUNDDOWN(value: Any, precision: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ROUNDDOWN()`` + Rounds the value to the number of decimal places given by "precision," always `rounding down `__. """ return FunctionCall('ROUNDDOWN', value, precision) -def ROUNDUP(value: Any, precision: Any, /) -> FunctionCall: # pragma: no cover +def ROUNDUP(value: Any, precision: Any, /) -> FunctionCall: """ - Produce a formula that calls ``ROUNDUP()`` + Rounds the value to the number of decimal places given by "precision," always `rounding up `__. """ return FunctionCall('ROUNDUP', value, precision) -def SEARCH(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def SEARCH(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``SEARCH()`` + Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty. """ return FunctionCall('SEARCH', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) -def SECOND(datetime: Any, /) -> FunctionCall: # pragma: no cover +def SECOND(datetime: Any, /) -> FunctionCall: """ - Produce a formula that calls ``SECOND()`` + Returns the second of a datetime as an integer between 0 and 59. """ return FunctionCall('SECOND', datetime) -def SET_LOCALE(date: Any, locale_modifier: Any, /) -> FunctionCall: # pragma: no cover +def SET_LOCALE(date: Any, locale_modifier: Any, /) -> FunctionCall: """ - Produce a formula that calls ``SET_LOCALE()`` + Sets a specific locale for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported locale modifiers can be found `here `__. """ return FunctionCall('SET_LOCALE', date, locale_modifier) -def SET_TIMEZONE(date: Any, tz_identifier: Any, /) -> FunctionCall: # pragma: no cover +def SET_TIMEZONE(date: Any, tz_identifier: Any, /) -> FunctionCall: """ - Produce a formula that calls ``SET_TIMEZONE()`` + Sets a specific timezone for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported timezone identifiers can be found `here `__. """ return FunctionCall('SET_TIMEZONE', date, tz_identifier) -def SQRT(value: Any, /) -> FunctionCall: # pragma: no cover +def SQRT(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``SQRT()`` + Returns the square root of a nonnegative number. """ return FunctionCall('SQRT', value) -def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``SUBSTITUTE()`` + Replaces occurrences of old_text in string with new_text. """ return FunctionCall('SUBSTITUTE', string, old_text, new_text, *(v for v in [index] if v is not None)) -def SUM(number: Any, /, *numbers: Any) -> FunctionCall: # pragma: no cover +def SUM(number: Any, /, *numbers: Any) -> FunctionCall: """ - Produce a formula that calls ``SUM()`` + Sum together the numbers. Equivalent to number1 + number2 + ... """ return FunctionCall('SUM', number, *numbers) -def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall: # pragma: no cover +def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall: """ - Produce a formula that calls ``SWITCH()`` + Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input doesn't match any of the defined patterns. In many cases, SWITCH() can be used instead `of a nested IF() formula `__. """ return FunctionCall('SWITCH', expression, pattern, result, *pattern_results) -def T(value: Any, /) -> FunctionCall: # pragma: no cover +def T(value: Any, /) -> FunctionCall: """ - Produce a formula that calls ``T()`` + Returns the argument if it is text and blank otherwise. """ return FunctionCall('T', value) -def TIMESTR(timestamp: Any, /) -> FunctionCall: # pragma: no cover +def TIMESTR(timestamp: Any, /) -> FunctionCall: """ - Produce a formula that calls ``TIMESTR()`` + Formats a datetime into a time-only string (HH:mm:ss). """ return FunctionCall('TIMESTR', timestamp) -def TODAY() -> FunctionCall: # pragma: no cover +def TODAY() -> FunctionCall: """ - Produce a formula that calls ``TODAY()`` + While similar to the NOW() function: TODAY() returns the current date (not the current time, if formatted, time will return 12:00am). """ return FunctionCall('TODAY') -def TONOW(date: Any, /) -> FunctionCall: # pragma: no cover +def TONOW(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``TONOW()`` + Calculates the number of days between the current date and another date. """ return FunctionCall('TONOW', date) -def TRIM(string: Any, /) -> FunctionCall: # pragma: no cover +def TRIM(string: Any, /) -> FunctionCall: """ - Produce a formula that calls ``TRIM()`` + Removes whitespace at the beginning and end of string. """ return FunctionCall('TRIM', string) -def TRUE() -> FunctionCall: # pragma: no cover +def TRUE() -> FunctionCall: """ - Produce a formula that calls ``TRUE()`` + Logical value true. The value of true is represented numerically by a 1. """ return FunctionCall('TRUE') -def UPPER(string: Any, /) -> FunctionCall: # pragma: no cover +def UPPER(string: Any, /) -> FunctionCall: """ - Produce a formula that calls ``UPPER()`` + Makes string uppercase. """ return FunctionCall('UPPER', string) -def VALUE(text: Any, /) -> FunctionCall: # pragma: no cover +def VALUE(text: Any, /) -> FunctionCall: """ - Produce a formula that calls ``VALUE()`` + Converts the text string to a number. Some exceptions applyโ€”if the string contains certain mathematical operators(-,%) the result may not return as expected. In these scenarios we recommend using a combination of VALUE and REGEX_REPLACE to remove non-digit values from the string: """ return FunctionCall('VALUE', text) -def WEEKDAY(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WEEKDAY(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``WEEKDAY()`` + Returns the day of the week as an integer between 0 (Sunday) and 6 (Saturday). You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. """ return FunctionCall('WEEKDAY', date, *(v for v in [start_day_of_week] if v is not None)) -def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``WEEKNUM()`` + Returns the week number in a year. You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. """ return FunctionCall('WEEKNUM', date, *(v for v in [start_day_of_week] if v is not None)) -def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``WORKDAY()`` + Returns a date that is numDays working days after startDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. """ return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None)) -def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: # pragma: no cover +def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: """ - Produce a formula that calls ``WORKDAY_DIFF()`` + Counts the number of working days between startDate and endDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. """ return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None)) -def XOR(expression: Any, /, *expressions: Any) -> FunctionCall: # pragma: no cover +def XOR(expression: Any, /, *expressions: Any) -> FunctionCall: """ - Produce a formula that calls ``XOR()`` + Returns true if an **odd** number of arguments are true. """ return FunctionCall('XOR', expression, *expressions) -def YEAR(date: Any, /) -> FunctionCall: # pragma: no cover +def YEAR(date: Any, /) -> FunctionCall: """ - Produce a formula that calls ``YEAR()`` + Returns the four-digit year of a datetime. """ return FunctionCall('YEAR', date) -# [[[end]]] (checksum: 279778aec4334d54d1db5e21a9227b45) +# [[[end]]] (checksum: 6d21fb2dafa8810cefa1caad266e1453) # fmt: on diff --git a/pyairtable/formulas.txt b/pyairtable/formulas.txt index 57d6d4e2..5308c048 100644 --- a/pyairtable/formulas.txt +++ b/pyairtable/formulas.txt @@ -1,82 +1,82 @@ # Retrieved from https://www.airtable.com/universe/expHF9XTWWwAT299z # and then edited by hand for consistency and correctness. -ABS(value) -AND(expression, [expressions...]) -AVERAGE(number, [numbers...]) -BLANK() -CEILING(value, [significance]) -CONCATENATE(text, [texts...]) -COUNT(number, [numbers...]) -COUNTA(value, [values...]) -COUNTALL(value, [values...]) -CREATED_TIME() -DATEADD(date, number, units) -DATESTR(date) -DATETIME_DIFF(date1, date2, units) -DATETIME_FORMAT(date, [output_format]) -DATETIME_PARSE(date, [input_format], [locale]) -DAY(date) -ENCODE_URL_COMPONENT(component_string) -ERROR() -EVEN(value) -EXP(power) -FALSE() -FIND(stringToFind, whereToSearch, [startFromPosition]) -FLOOR(value, [significance]) -FROMNOW(date) -HOUR(datetime) -IF(expression, if_true, if_false) -INT(value) -ISERROR(expr) -IS_AFTER(date1, date2) -IS_BEFORE(date1, date2) -IS_SAME(date1, date2, unit) -LAST_MODIFIED_TIME([fields...]) -LEFT(string, howMany) -LEN(string) -LOG(number, [base]) -LOWER(string) -MAX(number, [numbers...]) -MID(string, whereToStart, count) -MIN(number, [numbers...]) -MINUTE(datetime) -MOD(value, divisor) -MONTH(date) -NOT(expression) -NOW() -ODD(value) -OR(expression, [expressions...]) -POWER(base, power) -RECORD_ID() -REGEX_EXTRACT(string, regex) -REGEX_MATCH(string, regex) -REGEX_REPLACE(string, regex, replacement) -REPLACE(string, start_character, number_of_characters, replacement) -REPT(string, number) -RIGHT(string, howMany) -ROUND(value, precision) -ROUNDDOWN(value, precision) -ROUNDUP(value, precision) -SEARCH(stringToFind, whereToSearch, [startFromPosition]) -SECOND(datetime) -SET_LOCALE(date, locale_modifier) -SET_TIMEZONE(date, tz_identifier) -SQRT(value) -SUBSTITUTE(string, old_text, new_text, [index]) -SUM(number, [numbers...]) -SWITCH(expression, pattern, result, [pattern_results...]) -T(value) -TIMESTR(timestamp) -TODAY() -TONOW(date) -TRIM(string) -TRUE() -UPPER(string) -VALUE(text) -WEEKDAY(date, [startDayOfWeek]) -WEEKNUM(date, [startDayOfWeek]) -WORKDAY(startDate, numDays, [holidays])ย  -WORKDAY_DIFF(startDate, endDate, [holidays]) -XOR(expression, [expressions...]) -YEAR(date) +ABS(value) # Returns the absolute value. +AND(expression, [expressions...]) # Returns true if all the arguments are true, returns false otherwise. +AVERAGE(number, [numbers...]) # Returns the average of the numbers. +BLANK() # Returns a blank value. +CEILING(value, [significance]) # Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed. +CONCATENATE(text, [texts...]) # Joins together the text arguments into a single text value. +COUNT(number, [numbers...]) # Count the number of numeric items. +COUNTA(value, [values...]) # Count the number of non-empty values. This function counts both numeric and text values. +COUNTALL(value, [values...]) # Count the number of all elements including text and blanks. +CREATED_TIME() # Returns the date and time a given record was created. +DATEADD(date, number, units) # Adds specified "count" units to a datetime. (See `list of shared unit specifiers `__. For this function we recommend using the full unit specifier for your desired unit.) +DATESTR(date) # Formats a datetime into a string (YYYY-MM-DD). +DATETIME_DIFF(date1, date2, units) # Returns the difference between datetimes in specified units. The difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative. +DATETIME_FORMAT(date, [output_format]) # Formats a datetime into a specified string. See an `explanation of how to use this function with date fields `__ or a list of `supported format specifiers `__. +DATETIME_PARSE(date, [input_format], [locale]) # Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted 'M/D/YYYY h:mm a'. +DAY(date) # Returns the day of the month of a datetime in the form of a number between 1-31. +ENCODE_URL_COMPONENT(component_string) # Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: ``-_.~`` +ERROR() # Returns a generic Error value (``#ERROR!``). +EVEN(value) # Returns the smallest even integer that is greater than or equal to the specified value. +EXP(power) # Computes **Euler's number** (e) to the specified power. +FALSE() # Logical value false. False is represented numerically by a 0. +FIND(stringToFind, whereToSearch, [startFromPosition]) # Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0. +FLOOR(value, [significance]) # Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed. +FROMNOW(date) # Calculates the number of days between the current date and another date. +HOUR(datetime) # Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm). +IF(expression, if_true, if_false) # Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make `nested IF statements `__. +INT(value) # Returns the greatest integer that is less than or equal to the specified value. +ISERROR(expr) # Returns true if the expression causes an error. +IS_AFTER(date1, date2) # Determines if [date1] is later than [date2]. Returns 1 if yes, 0 if no. +IS_BEFORE(date1, date2) # Determines if [date1] is earlier than [date2]. Returns 1 if yes, 0 if no. +IS_SAME(date1, date2, unit) # Compares two dates up to a unit and determines whether they are identical. Returns 1 if yes, 0 if no. +LAST_MODIFIED_TIME([fields...]) # Returns the date and time of the most recent modification made by a user in a non-computed field in the table. +LEFT(string, howMany) # Extract how many characters from the beginning of the string. +LEN(string) # Returns the length of a string. +LOG(number, [base]) # Computes the logarithm of the value in provided base. The base defaults to 10 if not specified. +LOWER(string) # Makes a string lowercase. +MAX(number, [numbers...]) # Returns the largest of the given numbers. +MID(string, whereToStart, count) # Extract a substring of count characters starting at whereToStart. +MIN(number, [numbers...]) # Returns the smallest of the given numbers. +MINUTE(datetime) # Returns the minute of a datetime as an integer between 0 and 59. +MOD(value, divisor) # Returns the remainder after dividing the first argument by the second. +MONTH(date) # Returns the month of a datetime as a number between 1 (January) and 12 (December). +NOT(expression) # Reverses the logical value of its argument. +NOW() # While similar to the TODAY() function, NOW() returns the current date AND time. +ODD(value) # Rounds positive value up the the nearest odd number and negative value down to the nearest odd number. +OR(expression, [expressions...]) # Returns true if any one of the arguments is true. +POWER(base, power) # Computes the specified base to the specified power. +RECORD_ID() # Returns the ID of the current record. +REGEX_EXTRACT(string, regex) # Returns the first substring that matches a regular expression. +REGEX_MATCH(string, regex) # Returns whether the input text matches a regular expression. +REGEX_REPLACE(string, regex, replacement) # Substitutes all matching substrings with a replacement string value. +REPLACE(string, start_character, number_of_characters, replacement) # Replaces the number of characters beginning with the start character with the replacement text. +REPT(string, number) # Repeats string by the specified number of times. +RIGHT(string, howMany) # Extract howMany characters from the end of the string. +ROUND(value, precision) # Rounds the value to the number of decimal places given by "precision." (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by `rounding half up toward positive infinity `__.) +ROUNDDOWN(value, precision) # Rounds the value to the number of decimal places given by "precision," always `rounding down `__. +ROUNDUP(value, precision) # Rounds the value to the number of decimal places given by "precision," always `rounding up `__. +SEARCH(stringToFind, whereToSearch, [startFromPosition]) # Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty. +SECOND(datetime) # Returns the second of a datetime as an integer between 0 and 59. +SET_LOCALE(date, locale_modifier) # Sets a specific locale for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported locale modifiers can be found `here `__. +SET_TIMEZONE(date, tz_identifier) # Sets a specific timezone for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported timezone identifiers can be found `here `__. +SQRT(value) # Returns the square root of a nonnegative number. +SUBSTITUTE(string, old_text, new_text, [index]) # Replaces occurrences of old_text in string with new_text. +SUM(number, [numbers...]) # Sum together the numbers. Equivalent to number1 + number2 + ... +SWITCH(expression, pattern, result, [pattern_results...]) # Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input doesn't match any of the defined patterns. In many cases, SWITCH() can be used instead `of a nested IF() formula `__. +T(value) # Returns the argument if it is text and blank otherwise. +TIMESTR(timestamp) # Formats a datetime into a time-only string (HH:mm:ss). +TODAY() # While similar to the NOW() function: TODAY() returns the current date (not the current time, if formatted, time will return 12:00am). +TONOW(date) # Calculates the number of days between the current date and another date. +TRIM(string) # Removes whitespace at the beginning and end of string. +TRUE() # Logical value true. The value of true is represented numerically by a 1. +UPPER(string) # Makes string uppercase. +VALUE(text) # Converts the text string to a number. Some exceptions applyโ€”if the string contains certain mathematical operators(-,%) the result may not return as expected. In these scenarios we recommend using a combination of VALUE and REGEX_REPLACE to remove non-digit values from the string: +WEEKDAY(date, [startDayOfWeek]) # Returns the day of the week as an integer between 0 (Sunday) and 6 (Saturday). You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. +WEEKNUM(date, [startDayOfWeek]) # Returns the week number in a year. You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. +WORKDAY(startDate, numDays, [holidays]) # Returns a date that is numDays working days after startDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. +WORKDAY_DIFF(startDate, endDate, [holidays]) # Counts the number of working days between startDate and endDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. +XOR(expression, [expressions...]) # Returns true if an **odd** number of arguments are true. +YEAR(date) # Returns the four-digit year of a datetime. diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 9efae650..b754ef75 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -300,3 +300,100 @@ class FakeModel(orm.Model): formula = getattr(FakeModel.name, methodname)("Value") formula &= GTE(FakeModel.age, 21) assert F.to_formula_str(formula) == f"AND({{Name}}{op}'Value', {{Age}}>=21)" + + +@pytest.mark.parametrize( + "fn,argcount", + [ + ("ABS", 1), + ("AVERAGE", 2), + ("BLANK", 0), + ("CEILING", 2), + ("CONCATENATE", 2), + ("COUNT", 2), + ("COUNTA", 2), + ("COUNTALL", 2), + ("CREATED_TIME", 0), + ("DATEADD", 3), + ("DATESTR", 1), + ("DATETIME_DIFF", 3), + ("DATETIME_FORMAT", 2), + ("DATETIME_PARSE", 3), + ("DAY", 1), + ("ENCODE_URL_COMPONENT", 1), + ("ERROR", 0), + ("EVEN", 1), + ("EXP", 1), + ("FALSE", 0), + ("FIND", 3), + ("FLOOR", 2), + ("FROMNOW", 1), + ("HOUR", 1), + ("IF", 3), + ("INT", 1), + ("ISERROR", 1), + ("IS_AFTER", 2), + ("IS_BEFORE", 2), + ("IS_SAME", 3), + ("LAST_MODIFIED_TIME", 1), + ("LEFT", 2), + ("LEN", 1), + ("LOG", 2), + ("LOWER", 1), + ("MAX", 2), + ("MID", 3), + ("MIN", 2), + ("MINUTE", 1), + ("MOD", 2), + ("MONTH", 1), + ("NOW", 0), + ("ODD", 1), + ("POWER", 2), + ("RECORD_ID", 0), + ("REGEX_EXTRACT", 2), + ("REGEX_MATCH", 2), + ("REGEX_REPLACE", 3), + ("REPLACE", 4), + ("REPT", 2), + ("RIGHT", 2), + ("ROUND", 2), + ("ROUNDDOWN", 2), + ("ROUNDUP", 2), + ("SEARCH", 3), + ("SECOND", 1), + ("SET_LOCALE", 2), + ("SET_TIMEZONE", 2), + ("SQRT", 1), + ("SUBSTITUTE", 4), + ("SUM", 2), + ("SWITCH", 4), + ("T", 1), + ("TIMESTR", 1), + ("TODAY", 0), + ("TONOW", 1), + ("TRIM", 1), + ("TRUE", 0), + ("UPPER", 1), + ("VALUE", 1), + ("WEEKDAY", 2), + ("WEEKNUM", 2), + ("WORKDAY", 3), + ("WORKDAY_DIFF", 3), + ("XOR", 2), + ("YEAR", 1), + ], +) +def test_function_calls(fn, argcount): + """ + Test that the function call shortcuts in the formulas module + all behave as expected with the given number of arguments. + """ + args = tuple(f"arg{n}" for n in range(1, argcount + 1)) + args_repr = ", ".join(repr(arg) for arg in args) + args_formula = ", ".join(F.to_formula_str(arg) for arg in args) + result = getattr(F, fn)(*args) + assert isinstance(result, F.FunctionCall) + assert result.name == fn + assert result.args == args + assert repr(result) == f"{fn}({args_repr})" + assert str(result) == f"{fn}({args_formula})" From efc10c3ae950c8813c5119a48b5d8f9659b9383d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 4 Mar 2024 23:14:50 -0800 Subject: [PATCH 081/272] match() now requires an argument --- docs/source/migrations.rst | 3 +++ pyairtable/formulas.py | 7 ++++--- tests/test_formulas.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index c8e7f3a5..4bedda0c 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -21,6 +21,9 @@ The full list of breaking changes is below: * - Function - Changes + * - :func:`~pyairtable.formulas.match` + - This now raises ``ValueError`` on empty input, + instead of returning ``None``. * - ``to_airtable_value()`` - Removed. Use :func:`~pyairtable.formulas.to_formula_str` instead. * - ``EQUAL()`` diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index ce110b59..1a08c421 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -318,7 +318,7 @@ class CircularDependency(RecursionError): """ -def match(field_values: Fields, *, match_any: bool = False) -> Optional[Formula]: +def match(field_values: Fields, *, match_any: bool = False) -> Formula: r""" Create one or more equality expressions for each provided value, treating keys as field names and values as values (not formula expressions). @@ -348,7 +348,6 @@ def match(field_values: Fields, *, match_any: bool = False) -> Optional[Formula] Args: field_values: mapping of column names to values (or to 2-tuples of the format ``(operator, value)``). - match_any: If ``True``, matches if *any* of the provided values match. Otherwise, all values must match. @@ -363,7 +362,9 @@ def match(field_values: Fields, *, match_any: bool = False) -> Optional[Formula] expressions.append(cmp(Field(key), val)) if len(expressions) == 0: - return None + raise ValueError( + "match() requires at least one field-value pair or keyword argument" + ) if len(expressions) == 1: return expressions[0] if match_any: diff --git a/tests/test_formulas.py b/tests/test_formulas.py index b754ef75..11042d1f 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -250,7 +250,6 @@ def test_to_formula(input, expected): @pytest.mark.parametrize( "sig,expected", [ - (call({}), "None"), (call({"Field": "value"}), "{Field}='value'"), (call({"A": ("=", 123), "B": ("!=", 123)}), "AND({A}=123, {B}!=123)"), (call({"A": 123, "B": 123}, match_any=True), "OR({A}=123, {B}=123)"), @@ -264,6 +263,11 @@ def test_match(sig, expected): assert str(F.match(*sig.args, **sig.kwargs)) == expected +def test_match__exception(): + with pytest.raises(ValueError): + F.match({}) + + def test_function_call(): fc = F.FunctionCall("IF", 1, True, False) assert repr(fc) == "IF(1, True, False)" From 7fc8e66c8e3fe942a3e4931d5786a4aca80a5c5d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 10 Mar 2024 00:27:41 -0800 Subject: [PATCH 082/272] Maintain timezones in DatetimeField --- pyairtable/models/record.py | 0 pyairtable/utils.py | 6 ++- tests/integration/test_integration_api.py | 9 +++-- tests/test_orm_fields.py | 47 ++++++++++++++++++++++- tests/test_utils.py | 25 +++++++----- 5 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 pyairtable/models/record.py diff --git a/pyairtable/models/record.py b/pyairtable/models/record.py new file mode 100644 index 00000000..e69de29b diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 8588ea7a..cee859d9 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -35,7 +35,7 @@ def datetime_to_iso_str(value: datetime) -> str: Args: value: datetime object """ - return value.isoformat(timespec="milliseconds") + "Z" + return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") def datetime_from_iso_str(value: str) -> datetime: @@ -45,7 +45,9 @@ def datetime_from_iso_str(value: str) -> datetime: Args: value: datetime string, e.g. "2014-09-05T07:00:00.000Z" """ - return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ") + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) def date_to_iso_str(value: Union[date, datetime]) -> str: diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 120bb840..e601cf71 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from uuid import uuid4 import pytest @@ -223,10 +223,11 @@ def test_batch_upsert(table: Table, cols): def test_integration_formula_datetime(table: Table, cols): - VALUE = datetime.utcnow() - str_value = fo.to_airtable_value(VALUE) + value = datetime.utcnow().replace(tzinfo=timezone.utc) + str_value = fo.to_airtable_value(value) + formula = fo.match({cols.DATETIME: str_value}) rv_create = table.create({cols.DATETIME: str_value}) - rv_first = table.first(formula=fo.match({cols.DATETIME: str_value})) + rv_first = table.first(formula=formula) assert rv_first and rv_first["id"] == rv_create["id"] diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 4cc32641..fd14d6f4 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -17,7 +17,7 @@ DATE_S = "2023-01-01" DATE_V = datetime.date(2023, 1, 1) DATETIME_S = "2023-04-12T09:30:00.000Z" -DATETIME_V = datetime.datetime(2023, 4, 12, 9, 30, 0) +DATETIME_V = datetime.datetime(2023, 4, 12, 9, 30, 0, tzinfo=datetime.timezone.utc) def test_field(): @@ -634,3 +634,48 @@ class T: with pytest.raises(ValueError): T().rating = 0 + + +def test_datetime_timezones(requests_mock): + """ + Test that DatetimeField handles time zones properly. + """ + + class M(Model): + Meta = fake_meta() + dt = f.DatetimeField("dt") + + obj = M.from_record(fake_record(dt="2024-02-29T12:34:56Z")) + + def patch_callback(request, context): + return { + "id": obj.id, + "createdTime": obj.created_time, + "fields": request.json()["fields"], + } + + m = requests_mock.patch(M.get_table().record_url(obj.id), json=patch_callback) + + # Test that we parse the "Z" into UTC correctly + assert obj.dt.date() == datetime.date(2024, 2, 29) + assert obj.dt.tzinfo is datetime.timezone.utc + obj.save() + assert m.last_request.json()["fields"]["dt"] == "2024-02-29T12:34:56.000Z" + + # Test that we can set a UTC timezone and it will be saved as-is. + obj.dt = datetime.datetime(2024, 3, 1, 11, 22, 33, tzinfo=datetime.timezone.utc) + obj.save() + assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000Z" + + # Test that we can set a local timezone and it will be sent to Airtable. + pacific = datetime.timezone(datetime.timedelta(hours=-8)) + obj.dt = datetime.datetime(2024, 3, 1, 11, 22, 33, tzinfo=pacific) + obj.save() + assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000-08:00" + + # Test that a timezone-unaware datetime is passed as-is to Airtable. + # This behavior will vary depending on how the field is configured. + # See https://airtable.com/developers/web/api/field-model#dateandtime + obj.dt = datetime.datetime(2024, 3, 1, 11, 22, 33) + obj.save() + assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000" diff --git a/tests/test_utils.py b/tests/test_utils.py index 9a3589b3..f1d8f42f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,22 +1,29 @@ -from datetime import date, datetime +from datetime import date, datetime, timezone +from functools import partial import pytest from pyairtable import utils +utc_tz = partial(datetime, tzinfo=timezone.utc) + @pytest.mark.parametrize( - "datetime_obj,datetime_str", + "dt_obj,dt_str", [ - (datetime(2000, 1, 2, 3, 4, 5, 0), "2000-01-02T03:04:05.000Z"), - (datetime(2025, 12, 31, 23, 59, 59, 0), "2025-12-31T23:59:59.000Z"), - (datetime(2025, 12, 31, 23, 59, 59, 5_000), "2025-12-31T23:59:59.005Z"), - (datetime(2025, 12, 31, 23, 59, 59, 555_000), "2025-12-31T23:59:59.555Z"), + (datetime(2000, 1, 2, 3, 4, 5, 0), "2000-01-02T03:04:05.000"), + (datetime(2025, 12, 31, 23, 59, 59, 0), "2025-12-31T23:59:59.000"), + (datetime(2025, 12, 31, 23, 59, 59, 5_000), "2025-12-31T23:59:59.005"), + (datetime(2025, 12, 31, 23, 59, 59, 555_000), "2025-12-31T23:59:59.555"), + (utc_tz(2000, 1, 2, 3, 4, 5, 0), "2000-01-02T03:04:05.000Z"), + (utc_tz(2025, 12, 31, 23, 59, 59, 0), "2025-12-31T23:59:59.000Z"), + (utc_tz(2025, 12, 31, 23, 59, 59, 5_000), "2025-12-31T23:59:59.005Z"), + (utc_tz(2025, 12, 31, 23, 59, 59, 555_000), "2025-12-31T23:59:59.555Z"), ], ) -def test_datetime_utils(datetime_obj, datetime_str): - assert utils.datetime_to_iso_str(datetime_obj) == datetime_str - assert utils.datetime_from_iso_str(datetime_str) == datetime_obj +def test_datetime_utils(dt_obj, dt_str): + assert utils.datetime_to_iso_str(dt_obj) == dt_str + assert utils.datetime_from_iso_str(dt_str) == dt_obj @pytest.mark.parametrize( From 7fc35aef7ee110fb3e55f31c493b2e6aee5c1fa1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 10 Mar 2024 01:01:58 -0800 Subject: [PATCH 083/272] Do not attempt converting None in Model.from_record --- pyairtable/orm/model.py | 2 +- tests/test_orm.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index f02e5d6c..f3e752c4 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -289,7 +289,7 @@ def from_record(cls, record: RecordDict) -> SelfType: field: name_field_map[field].to_internal_value(value) for (field, value) in record["fields"].items() # Silently proceed if Airtable returns fields we don't recognize - if field in name_field_map + if field in name_field_map and value is not None } # Since instance(**field_values) will perform validation and fail on # any readonly fields, instead we directly set instance._fields. diff --git a/tests/test_orm.py b/tests/test_orm.py index 16387e7c..ac90f142 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -114,7 +114,11 @@ def test_from_record(): m_get.return_value = { "id": "recwnBLPIeQJoYVt4", "createdTime": "", - "fields": {"First Name": "X", "Created At": "2014-09-05T12:34:56.000Z"}, + "fields": { + "First Name": "X", + "Birthday": None, + "Created At": "2014-09-05T12:34:56.000Z", + }, } contact = Contact.from_id("recwnBLPIeQJoYVt4") From 592dabb385f02205b6e2bbf33b5c2da70ab65a6c Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Tue, 12 Mar 2024 01:34:59 -0400 Subject: [PATCH 084/272] Update `base.create_table` Turn off validation when creating a new table. See issue #343 --- pyairtable/api/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index f11ab291..65dcf9c7 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -160,7 +160,7 @@ def create_table( if description: payload["description"] = description response = self.api.post(url, json=payload) - return self.table(response["id"], validate=True) + return self.table(response["id"], validate=False) @property def url(self) -> str: From 67635c25c982a1ba2dc12cae21c5b0c6f715cdce Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Tue, 12 Mar 2024 01:56:52 -0400 Subject: [PATCH 085/272] Update `base.create_table` Update `base.create_table` to force refetching the tables as the new table is not included in the cache. #343 --- pyairtable/api/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 65dcf9c7..358b66d7 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -160,7 +160,7 @@ def create_table( if description: payload["description"] = description response = self.api.post(url, json=payload) - return self.table(response["id"], validate=False) + return self.table(response["id"], validate=True, force=True) @property def url(self) -> str: From 3afde50f66ba6e3ba65ed6351b9aa2e49e6e8fcf Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Thu, 14 Mar 2024 07:35:23 -0400 Subject: [PATCH 086/272] Add `test_schema_refresh` Add test for checking if schema is refreshed when a new table is created --- tests/test_api_base.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 83432f4e..4fadda8d 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -247,6 +247,41 @@ def test_create_table(base, requests_mock, mock_tables_endpoint): assert table.schema().primary_field_id == "fldWasJustCreated" +def test_schema_refresh(base, requests_mock, mock_tables_endpoint): + """ + Test that base.schema() is forced to refresh on table create. + """ + + schema_id = id(base.schema()) + + m = requests_mock.post(mock_tables_endpoint._url, json={"id": "tblWasJustCreated"}) + mock_tables_endpoint._responses[0]._params["json"]["tables"].append( + { + "id": "tblWasJustCreated", + "name": "Table Name", + "primaryFieldId": "fldWasJustCreated", + "fields": [ + { + "id": "fldWasJustCreated", + "name": "Whatever", + "type": "singleLineText", + } + ], + "views": [], + } + ) + + table = base.create_table( + "Table Name", + fields=[{"name": "Whatever"}], + description="Description", + ) + + assert m.call_count == 1 + assert table.id == "tblWasJustCreated" + assert not id(base.schema()) == schema_id + + def test_delete(base, requests_mock): """ Test that Base.delete() hits the right endpoint. From 181980d8550da2feb27363d18a46e600ca13dc78 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 14 Mar 2024 13:24:02 -0700 Subject: [PATCH 087/272] Release 2.3.1 --- docs/source/changelog.rst | 8 ++++++++ pyairtable/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e17b32c2..ce6616b7 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,14 @@ Changelog ========= +2.3.1 (2024-03-14) +------------------------ + +* Fixed a bug affecting how timezones are parsed by :class:`~pyairtable.orm.fields.DatetimeField`. + - `PR #342 `_. +* Fixed a bug affecting :meth:`~pyairtable.Base.create_table`. + - `PR #345 `_. + 2.3.0 (2024-02-25) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index f4fc3536..637325be 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.0.post1" +__version__ = "2.3.1" from .api import Api, Base, Table from .api.enterprise import Enterprise From a601934a91afd7631270416afd79be0dd8e76ca0 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Thu, 14 Mar 2024 23:21:12 -0400 Subject: [PATCH 088/272] Bug fix for deprecated `metadata.get_table_schema` Fixes the bug described in #314 Adds test case in `tests\test_api_table` --- pyairtable/metadata.py | 2 ++ tests/test_api_table.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py index b231eac5..90e8fde7 100644 --- a/pyairtable/metadata.py +++ b/pyairtable/metadata.py @@ -146,4 +146,6 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no co assert isinstance(table_record, dict) if table.name == table_record["name"]: return table_record + if table.id == table_record["id"]: + return table_record return None diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 6fa43c6a..8a2b8aee 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -5,6 +5,7 @@ from requests_mock import Mocker from pyairtable import Api, Base, Table +from pyairtable.metadata import get_table_schema from pyairtable.models.schema import TableSchema from pyairtable.testing import fake_id, fake_record from pyairtable.utils import chunked @@ -441,6 +442,26 @@ def test_delete_view(table, mock_schema, requests_mock): assert m.call_count == 1 +def test_deprecated_get_schema_by_id(base: Base, api, requests_mock, sample_json): + """ + Tests the ability to get a table schema by `id` using the deprecated `pyairtable.metadata.get_table_schema` + """ + mock_create = requests_mock.get( + base.meta_url("tables"), + json=sample_json("BaseSchema"), + ) + + # Test fetching schema by id + table = api.table(base.id, base.tables()[0].id) + + # Deprecated method for getting table's schema + table_schema = get_table_schema(table) + + assert table_schema is None + assert table_schema["id"] == table.id + assert mock_create.call_count == 2 + + # Helpers From 52f37db93d9490b87057ff387b85b695e4795e3f Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Fri, 15 Mar 2024 00:12:14 -0400 Subject: [PATCH 089/272] Fix linting issue Issue during checks due to including a type in the test function --- tests/test_api_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 8a2b8aee..3dc097b4 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -442,7 +442,7 @@ def test_delete_view(table, mock_schema, requests_mock): assert m.call_count == 1 -def test_deprecated_get_schema_by_id(base: Base, api, requests_mock, sample_json): +def test_deprecated_get_schema_by_id(base, api, requests_mock, sample_json): """ Tests the ability to get a table schema by `id` using the deprecated `pyairtable.metadata.get_table_schema` """ From 1b3f592ba7ca5dded2d4faad75e46679089198df Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Fri, 15 Mar 2024 00:15:07 -0400 Subject: [PATCH 090/272] Fix assertion in `test_api_table.py` --- tests/test_api_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 3dc097b4..f3cad438 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -457,7 +457,7 @@ def test_deprecated_get_schema_by_id(base, api, requests_mock, sample_json): # Deprecated method for getting table's schema table_schema = get_table_schema(table) - assert table_schema is None + assert table_schema is not None assert table_schema["id"] == table.id assert mock_create.call_count == 2 From 001885bdf3b02d418dd5c7aa58681cbb36bf1f1d Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Mon, 18 Mar 2024 00:37:09 -0400 Subject: [PATCH 091/272] Add decorator for kwargs modification --- pyairtable/orm/model.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index f3e752c4..75aad7c4 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -170,6 +170,19 @@ def _validate_class(cls) -> None: ) ) + def modify_kwargs(func): + """Decorator to modify the kwargs by calling _kwargs_union method.""" + from functools import wraps + + @wraps(func) + def wrapper(cls, *args, **kwargs): + # Modify kwargs by calling the _kwargs_union method on the class. + if hasattr(cls, "_kwargs_union"): + kwargs = cls._kwargs_union(kwargs) + return func(cls, *args, **kwargs) + + return wrapper + @classmethod @lru_cache def get_api(cls) -> Api: @@ -238,6 +251,7 @@ def delete(self) -> bool: return bool(result["deleted"]) @classmethod + @modify_kwargs def all(cls, **kwargs: Any) -> List[SelfType]: """ Retrieve all records for this model. For all supported @@ -257,6 +271,25 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: return cls.from_record(record) return None + @classmethod + def _kwargs_union(cls, request_args: dict): + meta_keys = [ + "view", + "fields", + "sort", + "formula", + "cell_format", + "user_locale", + "time_zone", + "return_fields_by_field_id", + ] + print("running union") + args_union = { + attr: cls._get_meta(attr) for attr in meta_keys if cls._get_meta(attr) + } + args_union.update(request_args) + return args_union + def to_record(self, only_writable: bool = False) -> RecordDict: """ Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance. From 927414a6909088d35e5cb6fc3c27df2ebcfc047c Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Mon, 18 Mar 2024 01:09:10 -0400 Subject: [PATCH 092/272] Update model.py --- pyairtable/orm/model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 75aad7c4..2118b3de 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -201,7 +201,9 @@ def get_table(cls) -> Table: @classmethod def _typecast(cls) -> bool: - return bool(cls._get_meta("typecast", default=True)) + _ = bool(cls._get_meta("typecast", default=True)) + print(f"Typecast: {_}") + return _ def exists(self) -> bool: """ @@ -261,6 +263,7 @@ def all(cls, **kwargs: Any) -> List[SelfType]: return [cls.from_record(record) for record in table.all(**kwargs)] @classmethod + @modify_kwargs def first(cls, **kwargs: Any) -> Optional[SelfType]: """ Retrieve the first record for this model. For all supported @@ -283,7 +286,6 @@ def _kwargs_union(cls, request_args: dict): "time_zone", "return_fields_by_field_id", ] - print("running union") args_union = { attr: cls._get_meta(attr) for attr in meta_keys if cls._get_meta(attr) } From 1a58b711658d6de73661118c1592c023a00b4baa Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 18 Mar 2024 08:57:06 -0700 Subject: [PATCH 093/272] Fix failing integration test from #349 --- pyairtable/metadata.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py index 90e8fde7..706431cd 100644 --- a/pyairtable/metadata.py +++ b/pyairtable/metadata.py @@ -142,10 +142,11 @@ def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no co stacklevel=2, ) base_schema = get_base_schema(table) + by_id: Dict[str, Dict[Any, Any]] = {} for table_record in base_schema.get("tables", {}): assert isinstance(table_record, dict) + by_id[table_record["id"]] = table_record if table.name == table_record["name"]: return table_record - if table.id == table_record["id"]: - return table_record - return None + # if lookup by name fails, perhaps table.name is actually an ID + return by_id.get(table.name) From 23a02aaff879e5ae7ff549d22ccaa09b35f1c8db Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 18 Mar 2024 09:07:08 -0700 Subject: [PATCH 094/272] Release 2.3.2 --- docs/source/changelog.rst | 6 ++++++ pyairtable/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index ce6616b7..baebf6aa 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,12 @@ Changelog ========= +2.3.2 (2024-03-18) +------------------------ + +* Fixed a bug affecting :func:`pyairtable.metadata.get_table_schema`. + - `PR #349 `_. + 2.3.1 (2024-03-14) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 637325be..cad16f95 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.1" +__version__ = "2.3.2" from .api import Api, Base, Table from .api.enterprise import Enterprise From 2744c67ffcdf3145471ff56b07e2a94db8a1b0bf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 19 Mar 2024 00:07:21 -0700 Subject: [PATCH 095/272] Fix docs typos --- docs/source/changelog.rst | 2 +- docs/source/formulas.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0284d9c0..08eceb5f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -56,7 +56,7 @@ Changelog :func:`~pyairtable.formulas.GREATER_EQUAL`, and :func:`~pyairtable.formulas.NOT_EQUAL`. - - `PR #323 `_ + - `PR #323 `_. 2.2.1 (2023-11-28) ------------------------ diff --git a/docs/source/formulas.rst b/docs/source/formulas.rst index a9ad11ad..cf1a7a5d 100644 --- a/docs/source/formulas.rst +++ b/docs/source/formulas.rst @@ -71,7 +71,7 @@ You can also use Python operators to modify and combine formulas: :header-rows: 1 * - Python operator - - `Airtable equivexpressionalent `__ + - `Airtable expression `__ * - ``lval & rval`` - ``AND(lval, rval)`` * - ``lval | rval`` From ff4fac4131819862fcb88c4e3f7597270e0889c1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 13 Mar 2024 12:10:03 -0700 Subject: [PATCH 096/272] Return `""` and `False` from TextField, CheckboxField --- pyairtable/orm/fields.py | 75 +++++++++++++++-------- tests/integration/test_integration_orm.py | 2 +- tests/test_orm.py | 30 ++++----- tests/test_orm_fields.py | 48 +++++++++++++++ tests/test_typing.py | 18 +++--- 5 files changed, 121 insertions(+), 52 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 3bf18054..a5df6c35 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -64,19 +64,22 @@ _ClassInfo: TypeAlias = Union[type, Tuple["_ClassInfo", ...]] T = TypeVar("T") -T_Linked = TypeVar("T_Linked", bound="Model") +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_Missing = TypeVar("T_Missing") # type returned when Airtable has no value -class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta): +class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta): """ A generic class for an Airtable field descriptor that will be included in an ORM model. - Type-checked subclasses should provide two type parameters, - ``T_API`` and ``T_ORM``, which indicate the type returned - by the API and the type used to store values internally. + Type-checked subclasses should provide three type parameters: + + * ``T_API``, indicating the JSON-serializable type returned by the API + * ``T_ORM``, indicating the type used to store values internally + * ``T_Missing``, indicating the type of value returned if the field is empty Subclasses should also define ``valid_types`` as a type or tuple of types, which will be used to validate the type @@ -149,11 +152,13 @@ def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... # obj.field will call __get__(instance=obj, owner=Model) @overload - def __get__(self, instance: "Model", owner: Type[Any]) -> Optional[T_ORM]: ... + def __get__( + self, instance: "Model", owner: Type[Any] + ) -> Union[T_ORM, T_Missing]: ... def __get__( self, instance: Optional["Model"], owner: Type[Any] - ) -> Union[SelfType, Optional[T_ORM]]: + ) -> Union[SelfType, T_ORM, T_Missing]: # allow calling Model.field to get the field object instead of a value if not instance: return self @@ -173,8 +178,12 @@ def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: def __delete__(self, instance: "Model") -> None: raise AttributeError(f"cannot delete {self._description}") - def _missing_value(self) -> Optional[T_ORM]: - return None + @classmethod + def _missing_value(cls) -> T_Missing: + # This assumes Field[T_API, T_ORM, None]. If a subclass defines T_Missing as + # something different, it needs to override _missing_value. + # This can be tidied in 3.13 with T_Missing(default=None). See PEP-696. + return cast(T_Missing, None) def to_record_value(self, value: Any) -> Any: """ @@ -249,17 +258,36 @@ def lte(self, value: Any) -> "formulas.Comparison": return formulas.LTE(self, value) -#: A generic Field whose internal and API representations are the same type. -_BasicField: TypeAlias = Field[T, T] +class _FieldWithTypedDefaultValue(Generic[T], Field[T, T, T]): + """ + A generic Field with default value of the same type as internal and API representations. + + For now this is used for TextField and CheckboxField, because Airtable stores the empty + values for those types ("" and False) internally as None. + """ + + @classmethod + def _missing_value(cls) -> T: + first_type = cls.valid_types + while isinstance(first_type, tuple): + if not first_type: + raise RuntimeError(f"{cls.__qualname__}.valid_types is malformed") + first_type = first_type[0] + return cast(T, first_type()) + + +#: A generic Field with internal and API representations that are the same type. +_BasicField: TypeAlias = Field[T, T, None] #: An alias for any type of Field. -AnyField: TypeAlias = _BasicField[Any] +AnyField: TypeAlias = Field[Any, Any, Any] -class TextField(_BasicField[str]): +class TextField(_FieldWithTypedDefaultValue[str]): """ - Used for all Airtable text fields. Accepts ``str``. + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. See `Single line text `__ and `Long text `__. @@ -329,8 +357,9 @@ def valid_or_raise(self, value: int) -> None: raise ValueError("rating cannot be below 1") -class CheckboxField(_BasicField[bool]): +class CheckboxField(_FieldWithTypedDefaultValue[bool]): """ + Accepts ``bool``. Returns ``False`` instead of ``None`` if the field is empty on the Airtable base. See `Checkbox `__. @@ -338,11 +367,8 @@ class CheckboxField(_BasicField[bool]): valid_types = bool - def _missing_value(self) -> bool: - return False - -class DatetimeField(Field[str, datetime]): +class DatetimeField(Field[str, datetime, None]): """ DateTime field. Accepts only `datetime `_ values. @@ -364,7 +390,7 @@ def to_internal_value(self, value: str) -> datetime: return utils.datetime_from_iso_str(value) -class DateField(Field[str, date]): +class DateField(Field[str, date, None]): """ Date field. Accepts only `date `_ values. @@ -386,7 +412,7 @@ def to_internal_value(self, value: str) -> date: return utils.date_from_iso_str(value) -class DurationField(Field[int, timedelta]): +class DurationField(Field[int, timedelta, None]): """ Duration field. Accepts only `timedelta `_ values. @@ -418,7 +444,7 @@ class _DictField(Generic[T], _BasicField[T]): valid_types = dict -class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM]]): +class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM], List[T_ORM]]): """ 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. @@ -455,11 +481,6 @@ def _get_list_value(self, instance: "Model") -> List[T_ORM]: instance._fields[self.field_name] = value return value - def to_internal_value(self, value: Optional[List[T_ORM]]) -> List[T_ORM]: - if value is None: - value = [] - return value - class _ValidatingListField(Generic[T], _ListField[T, T]): contains_type: Type[T] diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 6300f34e..49455927 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -219,7 +219,7 @@ def test_every_field(Everything): # The ORM won't refresh the model's field values after save() assert record.formula_integer is None - assert record.formula_nan is None + assert record.formula_nan == "" assert record.link_count is None assert record.lookup_error == [] assert record.lookup_integer == [] diff --git a/tests/test_orm.py b/tests/test_orm.py index ac90f142..4826d4f3 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -15,7 +15,7 @@ class Address(Model): Meta = fake_meta(table_name="Address") street = f.TextField("Street") - number = f.TextField("Number") + number = f.IntegerField("Number") class Contact(Model): @@ -73,7 +73,7 @@ def test_unsupplied_fields(): """ a = Address() assert a.number is None - assert a.street is None + assert a.street == "" def test_null_fields(): @@ -188,7 +188,7 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records): record IDs into instances of the model. This could interfere with save(), so this test ensures we don't regress the capability. """ - address_json = fake_record(Number="123", Street="Fake St") + address_json = fake_record(Number=123, Street="Fake St") address_id = address_json["id"] address_url_re = re.escape(Address.get_table().url + "?filterByFormula=") contact_json = fake_record(Email="alice@example.com", Link=[address_id]) @@ -246,7 +246,7 @@ def test_undeclared_field(requests_mock, test_case): """ record = fake_record( - Number="123", + Number=123, Street="Fake St", City="Springfield", State="IL", @@ -265,7 +265,7 @@ def test_undeclared_field(requests_mock, test_case): _, get_model_instance = test_case instance = get_model_instance(Address, record["id"]) - assert instance.to_record()["fields"] == {"Number": "123", "Street": "Fake St"} + assert instance.to_record()["fields"] == {"Number": 123, "Street": "Fake St"} @mock.patch("pyairtable.Table.batch_create") @@ -275,19 +275,19 @@ def test_batch_save(mock_update, mock_create): Test that we can pass multiple unsaved Model instances (or dicts) to batch_save and it will create or update them all in as few requests as possible. """ - addr1 = Address(number="123", street="Fake St") - addr2 = Address(number="456", street="Fake St") + addr1 = Address(number=123, street="Fake St") + addr2 = Address(number=456, street="Fake St") addr3 = Address.from_record( { "id": "recExistingRecord", "createdTime": datetime.utcnow().isoformat(), - "fields": {"Number": "789", "Street": "Fake St"}, + "fields": {"Number": 789, "Street": "Fake St"}, } ) mock_create.return_value = [ - fake_record(id="abc", Number="123", Street="Fake St"), - fake_record(id="def", Number="456", Street="Fake St"), + fake_record(id="abc", Number=123, Street="Fake St"), + fake_record(id="def", Number=456, Street="Fake St"), ] # Just like model.save(), Model.batch_save() will set IDs on new records. @@ -298,8 +298,8 @@ def test_batch_save(mock_update, mock_create): mock_create.assert_called_once_with( [ - {"Number": "123", "Street": "Fake St"}, - {"Number": "456", "Street": "Fake St"}, + {"Number": 123, "Street": "Fake St"}, + {"Number": 456, "Street": "Fake St"}, ], typecast=True, ) @@ -307,7 +307,7 @@ def test_batch_save(mock_update, mock_create): [ { "id": "recExistingRecord", - "fields": {"Number": "789", "Street": "Fake St"}, + "fields": {"Number": 789, "Street": "Fake St"}, }, ], typecast=True, @@ -366,8 +366,8 @@ def test_batch_delete__unsaved_record(mock_delete): receives any models which have not been created yet. """ addresses = [ - Address.from_record(fake_record(Number="1", Street="Fake St")), - Address(number="2", street="Fake St"), + Address.from_record(fake_record(Number=1, Street="Fake St")), + Address(number=2, street="Fake St"), ] with pytest.raises(ValueError): Address.batch_delete(addresses) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index fd14d6f4..96c27128 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -679,3 +679,51 @@ def patch_callback(request, context): obj.dt = datetime.datetime(2024, 3, 1, 11, 22, 33) obj.save() assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000" + + +@pytest.mark.parametrize( + "classinfo,expected", + [ + (str, ""), + ((str, bool), ""), + ((((str,),),), ""), + (bool, False), + ], +) +def test_missing_value(classinfo, expected): + """ + Test that _FieldWithTypedDefaultValue._missing_value finds the first + valid type and calls it to create the "missing from Airtable" value. + """ + + class F(f._FieldWithTypedDefaultValue): + valid_types = classinfo + + class T: + the_field = F("Field Name") + + assert T().the_field == expected + + +@pytest.mark.parametrize( + "classinfo,exc_class", + [ + ((), RuntimeError), + ((((), str), bool), RuntimeError), + ], +) +def test_missing_value__invalid_classinfo(classinfo, exc_class): + """ + Test that _FieldWithTypedDefaultValue._missing_value raises an exception + if the class's valid_types is set to an invalid value. + """ + + class F(f._FieldWithTypedDefaultValue): + valid_types = classinfo + + class T: + the_field = F("Field Name") + + obj = T() + with pytest.raises(exc_class): + obj.the_field diff --git a/tests/test_typing.py b/tests/test_typing.py index f8f0f2cb..c5a76ff8 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -71,7 +71,7 @@ class Actor(orm.Model): name = orm.fields.TextField("Name") logins = orm.fields.MultipleCollaboratorsField("Logins") - assert_type(Actor().name, Optional[str]) + assert_type(Actor().name, str) assert_type(Actor().logins, List[T.CollaboratorDict]) class Movie(orm.Model): @@ -81,11 +81,11 @@ class Movie(orm.Model): actors = orm.fields.LinkField("Actors", Actor) movie = Movie() - assert_type(movie.name, Optional[str]) + 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[0].name, Optional[str]) + assert_type(movie.actors[0].name, str) class EveryField(orm.Model): aitext = orm.fields.AITextField("AI Generated Text") @@ -123,7 +123,7 @@ class EveryField(orm.Model): assert_type(record.autonumber, Optional[int]) assert_type(record.barcode, Optional[T.BarcodeDict]) assert_type(record.button, Optional[T.ButtonDict]) - assert_type(record.checkbox, Optional[bool]) + assert_type(record.checkbox, bool) assert_type(record.collaborator, Optional[T.CollaboratorDict]) assert_type(record.count, Optional[int]) assert_type(record.created_by, Optional[T.CollaboratorDict]) @@ -132,7 +132,7 @@ class EveryField(orm.Model): assert_type(record.date, Optional[datetime.date]) assert_type(record.datetime, Optional[datetime.datetime]) assert_type(record.duration, Optional[datetime.timedelta]) - assert_type(record.email, Optional[str]) + assert_type(record.email, str) assert_type(record.float, Optional[float]) assert_type(record.integer, Optional[int]) assert_type(record.last_modified_by, Optional[T.CollaboratorDict]) @@ -141,8 +141,8 @@ class EveryField(orm.Model): assert_type(record.multi_select, List[str]) assert_type(record.number, Optional[Union[int, float]]) assert_type(record.percent, Optional[Union[int, float]]) - assert_type(record.phone, Optional[str]) + assert_type(record.phone, str) assert_type(record.rating, Optional[int]) - assert_type(record.rich_text, Optional[str]) - assert_type(record.select, Optional[str]) - assert_type(record.url, Optional[str]) + assert_type(record.rich_text, str) + assert_type(record.select, str) + assert_type(record.url, str) From 701ea70812d6965dcd29892a8996b52c330707c4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 13 Mar 2024 17:27:15 -0700 Subject: [PATCH 097/272] Fix __all__ in pyairtable.orm.fields --- pyairtable/orm/fields.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index a5df6c35..e975a865 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -944,10 +944,10 @@ class UrlField(TextField): with open(cog.inFile) as fp: src = fp.read() -classes = re.findall(r"class ([A-Z]\w+Field)", src) -constants = re.findall(r"^([A-Z][A-Z_+]) = ", src) +classes = re.findall(r"class ((?:[A-Z]\w+)?Field)", src) +constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+) = ", src, re.MULTILINE) extras = ["LinkSelf"] -names = sorted(classes + constants + extras) +names = sorted(classes) + constants + extras cog.outl("\n\n__all__ = [") for name in ["Field", *names]: @@ -974,12 +974,12 @@ class UrlField(TextField): "DurationField", "EmailField", "ExternalSyncSourceField", + "Field", "FloatField", "IntegerField", "LastModifiedByField", "LastModifiedTimeField", "LinkField", - "LinkSelf", "LookupField", "MultipleCollaboratorsField", "MultipleSelectField", @@ -991,8 +991,13 @@ class UrlField(TextField): "SelectField", "TextField", "UrlField", + "ALL_FIELDS", + "READONLY_FIELDS", + "FIELD_TYPES_TO_CLASSES", + "FIELD_CLASSES_TO_TYPES", + "LinkSelf", ] -# [[[end]]] (checksum: 3fa8c12315457baf170f9766fd8c9f8e) +# [[[end]]] (checksum: 2aa36f4e76db73f3d0b741b6be6c9e9e) # Delayed import to avoid circular dependency From 033a36ed05a6f2b034cefba2802b8a53c871d895 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 15 Mar 2024 12:02:55 -0700 Subject: [PATCH 098/272] SelectField should distinguish `""` and `None` --- pyairtable/orm/fields.py | 7 +++++-- pyairtable/orm/model.py | 8 ++++++-- tests/test_orm_fields.py | 27 +++++++++++++++++++++++++++ tests/test_typing.py | 2 +- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index e975a865..2dbbffcd 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -846,13 +846,16 @@ class RichTextField(TextField): """ -class SelectField(TextField): +class SelectField(Field[str, str, None]): """ - Equivalent to :class:`~TextField`. + Represents a single select dropdown field. This will return ``None`` if no value is set, + and will only return ``""`` if an empty dropdown option is available and selected. See `Single select `__. """ + valid_types = str + class UrlField(TextField): """ diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 21038cd1..acfcdaab 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -286,10 +286,14 @@ def from_record(cls, record: RecordDict) -> SelfType: # Convert Column Names into model field names field_values = { # Use field's to_internal_value to cast into model fields - field: name_field_map[field].to_internal_value(value) + field: ( + name_field_map[field].to_internal_value(value) + if value is not None + else None + ) for (field, value) in record["fields"].items() # Silently proceed if Airtable returns fields we don't recognize - if field in name_field_map and value is not None + if field in name_field_map } # Since instance(**field_values) will perform validation and fail on # any readonly fields, instead we directly set instance._fields. diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 96c27128..c93c2842 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -1,6 +1,7 @@ import datetime import operator import re +from unittest import mock import pytest @@ -727,3 +728,29 @@ class T: obj = T() with pytest.raises(exc_class): obj.the_field + + +@pytest.mark.parametrize( + "fields,expected", + [ + ({}, None), + ({"Field": None}, None), + ({"Field": ""}, ""), + ({"Field": "xyz"}, "xyz"), + ], +) +def test_select_field(fields, expected): + """ + Test that select field distinguishes between empty string and None. + """ + + class T(Model): + Meta = fake_meta() + the_field = f.SelectField("Field") + + obj = T.from_record(fake_record(**fields)) + assert obj.the_field == expected + + with mock.patch("pyairtable.Table.update", return_value=obj.to_record()) as m: + obj.save() + m.assert_called_once_with(obj.id, fields, typecast=True) diff --git a/tests/test_typing.py b/tests/test_typing.py index c5a76ff8..60019447 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -144,5 +144,5 @@ class EveryField(orm.Model): assert_type(record.phone, str) assert_type(record.rating, Optional[int]) assert_type(record.rich_text, str) - assert_type(record.select, str) + assert_type(record.select, Optional[str]) assert_type(record.url, str) From ab13ce5a5e52d3928c5679d2aa4c8218743cbc9e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 19 Mar 2024 00:14:06 -0700 Subject: [PATCH 099/272] orm_null_values: Changelog --- docs/source/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 08eceb5f..8022d4de 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,6 +5,10 @@ Changelog 3.0 (TBD) ------------------------ +* ORM fields :class:`~pyairtable.orm.fields.TextField` and + :class:`~pyairtable.orm.fields.CheckboxField` will no longer + return ``None`` when the field is empty. + - `PR #347 `_. * Rewrite of :mod:`pyairtable.formulas` module. - `PR #329 `_. From 0933afc8b4ed390c1452243c938ad5606076fbf8 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Wed, 20 Mar 2024 01:59:10 -0400 Subject: [PATCH 100/272] Update `orm.Model._kwargs_union` --- pyairtable/orm/model.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 2118b3de..684ed451 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -276,20 +276,11 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: @classmethod def _kwargs_union(cls, request_args: dict): - meta_keys = [ - "view", - "fields", - "sort", - "formula", + disallowed_args = ( "cell_format", - "user_locale", - "time_zone", "return_fields_by_field_id", - ] - args_union = { - attr: cls._get_meta(attr) for attr in meta_keys if cls._get_meta(attr) - } - args_union.update(request_args) + ) + args_union = {k: v for k, v in request_args.items() if k not in disallowed_args} return args_union def to_record(self, only_writable: bool = False) -> RecordDict: From 152094fd6c4196ab5ccf1f3094db447c76df0146 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Wed, 20 Mar 2024 02:06:28 -0400 Subject: [PATCH 101/272] Add 'disallowed_args' to 'Model.all' and 'Model.first' --- pyairtable/orm/model.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 684ed451..ec95c68f 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -177,12 +177,23 @@ def modify_kwargs(func): @wraps(func) def wrapper(cls, *args, **kwargs): # Modify kwargs by calling the _kwargs_union method on the class. - if hasattr(cls, "_kwargs_union"): - kwargs = cls._kwargs_union(kwargs) + kwargs = cls._kwargs_union(kwargs) return func(cls, *args, **kwargs) return wrapper + @classmethod + def _kwargs_union(cls, request_args: dict): + disallowed_args = ( + "cell_format", + "return_fields_by_field_id", + ) + args_union = {k: v for k, v in request_args.items() if k not in disallowed_args} + use_field_ids = cls._get_meta("use_field_ids") + if use_field_ids: + args_union["return_fields_by_field_id"] = True + return args_union + @classmethod @lru_cache def get_api(cls) -> Api: @@ -274,15 +285,6 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: return cls.from_record(record) return None - @classmethod - def _kwargs_union(cls, request_args: dict): - disallowed_args = ( - "cell_format", - "return_fields_by_field_id", - ) - args_union = {k: v for k, v in request_args.items() if k not in disallowed_args} - return args_union - def to_record(self, only_writable: bool = False) -> RecordDict: """ Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance. From 20fabfcffa18d16f16a9483f6cf2518986bda353 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Wed, 20 Mar 2024 02:19:07 -0400 Subject: [PATCH 102/272] Update model.py --- pyairtable/orm/model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index ec95c68f..471aaedc 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -31,6 +31,7 @@ class Model: * ``table_name`` (required) - Table ID or name. * ``timeout`` - A tuple indicating a connect and read timeout. Defaults to no timeout. * ``typecast`` - |kwarg_typecast| Defaults to ``True``. + * ``use_field_ids`` - |kwarg_use_field_ids| Defaults to ``False``. .. code-block:: python @@ -46,6 +47,7 @@ class Meta: api_key = "keyapikey" timeout = (5, 5) typecast = True + use_field_ids = True You can implement meta attributes as callables if certain values need to be dynamically provided or are unavailable at import time: From 3065db34115aee80633f91d994b6a407b1aacf9d Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Wed, 20 Mar 2024 02:50:57 -0400 Subject: [PATCH 103/272] Update test_orm_model.py --- tests/test_orm_model.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 3f069305..9c70cfc2 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -173,6 +173,27 @@ def test_from_ids__no_fetch(mock_all): assert set(contact.id for contact in contacts) == set(fake_ids) +def test_disallowed_args(): + """ + Test the argument sanitizer, `use_field_ids` attribute, and disallowed kwargs + """ + obj = FakeModel() + + FakeModel.Meta.use_field_ids = True + with mock.patch("pyairtable.Table.all") as mock_all: + obj.all(fields=["one", "two"]) + assert obj._get_meta("use_field_ids") + assert "return_fields_by_field_id" in mock_all.call_args.kwargs + assert "fields" in mock_all.call_args.kwargs + + FakeModel.Meta.use_field_ids = False + with mock.patch("pyairtable.Table.all") as mock_all: + obj.all(fields=["one", "two"], return_fields_by_field_id=True) + assert not obj._get_meta("use_field_ids") + assert "return_fields_by_field_id" not in mock_all.call_args.kwargs + assert "fields" in mock_all.call_args.kwargs + + def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide From e205e448c79e43f257d674bd098a42ab3fe06dd6 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Wed, 20 Mar 2024 03:00:20 -0400 Subject: [PATCH 104/272] Update model.py --- pyairtable/orm/model.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 471aaedc..a82898a8 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,4 +1,4 @@ -from functools import lru_cache +from functools import lru_cache, wraps from typing import Any, Dict, Iterable, List, Optional from typing_extensions import Self as SelfType @@ -173,26 +173,25 @@ def _validate_class(cls) -> None: ) def modify_kwargs(func): - """Decorator to modify the kwargs by calling _kwargs_union method.""" - from functools import wraps + # Modifies kwargs passed to Model.all and Model.first + # If Meta.use_field_ids, kwargs['return_fields_by_field_id']=True. @wraps(func) def wrapper(cls, *args, **kwargs): - # Modify kwargs by calling the _kwargs_union method on the class. - kwargs = cls._kwargs_union(kwargs) + kwargs = cls._kwargs_all_first(kwargs) return func(cls, *args, **kwargs) return wrapper @classmethod - def _kwargs_union(cls, request_args: dict): + def _kwargs_all_first(cls, request_args: dict): + # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. disallowed_args = ( "cell_format", "return_fields_by_field_id", ) args_union = {k: v for k, v in request_args.items() if k not in disallowed_args} - use_field_ids = cls._get_meta("use_field_ids") - if use_field_ids: + if cls._get_meta("use_field_ids"): args_union["return_fields_by_field_id"] = True return args_union From ecc3006aa581071db119fb12c9134f6ed95303be Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 21 Mar 2024 21:40:15 -0700 Subject: [PATCH 105/272] Fix broken docs for orm.fields constants --- pyairtable/orm/fields.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 2dbbffcd..9d543cf2 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -886,7 +886,7 @@ class UrlField(TextField): #: Mapping of Airtable field type names to their ORM classes. #: See https://airtable.com/developers/web/api/field-model -#: and :ref:`Formulas, Rollups, and Lookups`. +#: and :ref:`Formula, Rollup, and Lookup Fields`. #: #: The data type of "formula" and "rollup" fields will depend #: on the underlying fields they reference, so it is not practical @@ -941,22 +941,23 @@ class UrlField(TextField): # Auto-generate __all__ to explicitly exclude any imported values -r"""[[[cog]]] -import re - -with open(cog.inFile) as fp: - src = fp.read() - -classes = re.findall(r"class ((?:[A-Z]\w+)?Field)", src) -constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+) = ", src, re.MULTILINE) -extras = ["LinkSelf"] -names = sorted(classes) + constants + extras - -cog.outl("\n\n__all__ = [") -for name in ["Field", *names]: - cog.outl(f' "{name}",') -cog.outl("]") -[[[out]]]""" +# +# [[[cog]]] +# import re +# +# with open(cog.inFile) as fp: +# src = fp.read() +# +# classes = re.findall(r"class ((?:[A-Z]\w+)?Field)", src) +# constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+) = ", src, re.MULTILINE) +# extras = ["LinkSelf"] +# names = sorted(classes) + constants + extras +# +# cog.outl("\n\n__all__ = [") +# for name in ["Field", *names]: +# cog.outl(f' "{name}",') +# cog.outl("]") +# [[[out]]] __all__ = [ From 51b4b05693da8a97ad58cb63b4d26babb0d749fc Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 15 Mar 2024 13:43:05 -0700 Subject: [PATCH 106/272] Model.created_time is now a datetime, not a str --- pyairtable/orm/model.py | 17 ++++++++++------- tests/test_orm.py | 16 ++++++++++------ tests/test_orm_fields.py | 3 ++- tests/test_orm_model.py | 14 +++++++++++--- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index acfcdaab..1d20e490 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,3 +1,4 @@ +import datetime from functools import lru_cache from typing import Any, Dict, Iterable, List, Optional @@ -16,6 +17,7 @@ from pyairtable.formulas import EQ, OR, RECORD_ID from pyairtable.models import Comment from pyairtable.orm.fields import AnyField, Field +from pyairtable.utils import datetime_from_iso_str, datetime_to_iso_str class Model: @@ -69,7 +71,7 @@ def api_key(): """ id: str = "" - created_time: str = "" + created_time: Optional[datetime.datetime] = None _deleted: bool = False _fields: Dict[FieldName, Any] @@ -219,7 +221,7 @@ def save(self) -> bool: did_create = False self.id = record["id"] - self.created_time = record["createdTime"] + self.created_time = datetime_from_iso_str(record["createdTime"]) return did_create def delete(self) -> bool: @@ -275,7 +277,8 @@ def to_record(self, only_writable: bool = False) -> RecordDict: for field, value in self._fields.items() if not (map_[field].readonly and only_writable) } - return {"id": self.id, "createdTime": self.created_time, "fields": fields} + ct = datetime_to_iso_str(self.created_time) if self.created_time else "" + return {"id": self.id, "createdTime": ct, "fields": fields} @classmethod def from_record(cls, record: RecordDict) -> SelfType: @@ -299,7 +302,7 @@ def from_record(cls, record: RecordDict) -> SelfType: # any readonly fields, instead we directly set instance._fields. instance = cls(id=record["id"]) instance._fields = field_values - instance.created_time = record["createdTime"] + instance.created_time = datetime_from_iso_str(record["createdTime"]) return instance @classmethod @@ -388,9 +391,9 @@ def batch_save(cls, models: List[SelfType]) -> None: table = cls.get_table() table.batch_update(update_records, typecast=cls._typecast()) created_records = table.batch_create(create_records, typecast=cls._typecast()) - for model, created_record in zip(create_models, created_records): - model.id = created_record["id"] - model.created_time = created_record["createdTime"] + for model, record in zip(create_models, created_records): + model.id = record["id"] + model.created_time = datetime_from_iso_str(record["createdTime"]) @classmethod def batch_delete(cls, models: List[SelfType]) -> None: diff --git a/tests/test_orm.py b/tests/test_orm.py index 4826d4f3..fdab5f5f 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -1,5 +1,5 @@ import re -from datetime import datetime +from datetime import datetime, timezone from operator import itemgetter from unittest import mock @@ -10,6 +10,9 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f from pyairtable.testing import fake_meta, fake_record +from pyairtable.utils import datetime_to_iso_str + +NOW = datetime.utcnow().isoformat() + "Z" class Address(Model): @@ -44,11 +47,12 @@ def test_model_basics(): # save with mock.patch.object(Table, "create") as m_save: - m_save.return_value = {"id": "id", "createdTime": "time"} + m_save.return_value = {"id": "id", "createdTime": NOW} contact.save() assert m_save.called assert contact.id == "id" + assert contact.created_time.tzinfo is timezone.utc # delete with mock.patch.object(Table, "delete") as m_delete: @@ -63,7 +67,7 @@ def test_model_basics(): record = contact.to_record() assert record["id"] == contact.id - assert record["createdTime"] == contact.created_time + assert record["createdTime"] == datetime_to_iso_str(contact.created_time) assert record["fields"]["First Name"] == contact.first_name @@ -89,7 +93,7 @@ def test_first(): with mock.patch.object(Table, "first") as m_first: m_first.return_value = { "id": "recwnBLPIeQJoYVt4", - "createdTime": "", + "createdTime": NOW, "fields": { "First Name": "X", "Created At": "2014-09-05T12:34:56.000Z", @@ -113,7 +117,7 @@ def test_from_record(): with mock.patch.object(Table, "get") as m_get: m_get.return_value = { "id": "recwnBLPIeQJoYVt4", - "createdTime": "", + "createdTime": NOW, "fields": { "First Name": "X", "Birthday": None, @@ -162,7 +166,7 @@ def test_readonly_field_not_saved(): def test_linked_record(): - record = {"id": "recFake", "createdTime": "", "fields": {"Street": "A"}} + record = {"id": "recFake", "createdTime": NOW, "fields": {"Street": "A"}} address = Address.from_id("recFake", fetch=False) # Id Reference diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index c93c2842..e0f354ca 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -14,6 +14,7 @@ fake_record, fake_user, ) +from pyairtable.utils import datetime_to_iso_str DATE_S = "2023-01-01" DATE_V = datetime.date(2023, 1, 1) @@ -651,7 +652,7 @@ class M(Model): def patch_callback(request, context): return { "id": obj.id, - "createdTime": obj.created_time, + "createdTime": datetime_to_iso_str(obj.created_time), "fields": request.json()["fields"], } diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index a95d5228..b4462326 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -182,12 +182,20 @@ def test_from_ids__no_fetch(mock_all): assert set(contact.id for contact in contacts) == set(fake_ids) -@pytest.mark.parametrize("methodname", ("all", "first")) -def test_passthrough(methodname): +@pytest.mark.parametrize( + "methodname,returns", + ( + ("all", [fake_record(), fake_record(), fake_record()]), + ("first", fake_record()), + ), +) +def test_passthrough(methodname, returns): """ Test that .all() and .first() pass through whatever they get. """ - with mock.patch(f"pyairtable.Table.{methodname}") as mock_endpoint: + with mock.patch( + f"pyairtable.Table.{methodname}", return_value=returns + ) as mock_endpoint: method = getattr(FakeModel, methodname) method(a=1, b=2, c=3) From cf3498f10317f33256e05ba6aee6397c92745ac8 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 16 Mar 2024 01:05:25 -0700 Subject: [PATCH 107/272] Schema objects use datetime instead of str --- docs/source/orm.rst | 2 +- docs/source/tables.rst | 2 +- pyairtable/api/base.py | 4 ++-- pyairtable/api/table.py | 2 +- pyairtable/models/_base.py | 21 +++++++++++++++++++-- pyairtable/models/audit.py | 3 ++- pyairtable/models/comment.py | 5 +++-- pyairtable/models/schema.py | 29 +++++++++++++++-------------- pyairtable/models/webhook.py | 17 +++++++++-------- tests/test_models_comment.py | 2 +- tests/test_models_webhook.py | 4 +++- 11 files changed, 57 insertions(+), 34 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index d0e2a8f6..fb2e95e7 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -360,7 +360,7 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/docs/source/tables.rst b/docs/source/tables.rst index a76308ef..552d7a47 100644 --- a/docs/source/tables.rst +++ b/docs/source/tables.rst @@ -278,7 +278,7 @@ and :meth:`~pyairtable.Table.add_comment` methods will return instances of Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 358b66d7..e850cc3d 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -210,7 +210,7 @@ def webhooks(self) -> List[Webhook]: last_successful_notification_time=None, notification_url="https://example.com", last_notification_result=None, - expiration_time="2023-07-01T00:00:00.000Z", + expiration_time=datetime.datetime(...), specification: WebhookSpecification(...) ) ] @@ -264,7 +264,7 @@ def add_webhook( CreateWebhookResponse( id='ach00000000000001', mac_secret_base64='c3VwZXIgZHVwZXIgc2VjcmV0', - expiration_time='2023-07-01T00:00:00.000Z' + expiration_time=datetime.datetime(...) ) Raises: diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 0cc4cdc5..3050bbe2 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -562,7 +562,7 @@ def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]: Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 479d201f..64b1f105 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -1,3 +1,4 @@ +from datetime import datetime from functools import partial from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union @@ -5,7 +6,11 @@ from typing_extensions import Self as SelfType from pyairtable._compat import pydantic -from pyairtable.utils import _append_docstring_text +from pyairtable.utils import ( + _append_docstring_text, + datetime_from_iso_str, + datetime_to_iso_str, +) class AirtableModel(pydantic.BaseModel): @@ -30,8 +35,16 @@ class Config: _raw: Any = pydantic.PrivateAttr() def __init__(self, **data: Any) -> None: + self._raw = data.copy() + # Convert JSON-serializable input data to the types expected by our model. + # For now this only converts ISO 8601 strings to datetime objects. + for field_model in self.__fields__.values(): + for name in {field_model.name, field_model.alias}: + if not (value := data.get(name)): + continue + if isinstance(value, str) and field_model.type_ is datetime: + data[name] = datetime_from_iso_str(value) super().__init__(**data) - self._raw = data @classmethod def from_api( @@ -244,6 +257,10 @@ def save(self) -> None: exclude=exclude, exclude_none=(not self.__save_none), ) + # This undoes the finagling we do in __init__, converting datetime back to str. + for key in data: + if isinstance(value := data.get(key), datetime): + data[key] = datetime_to_iso_str(value) response = self._api.request(self.__save_http_method, self._url, json=data) if self.__reload_after_save: self._reload(response) diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index 00a63f25..69e34992 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional from typing_extensions import TypeAlias @@ -30,7 +31,7 @@ class AuditLogEvent(AirtableModel): """ id: str - timestamp: str + timestamp: datetime action: str actor: "AuditLogActor" model_id: str diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 4d27a917..3769e2cf 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict, Optional from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs @@ -19,7 +20,7 @@ class Comment( Comment( id='comdVMNxslc6jG0Xe', text='Hello, @[usrVMNxslc6jG0Xed]!', - created_time='2023-06-07T17:46:24.435891', + created_time=datetime.datetime(...), last_updated_time=None, mentioned={ 'usrVMNxslc6jG0Xed': Mentioned( @@ -48,7 +49,7 @@ class Comment( text: str #: The ISO 8601 timestamp of when the comment was created. - created_time: str + created_time: datetime #: The ISO 8601 timestamp of when the comment was last edited. last_updated_time: Optional[str] diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0de7056b..1b4dd9f8 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,4 +1,5 @@ import importlib +from datetime import datetime from functools import partial from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast @@ -178,7 +179,7 @@ class InterfaceCollaborators( _Collaborators, url="meta/bases/{base.id}/interfaces/{key}", ): - created_time: str + created_time: datetime group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() invite_links: List["InterfaceInviteLink"] = _FL() @@ -214,7 +215,7 @@ class Info( ): state: str created_by_user_id: str - created_time: str + created_time: datetime share_id: str type: str is_password_protected: bool @@ -348,7 +349,7 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): class GroupCollaborator(AirtableModel): - created_time: str + created_time: datetime granted_by_user_id: str group_id: str name: str @@ -356,7 +357,7 @@ class GroupCollaborator(AirtableModel): class IndividualCollaborator(AirtableModel): - created_time: str + created_time: datetime granted_by_user_id: str user_id: str email: str @@ -380,7 +381,7 @@ class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"): id: str type: str - created_time: str + created_time: datetime invited_email: Optional[str] referred_by_user_id: str permission_level: str @@ -427,7 +428,7 @@ class EnterpriseInfo(AirtableModel): """ id: str - created_time: str + created_time: datetime group_ids: List[str] user_ids: List[str] workspace_ids: List[str] @@ -447,7 +448,7 @@ class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): id: str name: str - created_time: str + created_time: datetime base_ids: List[str] restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions") # fmt: skip group_collaborators: "WorkspaceCollaborators.GroupCollaborators" = _F("WorkspaceCollaborators.GroupCollaborators") # fmt: skip @@ -527,7 +528,7 @@ def workspaces(self) -> Dict[str, "Collaborations.WorkspaceCollaboration"]: class BaseCollaboration(AirtableModel): base_id: str - created_time: str + created_time: datetime granted_by_user_id: str permission_level: str @@ -536,7 +537,7 @@ class InterfaceCollaboration(BaseCollaboration): class WorkspaceCollaboration(AirtableModel): workspace_id: str - created_time: str + created_time: datetime granted_by_user_id: str permission_level: str @@ -559,8 +560,8 @@ class UserInfo( state: str is_sso_required: bool is_two_factor_auth_enabled: bool - last_activity_time: Optional[str] - created_time: Optional[str] + last_activity_time: Optional[datetime] + created_time: Optional[datetime] enterprise_user_type: Optional[str] invited_to_airtable_by_user_id: Optional[str] is_managed: bool = False @@ -581,8 +582,8 @@ class UserGroup(AirtableModel): id: str name: str enterprise_account_id: str - created_time: str - updated_time: str + created_time: datetime + updated_time: datetime members: List["UserGroup.Member"] collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) @@ -592,7 +593,7 @@ class Member(AirtableModel): first_name: str last_name: str role: str - created_time: str + created_time: datetime # The data model is a bit confusing here, but it's designed for maximum reuse. diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index e8d7cdbf..3ca54552 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -1,4 +1,5 @@ import base64 +from datetime import datetime from functools import partial from hmac import HMAC from typing import Any, Callable, Dict, Iterator, List, Optional, Union @@ -30,7 +31,7 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"): CreateWebhookResponse( id='ach00000000000001', mac_secret_base64='c3VwZXIgZHVwZXIgc2VjcmV0', - expiration_time='2023-07-01T00:00:00.000Z' + expiration_time=datetime.datetime(...) ) >>> webhooks = base.webhooks() >>> webhooks[0] @@ -42,7 +43,7 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"): last_successful_notification_time=None, notification_url="https://example.com", last_notification_result=None, - expiration_time="2023-07-01T00:00:00.000Z", + expiration_time=datetime.datetime(...), specification: WebhookSpecification(...) ) >>> webhooks[0].disable_notifications() @@ -110,7 +111,7 @@ def payloads( >>> iter_payloads = webhook.payloads() >>> next(iter_payloads) WebhookPayload( - timestamp="2022-02-01T21:25:05.663Z", + timestamp=datetime.datetime(...), base_transaction_number=4, payload_format="v0", action_metadata=ActionMetadata( @@ -204,7 +205,7 @@ def airtable_webhook(): base: _NestedId webhook: _NestedId - timestamp: str + timestamp: datetime @classmethod def from_request( @@ -241,7 +242,7 @@ def from_request( class WebhookNotificationResult(AirtableModel): success: bool - completion_timestamp: str + completion_timestamp: datetime duration_ms: float retry_number: int will_be_retried: Optional[bool] = None @@ -300,7 +301,7 @@ class CreateWebhookResponse(AirtableModel): mac_secret_base64: str #: The timestamp when the webhook will expire and be deleted. - expiration_time: Optional[str] + expiration_time: Optional[datetime] class WebhookPayload(AirtableModel): @@ -309,7 +310,7 @@ class WebhookPayload(AirtableModel): `Webhooks payload `_. """ - timestamp: str + timestamp: datetime base_transaction_number: int payload_format: str action_metadata: Optional["WebhookPayload.ActionMetadata"] @@ -372,7 +373,7 @@ class CellValuesByFieldId(AirtableModel): cell_values_by_field_id: Dict[str, Any] class RecordCreated(AirtableModel): - created_time: str + created_time: datetime cell_values_by_field_id: Dict[str, Any] diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 9b5b9799..fc4f223d 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -80,7 +80,7 @@ def test_save(comment, requests_mock): """ new_text = "This was changed!" mentions = {} - modified = dict(comment.dict(by_alias=True), mentioned=mentions, text=new_text) + modified = dict(comment._raw, mentioned=mentions, text=new_text) m = requests_mock.patch(comment._url, json=modified) comment.text = "Whatever" diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py index 512ab295..5a5572a0 100644 --- a/tests/test_models_webhook.py +++ b/tests/test_models_webhook.py @@ -189,7 +189,9 @@ def test_notification_from_request(secret): notification = WebhookNotification.from_request(body, header, secret) assert notification.base.id == "app00000000000000" assert notification.webhook.id == "ach00000000000000" - assert notification.timestamp == "2022-02-01T21:25:05.663Z" + assert notification.timestamp == datetime.datetime( + 2022, 2, 1, 21, 25, 5, 663000, tzinfo=datetime.timezone.utc + ) with pytest.raises(ValueError): WebhookNotification.from_request("[1,2,3]", header, secret) From f93e545f98804e533feb8416061b7f797aeeb4fe Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 19 Mar 2024 01:40:09 -0700 Subject: [PATCH 108/272] Remove deprecated datetime.utcnow() --- tests/integration/test_integration_orm.py | 6 +++--- tests/test_orm.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 49455927..099e6935 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone import pytest @@ -116,8 +116,8 @@ def test_integration_orm(Contact, Address): email="email@email.com", is_registered=True, address=[address], - birthday=datetime.utcnow().date(), - last_access=datetime.utcnow(), + birthday=datetime.now(timezone.utc).date(), + last_access=datetime.now(timezone.utc), ) assert contact.first_name == "John" diff --git a/tests/test_orm.py b/tests/test_orm.py index fdab5f5f..60178fc1 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -12,7 +12,7 @@ from pyairtable.testing import fake_meta, fake_record from pyairtable.utils import datetime_to_iso_str -NOW = datetime.utcnow().isoformat() + "Z" +NOW = datetime.now().isoformat() + "Z" class Address(Model): @@ -146,7 +146,7 @@ def test_readonly_field_not_saved(): record = { "id": "recwnBLPIeQJoYVt4", - "createdTime": datetime.utcnow().isoformat(), + "createdTime": datetime.now(timezone.utc).isoformat(), "fields": { "Birthday": "1970-01-01", "Age": 57, @@ -284,7 +284,7 @@ def test_batch_save(mock_update, mock_create): addr3 = Address.from_record( { "id": "recExistingRecord", - "createdTime": datetime.utcnow().isoformat(), + "createdTime": datetime.now(timezone.utc).isoformat(), "fields": {"Number": 789, "Street": "Fake St"}, } ) From c28942753b67b52abcae27e3bbebacbaf49f71cf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 21 Mar 2024 21:41:13 -0700 Subject: [PATCH 109/272] Do not call orm.Model.Meta callables when subclasses are created --- pyairtable/orm/model.py | 36 ++++++++++++++++++++++++------------ tests/test_orm_model.py | 27 ++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index acfcdaab..461f9838 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -142,13 +142,25 @@ def __init__(self, **fields: Any): setattr(self, key, value) @classmethod - def _get_meta(cls, name: str, default: Any = None, required: bool = False) -> Any: + def _get_meta( + cls, name: str, default: Any = None, required: bool = False, call: bool = True + ) -> Any: + """ + Retrieves the value of a Meta attribute. + + Args: + default: The default value to return if the attribute is not set. + required: Raise an exception if the attribute is not set. + call: If the value is callable, call it before returning a result. + """ if not hasattr(cls, "Meta"): raise AttributeError(f"{cls.__name__}.Meta must be defined") - if required and not hasattr(cls.Meta, name): - raise ValueError(f"{cls.__name__}.Meta.{name} must be defined") - value = getattr(cls.Meta, name, default) - if callable(value): + if not hasattr(cls.Meta, name): + if required: + raise ValueError(f"{cls.__name__}.Meta.{name} must be defined") + return default + value = getattr(cls.Meta, name) + if call and callable(value): value = value() if required and value is None: raise ValueError(f"{cls.__name__}.Meta.{name} cannot be None") @@ -156,10 +168,10 @@ def _get_meta(cls, name: str, default: Any = None, required: bool = False) -> An @classmethod def _validate_class(cls) -> None: - # Verify required Meta attributes were set - assert cls._get_meta("api_key", required=True) - assert cls._get_meta("base_id", required=True) - assert cls._get_meta("table_name", required=True) + # Verify required Meta attributes were set (but don't call any callables) + assert cls._get_meta("api_key", required=True, call=False) + assert cls._get_meta("base_id", required=True, call=False) + assert cls._get_meta("table_name", required=True, call=False) model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")] overridden = set(model_attributes).intersection(Model.__dict__.keys()) @@ -174,17 +186,17 @@ def _validate_class(cls) -> None: @lru_cache def get_api(cls) -> Api: return Api( - api_key=cls._get_meta("api_key"), + api_key=cls._get_meta("api_key", required=True), timeout=cls._get_meta("timeout"), ) @classmethod def get_base(cls) -> Base: - return cls.get_api().base(cls._get_meta("base_id")) + return cls.get_api().base(cls._get_meta("base_id", required=True)) @classmethod def get_table(cls) -> Table: - return cls.get_base().table(cls._get_meta("table_name")) + return cls.get_base().table(cls._get_meta("table_name", required=True)) @classmethod def _typecast(cls) -> bool: diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index a95d5228..133e6e2a 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -51,6 +51,22 @@ class Address(Model): street = f.TextField("Street") +def test_model_empty_meta_with_callable(): + """ + Test that we throw an exception when a required Meta attribute is + defined as a callable which returns None. + """ + + class Address(Model): + Meta = fake_meta(api_key=lambda: None) + street = f.TextField("Street") + + with mock.patch("pyairtable.Table.first", return_value=fake_record()) as m: + with pytest.raises(ValueError): + Address.first() + m.assert_not_called() + + @pytest.mark.parametrize("name", ("exists", "id")) def test_model_overlapping(name): """ @@ -197,7 +213,8 @@ def test_passthrough(methodname): def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide - the access token, base ID, and table name at runtime. + the access token, base ID, and table name at runtime. Also ensure + that callable Meta attributes don't get called until they're needed. """ data = { "api_key": "FakeApiKey", @@ -209,12 +226,12 @@ class Fake(Model): class Meta: api_key = lambda: data["api_key"] # noqa base_id = partial(data.get, "base_id") - - @staticmethod - def table_name(): - return data["table_name"] + table_name = mock.Mock(return_value=data["table_name"]) f = Fake() + Fake.Meta.table_name.assert_not_called() + assert f._get_meta("api_key") == data["api_key"] assert f._get_meta("base_id") == data["base_id"] assert f._get_meta("table_name") == data["table_name"] + Fake.Meta.table_name.assert_called_once() From 939c2d482325c8c350b2903196e279ac2eaaf53b Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Fri, 22 Mar 2024 02:18:02 -0400 Subject: [PATCH 110/272] Update model.py --- pyairtable/orm/model.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index a82898a8..3e5e217c 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,4 +1,4 @@ -from functools import lru_cache, wraps +from functools import lru_cache from typing import Any, Dict, Iterable, List, Optional from typing_extensions import Self as SelfType @@ -172,19 +172,8 @@ def _validate_class(cls) -> None: ) ) - def modify_kwargs(func): - # Modifies kwargs passed to Model.all and Model.first - # If Meta.use_field_ids, kwargs['return_fields_by_field_id']=True. - - @wraps(func) - def wrapper(cls, *args, **kwargs): - kwargs = cls._kwargs_all_first(kwargs) - return func(cls, *args, **kwargs) - - return wrapper - @classmethod - def _kwargs_all_first(cls, request_args: dict): + def _get_meta_request_kwargs(cls, request_args: dict): # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. disallowed_args = ( "cell_format", @@ -214,7 +203,6 @@ def get_table(cls) -> Table: @classmethod def _typecast(cls) -> bool: _ = bool(cls._get_meta("typecast", default=True)) - print(f"Typecast: {_}") return _ def exists(self) -> bool: @@ -265,22 +253,22 @@ def delete(self) -> bool: return bool(result["deleted"]) @classmethod - @modify_kwargs def all(cls, **kwargs: Any) -> List[SelfType]: """ Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. """ + kwargs.update(cls._get_meta_request_kwargs(kwargs)) table = cls.get_table() return [cls.from_record(record) for record in table.all(**kwargs)] @classmethod - @modify_kwargs def first(cls, **kwargs: Any) -> Optional[SelfType]: """ Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. """ + kwargs.update(cls._get_meta_request_kwargs(kwargs)) table = cls.get_table() if record := table.first(**kwargs): return cls.from_record(record) From 6fef52dedcd0660c10c025ef3a36d27f55a73313 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 22 Mar 2024 10:53:50 -0700 Subject: [PATCH 111/272] Only run integration tests once, not in each environment --- Makefile | 5 +---- tox.ini | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 5c604b85..ca48d5ee 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,8 @@ hooks: release: @zsh -c "./scripts/release.sh" -.PHONY: test test-e2e coverage lint format docs clean +.PHONY: test coverage lint format docs clean test: - tox -- -m 'not integration' - -test-e2e: tox coverage: diff --git a/tox.ini b/tox.ini index 6d218d9e..51586ae2 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = pre-commit mypy py3{8,9,10,11,12}{,-pydantic1,-requestsmin} + integration coverage [gh-actions] @@ -28,12 +29,16 @@ passenv = addopts = -v testpaths = tests commands = - python -m pytest {posargs} + python -m pytest {posargs:-m 'not integration'} deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg pydantic1: pydantic<2 # Lots of projects still use 1.x +[testenv:integration] +commands = + python -m pytest -m integration + [testenv:coverage] passenv = COVERAGE_FORMAT commands = From ba870cda43478e753bd61257f723d93dd6383ffc Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 22 Mar 2024 10:57:42 -0700 Subject: [PATCH 112/272] Bump to 3.0.0a1 --- pyairtable/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index cad16f95..c7d7a050 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.2" +__version__ = "3.0.0a1" from .api import Api, Base, Table from .api.enterprise import Enterprise From c035dc4afcf38b38a2fe2976dcc31b3d272b28e7 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 22 Mar 2024 11:00:20 -0700 Subject: [PATCH 113/272] Remove deprecated `pyairtable.metadata` module --- pyairtable/metadata.py | 152 ------------------ .../integration/test_integration_metadata.py | 32 +--- tests/test_api_table.py | 21 --- 3 files changed, 1 insertion(+), 204 deletions(-) delete mode 100644 pyairtable/metadata.py diff --git a/pyairtable/metadata.py b/pyairtable/metadata.py deleted file mode 100644 index 706431cd..00000000 --- a/pyairtable/metadata.py +++ /dev/null @@ -1,152 +0,0 @@ -import warnings -from typing import Any, Dict, Optional, Union - -from pyairtable.api import Api, Base, Table - - -def get_api_bases(api: Union[Api, Base]) -> Dict[Any, Any]: # pragma: no cover - """ - Return list of Bases from an Api or Base instance. - - This function has been deprecated. Use - :meth:`Api.bases() ` instead. - - Args: - api: :class:`Api` or :class:`Base` instance - - Usage: - >>> get_api_bases(api) - { - "bases": [ - { - "id": "appY3WxIBCdKPDdIa", - "name": "Apartment Hunting", - "permissionLevel": "create" - }, - { - "id": "appSW9R5uCNmRmfl6", - "name": "Project Tracker", - "permissionLevel": "edit" - } - ] - } - """ - warnings.warn( - "get_api_bases is deprecated; use Api.bases() instead.", - category=DeprecationWarning, - stacklevel=2, - ) - api = api.api if isinstance(api, Base) else api - base_list_url = api.build_url("meta", "bases") - return { - "bases": [ - base - for page in api.iterate_requests("get", base_list_url) - for base in page.get("bases", []) - ] - } - - -def get_base_schema(base: Union[Base, Table]) -> Dict[Any, Any]: # pragma: no cover - """ - Returns Schema of a Base - - This function has been deprecated. Use - :meth:`Base.schema() ` instead. - - Args: - base: :class:`Base` or :class:`Table` instance - - Usage: - >>> get_base_schema(base) - { - "tables": [ - { - "id": "tbltp8DGLhqbUmjK1", - "name": "Apartments", - "primaryFieldId": "fld1VnoyuotSTyxW1", - "fields": [ - { - "id": "fld1VnoyuotSTyxW1", - "name": "Name", - "type": "singleLineText" - }, - { - "id": "fldoaIqdn5szURHpw", - "name": "Pictures", - "type": "multipleAttachment" - }, - { - "id": "fldumZe00w09RYTW6", - "name": "District", - "type": "multipleRecordLinks" - } - ], - "views": [ - { - "id": "viwQpsuEDqHFqegkp", - "name": "Grid view", - "type": "grid" - } - ] - } - ] - } - """ - warnings.warn( - "get_base_schema is deprecated; use Base.schema() instead.", - category=DeprecationWarning, - stacklevel=2, - ) - base = base.base if isinstance(base, Table) else base - base_schema_url = base.api.build_url("meta", "bases", base.id, "tables") - assert isinstance(response := base.api.request("get", base_schema_url), dict) - return response - - -def get_table_schema(table: Table) -> Optional[Dict[Any, Any]]: # pragma: no cover - """ - Returns the specific table schema record provided by base schema list - - This function has been deprecated. Use - :meth:`Table.schema() ` instead. - - Args: - table: :class:`Table` instance - - Usage: - >>> get_table_schema(table) - { - "id": "tbltp8DGLhqbUmjK1", - "name": "Apartments", - "primaryFieldId": "fld1VnoyuotSTyxW1", - "fields": [ - { - "id": "fld1VnoyuotSTyxW1", - "name": "Name", - "type": "singleLineText" - } - ], - "views": [ - { - "id": "viwQpsuEDqHFqegkp", - "name": "Grid view", - "type": "grid" - } - ] - } - """ - warnings.warn( - "get_table_schema is deprecated; use Table.schema() instead.", - category=DeprecationWarning, - stacklevel=2, - ) - base_schema = get_base_schema(table) - by_id: Dict[str, Dict[Any, Any]] = {} - for table_record in base_schema.get("tables", {}): - assert isinstance(table_record, dict) - by_id[table_record["id"]] = table_record - if table.name == table_record["name"]: - return table_record - # if lookup by name fails, perhaps table.name is actually an ID - return by_id.get(table.name) diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py index 96316821..315a6a91 100644 --- a/tests/integration/test_integration_metadata.py +++ b/tests/integration/test_integration_metadata.py @@ -1,8 +1,7 @@ import pytest import requests -from pyairtable import Api, Base, Table -from pyairtable.metadata import get_api_bases, get_base_schema, get_table_schema +from pyairtable import Api, Base pytestmark = [pytest.mark.integration] @@ -37,32 +36,3 @@ def test_table_schema(base: Base, table_name: str, cols): assert cols.TEXT in [f.name for f in schema.fields] assert schema.field(cols.TEXT).id == cols.TEXT_ID assert schema.field(cols.TEXT_ID).name == cols.TEXT - - -def test_deprecated_get_api_bases(base: Base, base_name: str): - with pytest.warns(DeprecationWarning): - rv = get_api_bases(base) - assert base_name in [b["name"] for b in rv["bases"]] - - -def test_deprecated_get_base_schema(base: Base): - with pytest.warns(DeprecationWarning): - rv = get_base_schema(base) - assert sorted(table["name"] for table in rv["tables"]) == [ - "Address", - "Contact", - "EVERYTHING", - "TEST_TABLE", - ] - - -def test_deprecated_get_table_schema(table: Table): - with pytest.warns(DeprecationWarning): - rv = get_table_schema(table) - assert rv and rv["name"] == table.name - - -def test_deprecated_get_table_schema__invalid_table(table, monkeypatch): - monkeypatch.setattr(table, "name", "DoesNotExist") - with pytest.warns(DeprecationWarning): - assert get_table_schema(table) is None diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 7f2f6f01..d432676a 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -7,7 +7,6 @@ from pyairtable import Api, Base, Table from pyairtable.formulas import AND, EQ, Field -from pyairtable.metadata import get_table_schema from pyairtable.models.schema import TableSchema from pyairtable.testing import fake_id, fake_record from pyairtable.utils import chunked @@ -461,26 +460,6 @@ def test_delete_view(table, mock_schema, requests_mock): assert m.call_count == 1 -def test_deprecated_get_schema_by_id(base, api, requests_mock, sample_json): - """ - Tests the ability to get a table schema by `id` using the deprecated `pyairtable.metadata.get_table_schema` - """ - mock_create = requests_mock.get( - base.meta_url("tables"), - json=sample_json("BaseSchema"), - ) - - # Test fetching schema by id - table = api.table(base.id, base.tables()[0].id) - - # Deprecated method for getting table's schema - table_schema = get_table_schema(table) - - assert table_schema is not None - assert table_schema["id"] == table.id - assert mock_create.call_count == 2 - - # Helpers From a41e0f6bac0213eba29d5e98b8fa6654abf2e661 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 14 Mar 2024 00:05:07 -0700 Subject: [PATCH 114/272] orm.SingleLinkField to better support prefersSingleRecordLink --- docs/source/orm.rst | 53 ++++++++------ pyairtable/formulas.py | 5 ++ pyairtable/orm/fields.py | 150 ++++++++++++++++++++++++++++++++++++++- tests/test_formulas.py | 8 +++ tests/test_orm_fields.py | 134 +++++++++++++++++++++++++++++++--- tests/test_typing.py | 2 + 6 files changed, 320 insertions(+), 32 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index d0e2a8f6..a8c88001 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -20,7 +20,7 @@ The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for last_name = F.TextField("Last Name") email = F.EmailField("Email") is_registered = F.CheckboxField("Registered") - company = F.LinkField("Company", Company, lazy=False) + company = F.SingleLinkField("Company", Company, lazy=False) class Meta: base_id = "appaPqizdsNHDvlEm" @@ -187,11 +187,13 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.SelectField` - `Single select `__ + * - :class:`~pyairtable.orm.fields.SingleLinkField` + - `Link to another record `__ * - :class:`~pyairtable.orm.fields.TextField` - `Single line text `__, `Long text `__ * - :class:`~pyairtable.orm.fields.UrlField` - `Url `__ -.. [[[end]]] (checksum: 01c5696293e7571ac8250c4e8a2453e8) +.. [[[end]]] (checksum: afd0edeabb06937f2a3afd73a7bac32e) Formula, Rollup, and Lookup Fields @@ -249,44 +251,48 @@ Linked Records ---------------- In addition to standard data type fields, the :class:`~pyairtable.orm.fields.LinkField` -class offers a special behaviour that can fetch linked records, so that you can -traverse between related records. +and :class:`~pyairtable.orm.fields.SingleLinkField` classes will fetch linked records +upon access, so that you can traverse between related records. .. code-block:: python from pyairtable.orm import Model, fields as F - class Company(Model): + class Person(Model): class Meta: ... name = F.TextField("Name") + company = F.SingleLinkField("Company", "Company") - class Person(Model): + class Company(Model): class Meta: ... name = F.TextField("Name") - company = F.LinkField("Company", Company) + people = F.LinkField("People", Person) + .. code-block:: python >>> person = Person.from_id("recZ6qSLw0OCA61ul") >>> person.company - [] - >>> person.company[0].name + + >>> person.company.name 'Acme Corp' + >>> person.company.people + [, ...] pyAirtable will not retrieve field values for a model's linked records until the -first time you access that field. So in the example above, the fields for Company -were loaded when ``person.company`` was called for the first time. After that, -the Company models are persisted, and won't be refreshed until you call +first time you access a field. So in the example above, the fields for Company +were loaded when ``person.company`` was called for the first time. Linked models +are persisted after being created, and won't be refreshed until you call :meth:`~pyairtable.orm.Model.fetch`. .. note:: :class:`~pyairtable.orm.fields.LinkField` will always return a list of values, even if there is only a single value shown in the Airtable UI. It will not respect the `prefersSingleRecordLink `_ - field configuration option, because the API will *always* return linked fields - as a list of record IDs. + field configuration option. If you expect a field to only ever return a single + linked record, use :class:`~pyairtable.orm.fields.SingleLinkField`. Cyclical links @@ -317,18 +323,18 @@ address this: class Meta: ... name = F.TextField("Name") - company = F.LinkField[Company]("Company", Company) - manager = F.LinkField["Person"]("Manager", "Person") # option 2 + company = F.SingleLinkField[Company]("Company", Company) + manager = F.SingleLinkField["Person"]("Manager", "Person") # option 2 reports = F.LinkField["Person"]("Reports", F.LinkSelf) # option 3 .. code-block:: python >>> person = Person.from_id("recZ6qSLw0OCA61ul") >>> person.manager - [] - >>> person.manager[0].reports + + >>> person.manager.reports [, ...] - >>> person.company[0].employees + >>> person.company.employees [, , ...] Breaking down the :class:`~pyairtable.orm.fields.LinkField` invocation above, @@ -413,20 +419,23 @@ For example: .. code-block:: python + from pyairtable.orm import fields as F + class Person(Model): class Meta: ... name = F.TextField("Name") - manager = F.LinkField["Person"]("Manager", "Person") + manager = F.SingleLinkField["Person"]("Manager", F.LinkSelf) # This field is a formula: {Manager} != BLANK() has_manager = F.IntegerField("Has Manager?", readonly=True) bob = Person.from_id("rec2AqNuHwWcnG871") - assert bob.manager == [] + assert bob.manager is None assert bob.has_manager == 0 - bob.manager = [alice] + alice = Person.from_id("recAB2AqNuHwWcnG8") + bob.manager = alice bob.save() assert bob.has_manager == 0 diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 1a08c421..a6be5747 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -484,6 +484,11 @@ def __init__(self, name: str, *args: List[Any]): self.name = name self.args = args + def __eq__(self, other: Any) -> bool: + if not isinstance(other, FunctionCall): + return False + return (self.name, self.args) == (other.name, other.args) + def __str__(self) -> str: joined_args = ", ".join(to_formula_str(v) for v in self.args) return f"{self.name}({joined_args})" diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 9d543cf2..480db50d 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -539,7 +539,7 @@ def __init__( readonly: If ``True``, any attempt to write a value to this field will raise an ``AttributeError``. This will not, however, prevent any modification of the list object returned by this field. - lazy: If ``True``, this field will return empty objects with oly IDs; + lazy: If ``True``, this field will return empty objects with only IDs; call :meth:`~pyairtable.orm.Model.fetch` to retrieve values. """ from pyairtable.orm import Model # noqa, avoid circular import @@ -642,6 +642,151 @@ def valid_or_raise(self, value: Any) -> None: raise TypeError(f"expected {self.linked_model}; got {type(obj)}") +class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]): + """ + Represents a MultipleRecordLinks field which we assume will only ever contain one link. + Returns and accepts a single instance of the linked model, which will be converted to/from + a list of IDs when communicating with the Airtable API. + + See `Link to another record `__. + + .. warning:: + + If Airtable returns multiple IDs for a SingleLinkField and you modify the field value, + only the first ID will be saved to the API once you call ``.save()``. The other IDs will be lost. + + By default, a SingleLinkField will ignore the 2nd...Nth IDs if it receives multiple IDs from the API. + This behavior can be overridden by passing ``raise_if_many=True`` to the constructor. + + .. code-block:: python + + from pyairtable.orm import Model, fields as F + + class Book(Model): + class Meta: ... + + author = F.SingleLinkField("Author", Person) + editor = F.SingleLinkField("Editor", Person, raise_if_many=True) + + Given the model configuration above and the data below, + one field will silently return a single value, + while the other field will throw an exception. + + .. code-block:: python + + >>> book = Book.from_record({ + ... "id": "recZ6qSLw0OCA61ul", + ... "createdTime": ..., + ... "fields": { + ... "Author": ["reculZ6qSLw0OCA61", "rec61ulZ6qSLw0OCA"], + ... "Editor": ["recLw0OCA61ulZ6qS", "recOCA61ulZ6qSLw0"], + ... } + ... }) + >>> book.author + + >>> book.editor + Traceback (most recent call last): + ... + MultipleValues: Book.editor got more than one linked record + + """ + + def __init__( + self, + field_name: str, + model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]], + validate_type: bool = True, + readonly: Optional[bool] = None, + lazy: bool = False, + raise_if_many: bool = False, + ): + """ + Args: + field_name: Name of the Airtable field. + model: + Model class representing the linked table. There are a few options: + + 1. You can provide a ``str`` that is the fully qualified module and class name. + For example, ``"your.module.Model"`` will import ``Model`` from ``your.module``. + 2. You can provide a ``str`` that is *just* the class name, and it will be imported + from the same module as the model class. + 3. You can provide the sentinel value :data:`~LinkSelf`, and the link field + will point to the same model where the link field is created. + + validate_type: Whether to raise a TypeError if attempting to write + an object of an unsupported type as a field value. If ``False``, you + may encounter unpredictable behavior from the Airtable API. + readonly: If ``True``, any attempt to write a value to this field will + raise an ``AttributeError``. This will not, however, prevent any + modification of the list object returned by this field. + lazy: If ``True``, this field will return empty objects with only IDs; + call :meth:`~pyairtable.orm.Model.fetch` to retrieve values. + raise_if_many: If ``True``, this field will raise a + :class:`~pyairtable.orm.fields.MultipleValues` exception upon + being accessed if the underlying field contains multiple values. + """ + super().__init__(field_name, validate_type=validate_type, readonly=readonly) + self._raise_if_many = raise_if_many + # composition is easier than inheritance in this case ยฏ\_(ใƒ„)_/ยฏ + self._link_field = LinkField[T_Linked]( + field_name, + model, + validate_type=validate_type, + readonly=readonly, + lazy=lazy, + ) + + @overload + def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... + + @overload + def __get__(self, instance: "Model", owner: Type[Any]) -> Optional[T_Linked]: ... + + def __get__( + self, instance: Optional["Model"], owner: Type[Any] + ) -> Union[SelfType, Optional[T_Linked]]: + if not instance: + return self + if self._raise_if_many and len(instance._fields.get(self.field_name) or []) > 1: + raise MultipleValues(f"{self._description} got more than one linked record") + links = self._link_field.__get__(instance, owner) + try: + return links[0] + except IndexError: + return self._missing_value() + + def __set__(self, instance: "Model", value: Optional[T_Linked]) -> None: + self._raise_if_readonly() + values = None if value is None else [value] + self._link_field.__set__(instance, values) + + def __set_name__(self, owner: Any, name: str) -> None: + super().__set_name__(owner, name) + self._link_field.__set_name__(owner, name) + + def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: + return self._link_field.to_record_value(value) + + def _repr_fields(self) -> List[Tuple[str, Any]]: + return [ + ("model", self._link_field._linked_model), + ("validate_type", self.validate_type), + ("readonly", self.readonly), + ("lazy", self._link_field._lazy), + ("raise_if_many", self._raise_if_many), + ] + + @property + def linked_model(self) -> Type[T_Linked]: + return self._link_field.linked_model + + +class MultipleValues(ValueError): + """ + SingleLinkField received more than one value from either Airtable or calling code. + """ + + # Many of these are "passthrough" subclasses for now. E.g. there is no real # difference between `field = TextField()` and `field = PhoneNumberField()`. # @@ -993,6 +1138,7 @@ class UrlField(TextField): "RatingField", "RichTextField", "SelectField", + "SingleLinkField", "TextField", "UrlField", "ALL_FIELDS", @@ -1001,7 +1147,7 @@ class UrlField(TextField): "FIELD_CLASSES_TO_TYPES", "LinkSelf", ] -# [[[end]]] (checksum: 2aa36f4e76db73f3d0b741b6be6c9e9e) +# [[[end]]] (checksum: 314db7bbb782c156d620305a1c42dfef) # Delayed import to avoid circular dependency diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 2b85984c..b7b44c17 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -278,6 +278,14 @@ def test_function_call(): assert str(fc) == "IF(1, TRUE(), FALSE())" +def test_function_call_equivalence(): + assert F.TODAY() == F.TODAY() + assert F.TODAY() != F.NOW() + assert F.CEILING(1) == F.CEILING(1) + assert F.CEILING(1) != F.CEILING(2) + assert F.TODAY() != F.Formula("TODAY()") + + def test_field_name(): assert F.field_name("First Name") == "{First Name}" assert F.field_name("Guest's Name") == "{Guest\\'s Name}" diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index c93c2842..bc414c57 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -5,6 +5,7 @@ import pytest +from pyairtable.formulas import OR, RECORD_ID from pyairtable.orm import fields as f from pyairtable.orm.model import Model from pyairtable.testing import ( @@ -21,6 +22,10 @@ DATETIME_V = datetime.datetime(2023, 4, 12, 9, 30, 0, tzinfo=datetime.timezone.utc) +class Dummy(Model): + Meta = fake_meta() + + def test_field(): class T: name = f.Field("Name") @@ -71,6 +76,12 @@ class T: f.LinkField("Records", type("TestModel", (Model,), {"Meta": fake_meta()})), "LinkField('Records', model=, validate_type=True, readonly=False, lazy=False)", ), + ( + f.SingleLinkField( + "Records", type("TestModel", (Model,), {"Meta": fake_meta()}) + ), + "SingleLinkField('Records', model=, validate_type=True, readonly=False, lazy=False, raise_if_many=False)", + ), ], ) def test_repr(instance, expected): @@ -321,11 +332,13 @@ def test_completeness(): assert_all_fields_tested_by(test_writable_fields, test_readonly_fields) assert_all_fields_tested_by( test_type_validation, - exclude=f.READONLY_FIELDS | {f.LinkField}, + exclude=f.READONLY_FIELDS | {f.LinkField, f.SingleLinkField}, ) -def assert_all_fields_tested_by(*test_fns, exclude=(f.Field, f.LinkField)): +def assert_all_fields_tested_by( + *test_fns, exclude=(f.Field, f.LinkField, f.SingleLinkField) +): """ Allows meta-tests that fail if any new Field classes appear in pyairtable.orm.fields which are not covered by one of a few basic tests. This is intended to help remind @@ -436,12 +449,13 @@ class T: t.items = "hello!" -def test_link_field_must_link_to_model(): +@pytest.mark.parametrize("cls", (f.LinkField, f.SingleLinkField)) +def test_link_field_must_link_to_model(cls): """ Tests that a LinkField cannot link to an arbitrary type. """ with pytest.raises(TypeError): - f.LinkField("Field Name", model=dict) + cls("Field Name", model=dict) def test_link_field(): @@ -471,10 +485,6 @@ class Author(Model): author.books = -1 -class Dummy(Model): - Meta = fake_meta() - - def test_link_field__linked_model(): """ Test the various ways of specifying a linked model for the LinkField. @@ -590,6 +600,114 @@ def test_link_field__load_many(requests_mock): assert mock_list.call_count == 2 +def test_single_link_field(): + class Author(Model): + Meta = fake_meta() + name = f.TextField("Name") + + class Book(Model): + Meta = fake_meta() + author = f.SingleLinkField("Author", Author, lazy=True) + + assert Book.author.linked_model is Author + + book = Book() + assert book.author is None + + with pytest.raises(TypeError): + book.author = [Author()] + + with pytest.raises(TypeError): + book.author = [] + + alice = Author.from_record(fake_record(Name="Alice")) + book.author = alice + + with mock.patch("pyairtable.Table.get", return_value=alice.to_record()) as m: + book.author.fetch() + m.assert_called_once_with(alice.id) + + assert book.author.id == alice.id + assert book.author.name == "Alice" + + book.author = (bob := Author(name="Bob")) + assert not book.author.exists() + assert book.author.name == "Bob" + + with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m: + book.author.save() + m.assert_called_once_with({"Name": "Bob"}, typecast=True) + + with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m: + book.save() + m.assert_called_once_with({"Author": [bob.id]}, typecast=True) + + with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: + book.author = None + book.save() + m.assert_called_once_with(book.id, {"Author": None}, typecast=True) + + +def test_single_link_field__multiple_values(): + """ + Test the behavior of SingleLinkField when the Airtable API + returns multiple values. + """ + + class Author(Model): + Meta = fake_meta() + name = f.TextField("Name") + + class Book(Model): + Meta = fake_meta() + author = f.SingleLinkField("Author", Author) + + records = [fake_record(Name=f"Author {n+1}") for n in range(3)] + a1, a2, a3 = [r["id"] for r in records] + + # if Airtable sends back multiple IDs, we'll retrieve all of them, + # but we will only return the first one from the field descriptor. + book = Book.from_record(fake_record(Author=[a1, a2, a3])) + with mock.patch("pyairtable.Table.all", return_value=records) as m: + book.author + m.assert_called_once_with( + formula=OR(RECORD_ID().eq(r["id"]) for r in records), + ) + + assert book.author.id == a1 + assert book.author.name == "Author 1" + + # if no modifications made, the entire list will be sent back to the API + with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: + book.save() + m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True) + + # if we modify the field value, it will drop items 2-N + book.author = Author.from_record(fake_record()) + with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: + book.save() + m.assert_called_once_with(book.id, {"Author": [book.author.id]}, typecast=True) + + +def test_single_link_field__raise_if_many(): + """ + Test that passing raise_if_many=True to SingleLinkField will cause an exception + to be raised if (1) the field receives multiple values and (2) is accessed. + """ + + class Author(Model): + Meta = fake_meta() + name = f.TextField("Name") + + class Book(Model): + Meta = fake_meta() + author = f.SingleLinkField("Author", Author, raise_if_many=True) + + book = Book.from_record(fake_record(Author=[fake_id(), fake_id()])) + with pytest.raises(f.MultipleValues): + book.author + + def test_lookup_field(): class T: items = f.LookupField("Items") diff --git a/tests/test_typing.py b/tests/test_typing.py index 60019447..7c49db00 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -79,12 +79,14 @@ class Movie(orm.Model): rating = orm.fields.RatingField("Star Rating") prequels = orm.fields.LinkField["Movie"]("Prequels", "path.to.Movie") actors = orm.fields.LinkField("Actors", Actor) + prequel = orm.fields.SingleLinkField["Movie"]("Prequels", orm.fields.LinkSelf) 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.prequel, Optional[Movie]) assert_type(movie.actors[0].name, str) class EveryField(orm.Model): From a24002de1118eafa22f710f1c9ef95f497d4b8ed Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Sun, 24 Mar 2024 00:08:22 -0400 Subject: [PATCH 115/272] Updates for ORM model --- pyairtable/orm/model.py | 24 ++++++++++--------- tests/test_orm_model.py | 51 ++++++++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 554c5fe7..b76f2ff5 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -173,16 +173,18 @@ def _validate_class(cls) -> None: ) @classmethod - def _get_meta_request_kwargs(cls, request_args: dict): + def _get_meta_request_kwargs(cls): # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. - disallowed_args = ( - "cell_format", - "return_fields_by_field_id", - ) - args_union = {k: v for k, v in request_args.items() if k not in disallowed_args} - if cls._get_meta("use_field_ids"): - args_union["return_fields_by_field_id"] = True - return args_union + return { + "cell_format": "json", + "user_locale": None, + "time_zone": None, + "return_fields_by_field_id": ( + cls._get_meta("use_field_ids") + if cls._get_meta("use_field_ids") + else False + ), + } @classmethod @lru_cache @@ -258,7 +260,7 @@ def all(cls, **kwargs: Any) -> List[SelfType]: Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. """ - kwargs.update(cls._get_meta_request_kwargs(kwargs)) + kwargs.update(cls._get_meta_request_kwargs()) table = cls.get_table() return [cls.from_record(record) for record in table.all(**kwargs)] @@ -268,7 +270,7 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. """ - kwargs.update(cls._get_meta_request_kwargs(kwargs)) + kwargs.update(cls._get_meta_request_kwargs()) table = cls.get_table() if record := table.first(**kwargs): return cls.from_record(record) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index ba192748..2033a80f 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -2,6 +2,7 @@ from unittest import mock import pytest +from requests_mock import Mocker from pyairtable.orm import Model from pyairtable.orm import fields as f @@ -181,7 +182,7 @@ def test_from_ids__no_fetch(mock_all): assert len(contacts) == 10 assert set(contact.id for contact in contacts) == set(fake_ids) - + @pytest.mark.parametrize("methodname", ("all", "first")) def test_passthrough(methodname): """ @@ -191,28 +192,46 @@ def test_passthrough(methodname): method = getattr(FakeModel, methodname) method(a=1, b=2, c=3) - mock_endpoint.assert_called_once_with(a=1, b=2, c=3) + mock_endpoint.assert_called_once_with( + a=1, + b=2, + c=3, + time_zone=None, + user_locale=None, + cell_format="json", + return_fields_by_field_id=False, + ) + - -def test_disallowed_args(): +def test_disallowed_args(sample_json, mock_response_list): """ Test the argument sanitizer, `use_field_ids` attribute, and disallowed kwargs """ obj = FakeModel() - FakeModel.Meta.use_field_ids = True - with mock.patch("pyairtable.Table.all") as mock_all: - obj.all(fields=["one", "two"]) assert obj._get_meta("use_field_ids") - assert "return_fields_by_field_id" in mock_all.call_args.kwargs - assert "fields" in mock_all.call_args.kwargs - - FakeModel.Meta.use_field_ids = False - with mock.patch("pyairtable.Table.all") as mock_all: - obj.all(fields=["one", "two"], return_fields_by_field_id=True) - assert not obj._get_meta("use_field_ids") - assert "return_fields_by_field_id" not in mock_all.call_args.kwargs - assert "fields" in mock_all.call_args.kwargs + from pyairtable.models import schema + + obj_name = "BaseSchema" + obj_data = sample_json(obj_name) + obj_cls = getattr(schema, obj_name) + + mobj = obj_cls.parse_obj(obj_data) + table = mobj.tables[0] + + obj.Meta.table_name = table.name + obj.Meta.api_key = "patFakePersonalAccessToken" + + # Mock response for .all() + with Mocker() as mock: + mock.get( + "https://api.airtable.com/v0/appFakeTestingApp/Apartments?cellFormat=json&returnFieldsByFieldId=1&pageSize=1&maxRecords=1", + status_code=200, + json=mock_response_list[0], + complete_qs=True, + ) + response = obj.first() + print(response._field_name_descriptor_map()) def test_dynamic_model_meta(): From d4ec23e88d6ea5cdfaa2d9d1d6eaf888fb221652 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Sun, 24 Mar 2024 01:49:45 -0400 Subject: [PATCH 116/272] Update test_orm_model.py --- tests/test_orm_model.py | 60 ++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 2033a80f..4598dd3b 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -6,7 +6,7 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f -from pyairtable.testing import fake_id, fake_meta, fake_record +from pyairtable.testing import fake_id, fake_meta, fake_meta_from_ids, fake_record @pytest.fixture(autouse=True) @@ -75,6 +75,12 @@ class FakeModel(Model): two = f.TextField("two") +class FakeModelByIds(Model): + Meta = fake_meta_from_ids() + Name = f.TextField("fld1VnoyuotSTyxW1") + Age = f.NumberField("fld2VnoyuotSTy4g6") + + def test_repr(): record = fake_record() assert repr(FakeModel.from_record(record)) == f"" @@ -203,35 +209,37 @@ def test_passthrough(methodname): ) -def test_disallowed_args(sample_json, mock_response_list): - """ - Test the argument sanitizer, `use_field_ids` attribute, and disallowed kwargs - """ - obj = FakeModel() - FakeModel.Meta.use_field_ids = True - assert obj._get_meta("use_field_ids") - from pyairtable.models import schema - - obj_name = "BaseSchema" - obj_data = sample_json(obj_name) - obj_cls = getattr(schema, obj_name) - - mobj = obj_cls.parse_obj(obj_data) - table = mobj.tables[0] +@pytest.fixture +def fake_records_by_id(): + return { + "records": [ + fake_record(fld1VnoyuotSTyxW1="Alice", fld2VnoyuotSTy4g6=25), + fake_record(Name="Jack", Age=30), + ] + } - obj.Meta.table_name = table.name - obj.Meta.api_key = "patFakePersonalAccessToken" - # Mock response for .all() +def test_get_fields_by_id(fake_records_by_id): + """ + Test that we can get fields by their field ID. + """ with Mocker() as mock: mock.get( - "https://api.airtable.com/v0/appFakeTestingApp/Apartments?cellFormat=json&returnFieldsByFieldId=1&pageSize=1&maxRecords=1", - status_code=200, - json=mock_response_list[0], + f"{FakeModelByIds.get_table().url}?&returnFieldsByFieldId=1", + json=fake_records_by_id, complete_qs=True, + status_code=200, ) - response = obj.first() - print(response._field_name_descriptor_map()) + fake_models = FakeModelByIds.all() + + assert fake_models[0].Name == "Alice" + assert fake_models[0].Age == 25 + + assert fake_models[1].Name != "Jack" + assert fake_models[1].Age != 30 + + with pytest.raises(KeyError): + _ = getattr(fake_models[1], fake_records_by_id[0]["Age"]) def test_dynamic_model_meta(): @@ -247,7 +255,9 @@ def test_dynamic_model_meta(): class Fake(Model): class Meta: - api_key = lambda: data["api_key"] # noqa + def api_key(): + return data["api_key"] # noqa + base_id = partial(data.get, "base_id") @staticmethod From d971de5f8d5eb24a17be194a16972714e3745ee9 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Sun, 24 Mar 2024 01:50:10 -0400 Subject: [PATCH 117/272] Update testing.py --- pyairtable/testing.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 69158f08..dfe876b6 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -42,6 +42,24 @@ def fake_meta( return type("Meta", (), attrs) +def fake_meta_from_ids( + base_id: str = "appFakeTestingApp", + table_name: str = "Apartments", + api_key: str = "patFakePersonalAccessToken", + use_field_ids: bool = True, +) -> type: + """ + Generate a ``Meta`` class for inclusion in a ``Model`` subclass. + """ + attrs = { + "base_id": base_id, + "table_name": table_name, + "api_key": api_key, + "use_field_ids": use_field_ids, + } + return type("Meta", (), attrs) + + def fake_record( fields: Optional[Fields] = None, id: Optional[str] = None, From ff448df6ce48e064dc6662d2dc652664713e3608 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Sun, 24 Mar 2024 01:50:16 -0400 Subject: [PATCH 118/272] Update model.py --- pyairtable/orm/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index b76f2ff5..e00c1f2e 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -176,7 +176,6 @@ def _validate_class(cls) -> None: def _get_meta_request_kwargs(cls): # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. return { - "cell_format": "json", "user_locale": None, "time_zone": None, "return_fields_by_field_id": ( From 26cab98d0a8c1585d6fd1688381d0b4fbf52d4d9 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Sun, 24 Mar 2024 02:01:45 -0400 Subject: [PATCH 119/272] Update `orm.model` and tests --- pyairtable/orm/model.py | 1 + tests/test_orm_model.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index e00c1f2e..57fc654a 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -177,6 +177,7 @@ def _get_meta_request_kwargs(cls): # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. return { "user_locale": None, + "cell_format": "json", "time_zone": None, "return_fields_by_field_id": ( cls._get_meta("use_field_ids") diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 4598dd3b..7e42f3e6 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -197,15 +197,14 @@ def test_passthrough(methodname): with mock.patch(f"pyairtable.Table.{methodname}") as mock_endpoint: method = getattr(FakeModel, methodname) method(a=1, b=2, c=3) - mock_endpoint.assert_called_once_with( a=1, b=2, c=3, - time_zone=None, + return_fields_by_field_id=getattr(FakeModel.Meta, "use_field_ids", False), user_locale=None, + time_zone=None, cell_format="json", - return_fields_by_field_id=False, ) @@ -225,7 +224,7 @@ def test_get_fields_by_id(fake_records_by_id): """ with Mocker() as mock: mock.get( - f"{FakeModelByIds.get_table().url}?&returnFieldsByFieldId=1", + f"{FakeModelByIds.get_table().url}?&returnFieldsByFieldId=1&cellFormat=json", json=fake_records_by_id, complete_qs=True, status_code=200, From 64f9341d6cd57a79b07edaf06aae79b8700b8678 Mon Sep 17 00:00:00 2001 From: Benjamin Perkins Date: Tue, 26 Mar 2024 01:02:58 -0400 Subject: [PATCH 120/272] Updates per review --- pyairtable/orm/model.py | 11 ++--------- pyairtable/testing.py | 14 +------------- tests/test_orm_model.py | 8 +++----- 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 57fc654a..4fd74b94 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -47,7 +47,6 @@ class Meta: api_key = "keyapikey" timeout = (5, 5) typecast = True - use_field_ids = True You can implement meta attributes as callables if certain values need to be dynamically provided or are unavailable at import time: @@ -174,16 +173,11 @@ def _validate_class(cls) -> None: @classmethod def _get_meta_request_kwargs(cls): - # Called by modify_kwargs to modify kwargs passed to `all()` & `first()`. return { "user_locale": None, "cell_format": "json", "time_zone": None, - "return_fields_by_field_id": ( - cls._get_meta("use_field_ids") - if cls._get_meta("use_field_ids") - else False - ), + "return_fields_by_field_id": cls._get_meta("use_field_ids", default=False), } @classmethod @@ -204,8 +198,7 @@ def get_table(cls) -> Table: @classmethod def _typecast(cls) -> bool: - _ = bool(cls._get_meta("typecast", default=True)) - return _ + return bool(cls._get_meta("typecast", default=True)) def exists(self) -> bool: """ diff --git a/pyairtable/testing.py b/pyairtable/testing.py index dfe876b6..a594bf24 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -34,19 +34,7 @@ def fake_meta( base_id: str = "appFakeTestingApp", table_name: str = "tblFakeTestingTbl", api_key: str = "patFakePersonalAccessToken", -) -> type: - """ - Generate a ``Meta`` class for inclusion in a ``Model`` subclass. - """ - attrs = {"base_id": base_id, "table_name": table_name, "api_key": api_key} - return type("Meta", (), attrs) - - -def fake_meta_from_ids( - base_id: str = "appFakeTestingApp", - table_name: str = "Apartments", - api_key: str = "patFakePersonalAccessToken", - use_field_ids: bool = True, + use_field_ids: bool = False, ) -> type: """ Generate a ``Meta`` class for inclusion in a ``Model`` subclass. diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 7e42f3e6..466da9d8 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -6,7 +6,7 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f -from pyairtable.testing import fake_id, fake_meta, fake_meta_from_ids, fake_record +from pyairtable.testing import fake_id, fake_meta, fake_record @pytest.fixture(autouse=True) @@ -76,7 +76,7 @@ class FakeModel(Model): class FakeModelByIds(Model): - Meta = fake_meta_from_ids() + Meta = fake_meta(use_field_ids=True, table_name="Apartments") Name = f.TextField("fld1VnoyuotSTyxW1") Age = f.NumberField("fld2VnoyuotSTy4g6") @@ -254,9 +254,7 @@ def test_dynamic_model_meta(): class Fake(Model): class Meta: - def api_key(): - return data["api_key"] # noqa - + api_key = lambda: data["api_key"] # noqa base_id = partial(data.get, "base_id") @staticmethod From f72c792637db70e9b7d3869b4b4dc3220e84805a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 14 Mar 2024 08:47:57 -0700 Subject: [PATCH 121/272] Model.link_field.populate(instance) --- pyairtable/orm/fields.py | 50 +++++++++++++++++++++++++++++++++------- tests/test_orm_fields.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 480db50d..c2328cd2 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -586,15 +586,35 @@ def _repr_fields(self) -> List[Tuple[str, Any]]: ("lazy", self._lazy), ] - def _get_list_value(self, instance: "Model") -> List[T_Linked]: + def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None: """ - Unlike most other field classes, LinkField does not store its internal - representation (T_ORM) in instance._fields after Model.from_record(). - Instead, we defer creating objects until they're requested for the first - time, so we can avoid infinite recursion during to_internal_value(). + Populates the field's value for the given instance. This allows you to + selectively load models in either lazy or non-lazy fashion, depending on + your need, without having to decide at the time of field construction. + + Usage: + + .. code-block:: python + + from pyairtable.orm import Model, fields as F + + class Book(Model): + class Meta: ... + + class Author(Model): + class Meta: ... + books = F.LinkField("Books", Book) + + author = Author.from_id("reculZ6qSLw0OCA61") + Author.books.populate(author, lazy=True) """ + if self._model and not isinstance(instance, self._model): + raise RuntimeError( + f"populate() got {type(instance)}; expected {self._model}" + ) + lazy = lazy if lazy is not None else self._lazy if not (records := super()._get_list_value(instance)): - return records + return # If there are any values which are IDs rather than instances, # retrieve their values in bulk, and store them keyed by ID # so we can maintain the order we received from the API. @@ -604,7 +624,7 @@ def _get_list_value(self, instance: "Model") -> List[T_Linked]: record.id: record for record in self.linked_model.from_ids( cast(List[RecordId], new_record_ids), - fetch=(not self._lazy), + fetch=(not lazy), ) } # If the list contains record IDs, replace the contents with instances. @@ -614,7 +634,18 @@ def _get_list_value(self, instance: "Model") -> List[T_Linked]: new_records[cast(RecordId, value)] if isinstance(value, RecordId) else value for value in records ] - return records + + def _get_list_value(self, instance: "Model") -> List[T_Linked]: + """ + Unlike most other field classes, LinkField does not store its internal + representation (T_ORM) in instance._fields after Model.from_record(). + They will first be stored as a list of IDs. + + We defer creating Model objects until they're requested for the first + time, so we can avoid infinite recursion during to_internal_value(). + """ + self.populate(instance) + return super()._get_list_value(instance) def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: """ @@ -776,6 +807,9 @@ def _repr_fields(self) -> List[Tuple[str, Any]]: ("raise_if_many", self._raise_if_many), ] + def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None: + self._link_field.populate(instance, lazy=lazy) + @property def linked_model(self) -> Type[T_Linked]: return self._link_field.linked_model diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index bc414c57..a0aaff3f 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +from requests_mock import NoMockAddress from pyairtable.formulas import OR, RECORD_ID from pyairtable.orm import fields as f @@ -708,6 +709,48 @@ class Book(Model): book.author +@pytest.mark.parametrize("field_type", (f.LinkField, f.SingleLinkField)) +def test_link_field__populate(field_type, requests_mock): + """ + Test that implementers can use Model.link_field.populate(instance) to control + whether loading happens lazy or non-lazy at runtime. + """ + + class Linked(Model): + Meta = fake_meta() + name = f.TextField("Name") + + class T(Model): + Meta = fake_meta() + link = field_type("Link", Linked) + + links = [fake_record(id=n, Name=f"link{n}") for n in range(1, 4)] + link_ids = [link["id"] for link in links] + obj = T.from_record(fake_record(Link=link_ids[:])) + assert obj._fields.get("Link") == link_ids + assert obj._fields.get("Link") is not link_ids + + # calling the record directly will attempt network traffic + with pytest.raises(NoMockAddress): + obj.link + + # on a non-lazy field, we can still call .populate() to load it lazily + T.link.populate(obj, lazy=True) + + if field_type is f.SingleLinkField: + assert isinstance(obj.link, Linked) + assert obj.link.id == links[0]["id"] + assert obj.link.name == "" + else: + assert isinstance(obj.link[0], Linked) + assert link_ids == [link.id for link in obj.link] + assert all(link.name == "" for link in obj.link) + + # calling .populate() on the wrong model raises an exception + with pytest.raises(RuntimeError): + T.link.populate(Linked()) + + def test_lookup_field(): class T: items = f.LookupField("Items") From d6b0bfe583daa5d34cc9f8aa21bf0900b2585b42 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 19 Mar 2024 01:17:07 -0700 Subject: [PATCH 122/272] SingleLinkField should only retrieve the first record --- pyairtable/orm/fields.py | 47 ++++++++++++++++++++++------------------ tests/test_orm_fields.py | 8 +++---- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index c2328cd2..2e0744e9 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -511,6 +511,7 @@ class LinkField(_ListField[RecordId, T_Linked]): """ _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]] + _max_retrieve: Optional[int] = None def __init__( self, @@ -619,7 +620,9 @@ class Meta: ... # retrieve their values in bulk, and store them keyed by ID # so we can maintain the order we received from the API. new_records = {} - if new_record_ids := [v for v in records if isinstance(v, RecordId)]: + if new_record_ids := [ + v for v in records[: self._max_retrieve] if isinstance(v, RecordId) + ]: new_records = { record.id: record for record in self.linked_model.from_ids( @@ -630,9 +633,9 @@ class Meta: ... # If the list contains record IDs, replace the contents with instances. # Other code may already have references to this specific list, so # we replace the existing list's values. - records[:] = [ + records[: self._max_retrieve] = [ new_records[cast(RecordId, value)] if isinstance(value, RecordId) else value - for value in records + for value in records[: self._max_retrieve] ] def _get_list_value(self, instance: "Model") -> List[T_Linked]: @@ -647,7 +650,7 @@ def _get_list_value(self, instance: "Model") -> List[T_Linked]: self.populate(instance) return super()._get_list_value(instance) - def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: + def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: """ Build the list of record IDs which should be persisted to the API. """ @@ -656,15 +659,17 @@ def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: # When persisting this model back to the API, we can just write those IDs. if all(isinstance(v, str) for v in value): return cast(List[str], value) - # From here on, we assume we're dealing with models, not record IDs. - records = cast(List[T_Linked], value) + + # Validate any items in our list which are not record IDs + records = [v for v in value if not isinstance(v, str)] self.valid_or_raise(records) - # 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. if not all(record.exists() for record in records): + # 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") - return [record.id for record in records] + + return [v if isinstance(v, str) else v.id for v in value] def valid_or_raise(self, value: Any) -> None: super().valid_or_raise(value) @@ -766,6 +771,16 @@ def __init__( readonly=readonly, lazy=lazy, ) + self._link_field._max_retrieve = 1 + + def _repr_fields(self) -> List[Tuple[str, Any]]: + return [ + ("model", self._link_field._linked_model), + ("validate_type", self.validate_type), + ("readonly", self.readonly), + ("lazy", self._link_field._lazy), + ("raise_if_many", self._raise_if_many), + ] @overload def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... @@ -787,7 +802,6 @@ def __get__( return self._missing_value() def __set__(self, instance: "Model", value: Optional[T_Linked]) -> None: - self._raise_if_readonly() values = None if value is None else [value] self._link_field.__set__(instance, values) @@ -795,18 +809,9 @@ def __set_name__(self, owner: Any, name: str) -> None: super().__set_name__(owner, name) self._link_field.__set_name__(owner, name) - def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]: + def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: return self._link_field.to_record_value(value) - def _repr_fields(self) -> List[Tuple[str, Any]]: - return [ - ("model", self._link_field._linked_model), - ("validate_type", self.validate_type), - ("readonly", self.readonly), - ("lazy", self._link_field._lazy), - ("raise_if_many", self._raise_if_many), - ] - def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None: self._link_field.populate(instance, lazy=lazy) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index a0aaff3f..fcd781d5 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -666,17 +666,15 @@ class Book(Model): records = [fake_record(Name=f"Author {n+1}") for n in range(3)] a1, a2, a3 = [r["id"] for r in records] - # if Airtable sends back multiple IDs, we'll retrieve all of them, - # but we will only return the first one from the field descriptor. + # if Airtable sends back multiple IDs, we'll only retrieve the first one. book = Book.from_record(fake_record(Author=[a1, a2, a3])) with mock.patch("pyairtable.Table.all", return_value=records) as m: book.author - m.assert_called_once_with( - formula=OR(RECORD_ID().eq(r["id"]) for r in records), - ) + m.assert_called_once_with(formula=OR(RECORD_ID().eq(records[0]["id"]))) assert book.author.id == a1 assert book.author.name == "Author 1" + assert book._fields["Author"][1:] == [a2, a3] # not converted to models # if no modifications made, the entire list will be sent back to the API with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: From a4da1c6c098b1f37dc58f27fc7f695efd694ab89 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 27 Mar 2024 23:53:51 -0700 Subject: [PATCH 123/272] Close missing line in test coverage --- tests/test_models.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 52e52560..fc91a520 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List import pytest @@ -278,3 +279,23 @@ class Dummy(RestfulModel, url="{base.id}/{dummy.one}/{dummy.two}"): r'"\'NoneType\' object has no attribute \'id\'"' r", \{'base': None, 'dummy': Dummy\(.*\)\}" ) + + +def test_datetime_conversion(api, requests_mock): + """ + Test that if an AirtableModel field is specified as a datetime, + and the input data is provided as a str, we'll convert to a datetime + and back to a str when saving. + """ + + class Dummy(CanUpdateModel, url="{self.id}", writable=["timestamp"]): + id: str + timestamp: datetime + + data = {"id": "rec000", "timestamp": "2024-01-08T12:34:56Z"} + obj = Dummy.from_api(data, api) + assert obj.timestamp == datetime(2024, 1, 8, 12, 34, 56, tzinfo=timezone.utc) + m = requests_mock.patch(obj._url, json=data) + obj.save() + assert m.call_count == 1 + assert m.request_history[0].json() == {"timestamp": "2024-01-08T12:34:56.000Z"} From 80c3c98e233c5838ba0b1ceca0ccd13729bfbca0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 29 Mar 2024 16:53:52 -0700 Subject: [PATCH 124/272] Run mypy as part of GitHub Actions --- tox.ini | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tox.ini b/tox.ini index 51586ae2..b3c023cb 100644 --- a/tox.ini +++ b/tox.ini @@ -8,11 +8,11 @@ envlist = [gh-actions] python = - 3.8: py38, coverage - 3.9: py39 - 3.10: py310 - 3.11: py311 - 3.12: py312 + 3.8: py38, mypy + 3.9: py39, mypy + 3.10: py310, mypy + 3.11: py311, mypy + 3.12: coverage, mypy [testenv:pre-commit] deps = pre-commit From 3d6da834b34ed6e6bd8a2e6dc38069b560a088b6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 29 Mar 2024 16:54:47 -0700 Subject: [PATCH 125/272] Fix missing type annotation --- pyairtable/orm/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 5254acf4..23c95217 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -186,7 +186,7 @@ def _validate_class(cls) -> None: ) @classmethod - def _get_meta_request_kwargs(cls): + def _get_meta_request_kwargs(cls) -> Dict[str, Any]: return { "user_locale": None, "cell_format": "json", From fade68174a77c1eb6c04e9ce560dc2cc2ece7999 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 31 Mar 2024 21:19:51 -0700 Subject: [PATCH 126/272] Rename return_fields_by_field_id to use_field_ids --- docs/source/_substitutions.rst | 2 +- docs/source/changelog.rst | 16 ++++++++-- docs/source/tables.rst | 5 ++- pyairtable/api/params.py | 2 +- pyairtable/api/table.py | 38 +++++++++++------------ pyairtable/orm/model.py | 2 +- tests/integration/test_integration_api.py | 26 ++++++++-------- tests/test_orm_model.py | 2 +- tests/test_params.py | 14 ++++----- 9 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index 67309dde..1aea2549 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -57,7 +57,7 @@ by provided fields; if a field is not included its value will bet set to null. If ``False``, only provided fields are updated. -.. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the +.. |kwarg_use_field_ids| replace:: An optional boolean value that lets you return field objects where the key is the field id. This defaults to `false`, which returns field objects where the key is the field name. .. |kwarg_force_metadata| replace:: diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 8022d4de..13d93b3c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,12 +5,24 @@ Changelog 3.0 (TBD) ------------------------ +* Rewrite of :mod:`pyairtable.formulas` module. + - `PR #329 `_. * ORM fields :class:`~pyairtable.orm.fields.TextField` and :class:`~pyairtable.orm.fields.CheckboxField` will no longer return ``None`` when the field is empty. - `PR #347 `_. -* Rewrite of :mod:`pyairtable.formulas` module. - - `PR #329 `_. +* Changed the type of :data:`~pyairtable.orm.Model.created_time` + from ``str`` to ``datetime``, along with all other timestamp fields + used in :ref:`API: pyairtable.models`. + - `PR #352 `_. +* Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField` + for record link fields that (generally) link to a single record. + - `PR #354 `_. +* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. +* Support ``use_field_ids`` in the :ref:`ORM`. + - `PR #355 `_. +* Removed the ``pyairtable.metadata`` module. + - `PR #360 `_. 2.3.2 (2024-03-18) ------------------------ diff --git a/docs/source/tables.rst b/docs/source/tables.rst index 552d7a47..84ec8cd5 100644 --- a/docs/source/tables.rst +++ b/docs/source/tables.rst @@ -111,9 +111,8 @@ like :meth:`~pyairtable.Table.iterate` or :meth:`~pyairtable.Table.all`. - |kwarg_user_locale| * - ``time_zone`` - |kwarg_time_zone| - * - ``return_fields_by_field_id`` - .. versionadded:: 1.3.0 - - |kwarg_return_fields_by_field_id| + * - ``use_field_ids`` + - |kwarg_use_field_ids| Return Values diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 82e22938..10a09e87 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -70,7 +70,7 @@ def field_names_to_sorting_dict(field_names: List[str]) -> List[Dict[str, str]]: "max_records": "maxRecords", "offset": "offset", "page_size": "pageSize", - "return_fields_by_field_id": "returnFieldsByFieldId", + "use_field_ids": "returnFieldsByFieldId", "sort": "sort", "time_zone": "timeZone", "user_locale": "userLocale", diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 3050bbe2..d8851564 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -195,7 +195,7 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict: cell_format: |kwarg_cell_format| time_zone: |kwarg_time_zone| user_locale: |kwarg_user_locale| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ record = self.api.get(self.record_url(record_id), options=options) return assert_typed_dict(RecordDict, record) @@ -226,7 +226,7 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: cell_format: |kwarg_cell_format| user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ if isinstance(formula := options.get("formula"), Formula): options["formula"] = to_formula_str(formula) @@ -258,7 +258,7 @@ def all(self, **options: Any) -> List[RecordDict]: cell_format: |kwarg_cell_format| user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ return [record for page in self.iterate(**options) for record in page] @@ -278,7 +278,7 @@ def first(self, **options: Any) -> Optional[RecordDict]: cell_format: |kwarg_cell_format| user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ options.update(dict(page_size=1, max_records=1)) for page in self.iterate(**options): @@ -290,7 +290,7 @@ def create( self, fields: WritableFields, typecast: bool = False, - return_fields_by_field_id: bool = False, + use_field_ids: bool = False, ) -> RecordDict: """ Create a new record @@ -302,14 +302,14 @@ def create( Args: fields: Fields to insert. Must be a dict with field names or IDs as keys. typecast: |kwarg_typecast| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ created = self.api.post( url=self.url, json={ "fields": fields, "typecast": typecast, - "returnFieldsByFieldId": return_fields_by_field_id, + "returnFieldsByFieldId": use_field_ids, }, ) return assert_typed_dict(RecordDict, created) @@ -318,7 +318,7 @@ def batch_create( self, records: Iterable[WritableFields], typecast: bool = False, - return_fields_by_field_id: bool = False, + use_field_ids: bool = False, ) -> List[RecordDict]: """ Create a number of new records in batches. @@ -340,7 +340,7 @@ def batch_create( Args: records: Iterable of dicts representing records to be created. typecast: |kwarg_typecast| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ inserted_records = [] @@ -354,7 +354,7 @@ def batch_create( json={ "records": new_records, "typecast": typecast, - "returnFieldsByFieldId": return_fields_by_field_id, + "returnFieldsByFieldId": use_field_ids, }, ) inserted_records += assert_typed_dicts(RecordDict, response["records"]) @@ -367,7 +367,7 @@ def update( fields: WritableFields, replace: bool = False, typecast: bool = False, - return_fields_by_field_id: bool = False, + use_field_ids: bool = False, ) -> RecordDict: """ Update a particular record ID with the given fields. @@ -382,7 +382,7 @@ def update( fields: Fields to update. Must be a dict with column names or IDs as keys. replace: |kwarg_replace| typecast: |kwarg_typecast| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| """ method = "put" if replace else "patch" updated = self.api.request( @@ -391,7 +391,7 @@ def update( json={ "fields": fields, "typecast": typecast, - "returnFieldsByFieldId": return_fields_by_field_id, + "returnFieldsByFieldId": use_field_ids, }, ) return assert_typed_dict(RecordDict, updated) @@ -401,7 +401,7 @@ def batch_update( records: Iterable[UpdateRecordDict], replace: bool = False, typecast: bool = False, - return_fields_by_field_id: bool = False, + use_field_ids: bool = False, ) -> List[RecordDict]: """ Update several records in batches. @@ -410,7 +410,7 @@ def batch_update( records: Records to update. replace: |kwarg_replace| typecast: |kwarg_typecast| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| Returns: The list of updated records. @@ -429,7 +429,7 @@ def batch_update( json={ "records": chunk_records, "typecast": typecast, - "returnFieldsByFieldId": return_fields_by_field_id, + "returnFieldsByFieldId": use_field_ids, }, ) updated_records += assert_typed_dicts(RecordDict, response["records"]) @@ -442,7 +442,7 @@ def batch_upsert( key_fields: List[FieldName], replace: bool = False, typecast: bool = False, - return_fields_by_field_id: bool = False, + use_field_ids: bool = False, ) -> UpsertResultDict: """ Update or create records in batches, either using ``id`` (if given) or using a set of @@ -457,7 +457,7 @@ def batch_upsert( records in the input with existing records on the server. replace: |kwarg_replace| typecast: |kwarg_typecast| - return_fields_by_field_id: |kwarg_return_fields_by_field_id| + use_field_ids: |kwarg_use_field_ids| Returns: Lists of created/updated record IDs, along with the list of all records affected. @@ -494,7 +494,7 @@ def batch_upsert( json={ "records": formatted_records, "typecast": typecast, - "returnFieldsByFieldId": return_fields_by_field_id, + "returnFieldsByFieldId": use_field_ids, "performUpsert": {"fieldsToMergeOn": key_fields}, }, ) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 23c95217..1c36e038 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -191,7 +191,7 @@ def _get_meta_request_kwargs(cls) -> Dict[str, Any]: "user_locale": None, "cell_format": "json", "time_zone": None, - "return_fields_by_field_id": cls._get_meta("use_field_ids", default=False), + "use_field_ids": cls._get_meta("use_field_ids", default=False), } @classmethod diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 084c8b71..fecb7d94 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -50,38 +50,38 @@ def test_integration_table(table, cols): assert len(records) == COUNT -def test_return_fields_by_field_id(table: Table, cols): +def test_use_field_ids(table: Table, cols): """ Test that we can get, create, and update records by field ID vs. name. See https://github.com/gtalarico/pyairtable/issues/194 """ - # Create one record with return_fields_by_field_id=True - record = table.create({cols.TEXT_ID: "Hello"}, return_fields_by_field_id=True) + # Create one record with use_field_ids=True + record = table.create({cols.TEXT_ID: "Hello"}, use_field_ids=True) assert record["fields"][cols.TEXT_ID] == "Hello" - # Update one record with return_fields_by_field_id=True + # Update one record with use_field_ids=True updated = table.update( record["id"], {cols.TEXT_ID: "Goodbye"}, - return_fields_by_field_id=True, + use_field_ids=True, ) assert updated["fields"][cols.TEXT_ID] == "Goodbye" - # Create multiple records with return_fields_by_field_id=True + # Create multiple records with use_field_ids=True records = table.batch_create( [ {cols.TEXT_ID: "Alpha"}, {cols.TEXT_ID: "Bravo"}, {cols.TEXT_ID: "Charlie"}, ], - return_fields_by_field_id=True, + use_field_ids=True, ) assert records[0]["fields"][cols.TEXT_ID] == "Alpha" assert records[1]["fields"][cols.TEXT_ID] == "Bravo" assert records[2]["fields"][cols.TEXT_ID] == "Charlie" - # Update multiple records with return_fields_by_field_id=True + # Update multiple records with use_field_ids=True updates = [ dict( record, @@ -89,7 +89,7 @@ def test_return_fields_by_field_id(table: Table, cols): ) for record in records ] - updated = table.batch_update(updates, return_fields_by_field_id=True) + updated = table.batch_update(updates, use_field_ids=True) assert updated[0]["fields"][cols.TEXT_ID] == "Hello, Alpha" assert updated[1]["fields"][cols.TEXT_ID] == "Hello, Bravo" assert updated[2]["fields"][cols.TEXT_ID] == "Hello, Charlie" @@ -112,7 +112,7 @@ def test_get_records_options(table: Table, cols): assert table.all(sort=[cols.TEXT, cols.NUM]) == [rec] assert table.all(time_zone="utc") == [rec] assert table.all(user_locale="en-ie") == [rec] - assert table.all(return_fields_by_field_id=True) == [ + assert table.all(use_field_ids=True) == [ { "id": rec["id"], "createdTime": rec["createdTime"], @@ -132,7 +132,7 @@ def test_get_records_options(table: Table, cols): assert table.all(formula=formula, sort=[cols.TEXT, cols.NUM]) == [rec] assert table.all(formula=formula, time_zone="utc") == [rec] assert table.all(formula=formula, user_locale="en-ie") == [rec] - assert table.all(formula=formula, return_fields_by_field_id=True) == [ + assert table.all(formula=formula, use_field_ids=True) == [ { "id": rec["id"], "createdTime": rec["createdTime"], @@ -207,11 +207,11 @@ def test_batch_upsert(table: Table, cols): assert result["records"][2]["fields"] == {cols.TEXT: "Three", cols.NUM: 6} assert result["records"][3]["fields"] == {cols.NUM: 7} - # Test that batch_upsert passes along return_fields_by_field_id + # Test that batch_upsert passes along use_field_ids result = table.batch_upsert( [{"fields": {cols.TEXT: "Two", cols.NUM: 8}}], key_fields=[cols.TEXT], - return_fields_by_field_id=True, + use_field_ids=True, ) assert result["records"] == [ { diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 4117c430..81e84e2f 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -225,7 +225,7 @@ def test_passthrough(methodname, returns): a=1, b=2, c=3, - return_fields_by_field_id=getattr(FakeModel.Meta, "use_field_ids", False), + use_field_ids=getattr(FakeModel.Meta, "use_field_ids", False), user_locale=None, time_zone=None, cell_format="json", diff --git a/tests/test_params.py b/tests/test_params.py index bdf4e913..89b00aa7 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -17,7 +17,7 @@ def test_params_integration(table, mock_records, mock_response_iterator): "view": "View", "sort": ["Name"], "fields": ["Name", "Age"], - "return_fields_by_field_id": True, + "use_field_ids": True, } with Mocker() as m: url_params = ( @@ -100,9 +100,9 @@ def test_params_integration(table, mock_records, mock_response_iterator): "?timeZone=America%2FChicago", # '?timeZone=America/Chicago' ], - ["return_fields_by_field_id", True, "?returnFieldsByFieldId=1"], - ["return_fields_by_field_id", 1, "?returnFieldsByFieldId=1"], - ["return_fields_by_field_id", False, "?returnFieldsByFieldId=0"], + ["use_field_ids", True, "?returnFieldsByFieldId=1"], + ["use_field_ids", 1, "?returnFieldsByFieldId=1"], + ["use_field_ids", False, "?returnFieldsByFieldId=0"], # TODO # [ # {"sort": [("Name", "desc"), ("Phone", "asc")]}, @@ -163,9 +163,9 @@ def test_convert_options_to_params(option, value, url_params): }, ], ["cell_format", "string", {"cellFormat": "string"}], - ["return_fields_by_field_id", True, {"returnFieldsByFieldId": True}], - ["return_fields_by_field_id", 1, {"returnFieldsByFieldId": True}], - ["return_fields_by_field_id", False, {"returnFieldsByFieldId": False}], + ["use_field_ids", True, {"returnFieldsByFieldId": True}], + ["use_field_ids", 1, {"returnFieldsByFieldId": True}], + ["use_field_ids", False, {"returnFieldsByFieldId": False}], # userLocale and timeZone are not supported via POST, so they return "spare params" ["user_locale", "en-US", ({}, {"userLocale": "en-US"})], ["time_zone", "America/Chicago", ({}, {"timeZone": "America/Chicago"})], From d395ca729fecc89bd0e64f0b380bbb6e4036f3de Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 13 Mar 2024 22:00:07 -0700 Subject: [PATCH 127/272] Required*Field classes raise exception if a value is empty --- .pre-commit-config.yaml | 2 +- docs/source/orm.rst | 30 ++- pyairtable/orm/fields.py | 440 +++++++++++++++++++++++++++++++++++---- tests/test_orm_fields.py | 188 +++++++++++++++-- tests/test_typing.py | 44 +++- 5 files changed, 634 insertions(+), 70 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4729eaef..950dcf7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: name: cog language: python additional_dependencies: [cogapp] - entry: python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]" + entry: 'python -m cogapp -cr --verbosity=1 --markers="[[[cog]]] [[[out]]] [[[end]]]"' files: \.py$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 56b494a1..211b8a33 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -183,6 +183,34 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.RatingField` - `Rating `__ + * - :class:`~pyairtable.orm.fields.RequiredAITextField` ๐Ÿ”’ + - `AI Text `__ + * - :class:`~pyairtable.orm.fields.RequiredBarcodeField` + - `Barcode `__ + * - :class:`~pyairtable.orm.fields.RequiredCollaboratorField` + - `Collaborator `__ + * - :class:`~pyairtable.orm.fields.RequiredCountField` ๐Ÿ”’ + - `Count `__ + * - :class:`~pyairtable.orm.fields.RequiredCurrencyField` + - `Currency `__ + * - :class:`~pyairtable.orm.fields.RequiredDateField` + - `Date `__ + * - :class:`~pyairtable.orm.fields.RequiredDatetimeField` + - `Date and time `__ + * - :class:`~pyairtable.orm.fields.RequiredDurationField` + - `Duration `__ + * - :class:`~pyairtable.orm.fields.RequiredFloatField` + - `Number `__ + * - :class:`~pyairtable.orm.fields.RequiredIntegerField` + - `Number `__ + * - :class:`~pyairtable.orm.fields.RequiredNumberField` + - `Number `__ + * - :class:`~pyairtable.orm.fields.RequiredPercentField` + - `Percent `__ + * - :class:`~pyairtable.orm.fields.RequiredRatingField` + - `Rating `__ + * - :class:`~pyairtable.orm.fields.RequiredSelectField` + - `Single select `__ * - :class:`~pyairtable.orm.fields.RichTextField` - `Rich text `__ * - :class:`~pyairtable.orm.fields.SelectField` @@ -193,7 +221,7 @@ read `Field types and cell values `__, `Long text `__ * - :class:`~pyairtable.orm.fields.UrlField` - `Url `__ -.. [[[end]]] (checksum: afd0edeabb06937f2a3afd73a7bac32e) +.. [[[end]]] Formula, Rollup, and Lookup Fields diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 2e0744e9..f44764fa 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -276,8 +276,45 @@ def _missing_value(cls) -> T: return cast(T, first_type()) +class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, None]): + """ + A mix-in for a Field class which indicates two things: + + 1. It should never receive a null value from the Airtable API. + 2. It should never allow other code to set it as None (or the empty string). + + If either of those conditions occur, the field will raise an exception. + """ + + @overload + def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... + + @overload + def __get__(self, instance: "Model", owner: Type[Any]) -> T_ORM: ... + + def __get__( + self, instance: Optional["Model"], owner: Type[Any] + ) -> Union[SelfType, T_ORM]: + value = super().__get__(instance, owner) + if value is None or value == "": + raise MissingValue(f"{self._description} received an empty value") + return value + + def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: + if value in (None, ""): + raise MissingValue(f"{self._description} does not accept empty values") + super().__set__(instance, value) + + +class MissingValue(ValueError): + """ + A required field received an empty value, either from Airtable or other code. + """ + + #: A generic Field with internal and API representations that are the same type. _BasicField: TypeAlias = Field[T, T, None] +_BasicFieldWithRequiredValue: TypeAlias = _FieldWithRequiredValue[T, T] #: An alias for any type of Field. @@ -853,16 +890,6 @@ class AttachmentsField(_ValidatingListField[AttachmentDict]): contains_type = cast(Type[AttachmentDict], dict) -class AutoNumberField(IntegerField): - """ - Equivalent to :class:`IntegerField(readonly=True) `. - - See `Auto number `__. - """ - - readonly = True - - class BarcodeField(_DictField[BarcodeDict]): """ Accepts a `dict` that should conform to the format detailed in the @@ -871,16 +898,6 @@ class BarcodeField(_DictField[BarcodeDict]): """ -class ButtonField(_DictField[ButtonDict]): - """ - Read-only field that returns a `dict`. For more information, read the - `Button `_ - documentation. - """ - - readonly = True - - class CollaboratorField(_DictField[CollaboratorDict]): """ Accepts a `dict` that should conform to the format detailed in the @@ -899,26 +916,6 @@ class CountField(IntegerField): readonly = True -class CreatedByField(CollaboratorField): - """ - Equivalent to :class:`CollaboratorField(readonly=True) `. - - See `Created by `__. - """ - - readonly = True - - -class CreatedTimeField(DatetimeField): - """ - Equivalent to :class:`DatetimeField(readonly=True) `. - - See `Created time `__. - """ - - readonly = True - - class CurrencyField(NumberField): """ Equivalent to :class:`~NumberField`. @@ -1049,6 +1046,348 @@ class UrlField(TextField): """ +# Auto-generate Required*Field classes for anything above this line +# fmt: off +r"""[[[cog]]] +import re +from collections import namedtuple + +with open(cog.inFile) as fp: + src = "".join(fp.readlines()[:cog.firstLineNum]) + +Match = namedtuple('Match', 'cls generic bases annotation doc readonly') +expr = ( + r'(?ms)' + r'class ([A-Z]\w+Field)' + r'\(' + # This particular regex will not pick up Field subclasses that have + # multiple inheritance, which excludes anything using _NotNullField. + r'(?:(Generic\[.+?\]), )?([_A-Z][_A-Za-z]+)(?:\[(.+?)\])?' + r'\):\n' + r' \"\"\"\n (.+?) \"\"\"(?:\n| (?!readonly =)[^\n]*)*' + r'( readonly = True)?' +) +classes = { + match.cls: match + for group in re.findall(expr, src) + if (match := Match(*group)) +} + +for cls, match in sorted(classes.items()): + if cls in { + # checkbox values are either `True` or missing + "CheckboxField", + + # null value will be converted to an empty list + "AttachmentsField", + "LinkField", + "LookupField", + "MultipleCollaboratorsField", + "MultipleSelectField", + + # unsupported + "SingleLinkField", + + # illogical + "LastModifiedByField", + "LastModifiedTimeField", + "ExternalSyncSourceField", + }: + continue + + # skip if we've already included Required + if cls.startswith("Required") or "Required" in match.bases: + continue + + base, typn = match.cls, match.annotation + typn_bases = match.bases + while not typn: + typn = classes[typn_bases].annotation + typn_bases = classes[typn_bases].bases + + if typn.endswith(", None"): + typn = typn[:-len(", None")] + + mixin = ("_" if re.match(r"^[A-Za-z_.]+(\[\w+(, \w+)*\])?,", typn) else "_Basic") + "FieldWithRequiredValue" + base = base if not match.generic else f"{base}, {match.generic}" + cog.outl(f"\n\nclass Required{cls}({base}, {mixin}[{typn}]):") + cog.outl(' \"\"\"') + cog.outl(' ' + match.doc) + cog.out( ' If the Airtable API returns ``null``, ') + if not match.readonly: + cog.out('or if a caller sets this field to ``None``,\n ') + cog.outl('this field raises :class:`~pyairtable.orm.fields.MissingValue`.') + cog.outl(' \"\"\"') + +cog.outl('\n') +[[[out]]]""" + + +class RequiredAITextField(AITextField, _BasicFieldWithRequiredValue[AITextDict]): + """ + Read-only field that returns a `dict`. For more information, read the + `AI Text `_ + documentation. + + If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredBarcodeField(BarcodeField, _BasicFieldWithRequiredValue[BarcodeDict]): + """ + Accepts a `dict` that should conform to the format detailed in the + `Barcode `_ + documentation. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[CollaboratorDict]): + """ + Accepts a `dict` that should conform to the format detailed in the + `Collaborator `_ + documentation. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredCountField(CountField, _BasicFieldWithRequiredValue[int]): + """ + Equivalent to :class:`IntegerField(readonly=True) `. + + See `Count `__. + + If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredCurrencyField(CurrencyField, _BasicFieldWithRequiredValue[Union[int, float]]): + """ + Equivalent to :class:`~NumberField`. + + See `Currency `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredDateField(DateField, _FieldWithRequiredValue[str, date]): + """ + Date field. Accepts only `date `_ values. + + See `Date `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredDatetimeField(DatetimeField, _FieldWithRequiredValue[str, datetime]): + """ + DateTime field. Accepts only `datetime `_ values. + + See `Date and time `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredDurationField(DurationField, _FieldWithRequiredValue[int, timedelta]): + """ + Duration field. Accepts only `timedelta `_ values. + + See `Duration `__. + Airtable's API returns this as a number of seconds. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredEmailField(EmailField, _BasicFieldWithRequiredValue[str]): + """ + Equivalent to :class:`~TextField`. + + See `Email `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredFloatField(FloatField, _BasicFieldWithRequiredValue[float]): + """ + Number field with decimal precision. Accepts only ``float`` values. + + See `Number `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredIntegerField(IntegerField, _BasicFieldWithRequiredValue[int]): + """ + Number field with integer precision. Accepts only ``int`` values. + + See `Number `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredNumberField(NumberField, _BasicFieldWithRequiredValue[Union[int, float]]): + """ + Number field with unspecified precision. Accepts either ``int`` or ``float``. + + See `Number `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredPercentField(PercentField, _BasicFieldWithRequiredValue[Union[int, float]]): + """ + Equivalent to :class:`~NumberField`. + + See `Percent `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredPhoneNumberField(PhoneNumberField, _BasicFieldWithRequiredValue[str]): + """ + Equivalent to :class:`~TextField`. + + See `Phone `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredRatingField(RatingField, _BasicFieldWithRequiredValue[int]): + """ + Accepts ``int`` values that are greater than zero. + + See `Rating `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredRichTextField(RichTextField, _BasicFieldWithRequiredValue[str]): + """ + Equivalent to :class:`~TextField`. + + See `Rich text `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredSelectField(SelectField, _FieldWithRequiredValue[str, str]): + """ + Represents a single select dropdown field. This will return ``None`` if no value is set, + and will only return ``""`` if an empty dropdown option is available and selected. + + See `Single select `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredTextField(TextField, _BasicFieldWithRequiredValue[str]): + """ + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + + See `Single line text `__ + and `Long text `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredUrlField(UrlField, _BasicFieldWithRequiredValue[str]): + """ + Equivalent to :class:`~TextField`. + + See `Url `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +# [[[end]]] (checksum: 84b5c48286d992737e12318a72e4e123) +# fmt: on + + +class AutoNumberField(RequiredIntegerField): + """ + Equivalent to :class:`IntegerField(readonly=True) `. + + See `Auto number `__. + + If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. + """ + + readonly = True + + +class ButtonField(_DictField[ButtonDict], _BasicFieldWithRequiredValue[ButtonDict]): + """ + Read-only field that returns a `dict`. For more information, read the + `Button `_ + documentation. + + If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. + """ + + readonly = True + + +class CreatedByField(RequiredCollaboratorField): + """ + Equivalent to :class:`CollaboratorField(readonly=True) `. + + See `Created by `__. + + If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. + """ + + readonly = True + + +class CreatedTimeField(RequiredDatetimeField): + """ + Equivalent to :class:`DatetimeField(readonly=True) `. + + See `Created time `__. + + If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. + + If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. + """ + + readonly = True + + #: Set of all Field subclasses exposed by the library. #: #: :meta hide-value: @@ -1175,6 +1514,25 @@ class UrlField(TextField): "PercentField", "PhoneNumberField", "RatingField", + "RequiredAITextField", + "RequiredBarcodeField", + "RequiredCollaboratorField", + "RequiredCountField", + "RequiredCurrencyField", + "RequiredDateField", + "RequiredDatetimeField", + "RequiredDurationField", + "RequiredEmailField", + "RequiredFloatField", + "RequiredIntegerField", + "RequiredNumberField", + "RequiredPercentField", + "RequiredPhoneNumberField", + "RequiredRatingField", + "RequiredRichTextField", + "RequiredSelectField", + "RequiredTextField", + "RequiredUrlField", "RichTextField", "SelectField", "SingleLinkField", @@ -1186,7 +1544,7 @@ class UrlField(TextField): "FIELD_CLASSES_TO_TYPES", "LinkSelf", ] -# [[[end]]] (checksum: 314db7bbb782c156d620305a1c42dfef) +# [[[end]]] (checksum: 21316c688401f32f59d597c496d48bf3) # Delayed import to avoid circular dependency diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 14516643..33cea7c6 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -117,7 +117,10 @@ class T(Model): # Mapping from types to a test value for that type. TYPE_VALIDATION_TEST_VALUES = { - **{t: t() for t in (str, bool, list, dict)}, + str: "some value", + bool: False, + list: [], + dict: {}, int: 1, # cannot use int() because RatingField requires value >= 1 float: 1.0, # cannot use float() because RatingField requires value >= 1 datetime.date: datetime.date.today(), @@ -130,28 +133,45 @@ class T(Model): "test_case", [ (f.Field, tuple(TYPE_VALIDATION_TEST_VALUES)), - (f.TextField, str), - (f.IntegerField, int), - (f.RichTextField, str), - (f.DatetimeField, datetime.datetime), - (f.TextField, str), - (f.CheckboxField, bool), + (f.AttachmentsField, list), (f.BarcodeField, dict), - (f.NumberField, (int, float)), - (f.PhoneNumberField, str), + (f.CheckboxField, bool), + (f.CollaboratorField, dict), + (f.CurrencyField, (int, float)), + (f.DateField, (datetime.date, datetime.datetime)), + (f.DatetimeField, datetime.datetime), (f.DurationField, datetime.timedelta), - (f.RatingField, int), - (f.UrlField, str), + (f.EmailField, str), + (f.FloatField, float), + (f.IntegerField, int), + (f.MultipleCollaboratorsField, list), (f.MultipleSelectField, list), + (f.NumberField, (int, float)), (f.PercentField, (int, float)), - (f.DateField, (datetime.date, datetime.datetime)), - (f.FloatField, float), - (f.CollaboratorField, dict), + (f.PhoneNumberField, str), + (f.RatingField, int), + (f.RichTextField, str), (f.SelectField, str), - (f.EmailField, str), - (f.AttachmentsField, list), - (f.MultipleCollaboratorsField, list), - (f.CurrencyField, (int, float)), + (f.TextField, str), + (f.TextField, str), + (f.UrlField, str), + (f.RequiredBarcodeField, dict), + (f.RequiredCollaboratorField, dict), + (f.RequiredCurrencyField, (int, float)), + (f.RequiredDateField, (datetime.date, datetime.datetime)), + (f.RequiredDatetimeField, datetime.datetime), + (f.RequiredDurationField, datetime.timedelta), + (f.RequiredFloatField, float), + (f.RequiredIntegerField, int), + (f.RequiredNumberField, (int, float)), + (f.RequiredPercentField, (int, float)), + (f.RequiredRatingField, int), + (f.RequiredSelectField, str), + (f.RequiredEmailField, str), + (f.RequiredPhoneNumberField, str), + (f.RequiredRichTextField, str), + (f.RequiredTextField, str), + (f.RequiredUrlField, str), ], ids=operator.itemgetter(0), ) @@ -242,6 +262,9 @@ class Container(Model): # 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), + # We also want to test the not-null versions of these fields + (f.RequiredAITextField, {"state": "empty", "isStale": True, "value": None}), + (f.RequiredCountField, 1), ], ids=operator.itemgetter(0), ) @@ -290,12 +313,31 @@ class T(Model): (f.PercentField, 0.5), (f.PhoneNumberField, "+49 40-349180"), (f.RichTextField, "Check out [Airtable](www.airtable.com)"), + (f.SelectField, ""), (f.SelectField, "any value"), (f.UrlField, "www.airtable.com"), + (f.RequiredNumberField, 1), + (f.RequiredNumberField, 1.5), + (f.RequiredIntegerField, 1), + (f.RequiredFloatField, 1.5), + (f.RequiredRatingField, 1), + (f.RequiredCurrencyField, 1.05), + (f.RequiredCollaboratorField, {"id": "usrFakeUserId", "email": "x@y.com"}), + (f.RequiredBarcodeField, {"type": "upce", "text": "084114125538"}), + (f.RequiredPercentField, 0.5), + (f.RequiredSelectField, "any value"), + (f.RequiredEmailField, "any value"), + (f.RequiredPhoneNumberField, "any value"), + (f.RequiredRichTextField, "any value"), + (f.RequiredTextField, "any value"), + (f.RequiredUrlField, "any value"), # If a 3-tuple, we should be able to convert API -> ORM values. (f.DateField, DATE_S, DATE_V), - (f.DurationField, 100.5, datetime.timedelta(seconds=100, microseconds=500000)), (f.DatetimeField, DATETIME_S, DATETIME_V), + (f.DurationField, 100.5, datetime.timedelta(seconds=100, microseconds=500000)), + (f.RequiredDateField, DATE_S, DATE_V), + (f.RequiredDatetimeField, DATETIME_S, DATETIME_V), + (f.RequiredDurationField, 100, datetime.timedelta(seconds=100)), ], ids=operator.itemgetter(0), ) @@ -327,20 +369,120 @@ class T(Model): assert existing_obj.the_field == orm_value +@pytest.mark.parametrize( + "field_type", + [ + f.Field, + f.AITextField, + f.AttachmentsField, + f.BarcodeField, + f.CheckboxField, + f.CollaboratorField, + f.CountField, + f.CurrencyField, + f.DateField, + f.DatetimeField, + f.DurationField, + f.EmailField, + f.ExternalSyncSourceField, + f.FloatField, + f.IntegerField, + f.LastModifiedByField, + f.LastModifiedTimeField, + f.LookupField, + f.MultipleCollaboratorsField, + f.MultipleSelectField, + f.NumberField, + f.NumberField, + f.PercentField, + f.PhoneNumberField, + f.RatingField, + f.RichTextField, + f.SelectField, + f.TextField, + f.UrlField, + ], +) +def test_accepts_null(field_type): + """ + Test field types that allow null values from Airtable. + """ + + class T(Model): + Meta = fake_meta() + the_field = field_type("Field Name") + + obj = T() + assert not obj.the_field + + +@pytest.mark.parametrize( + "field_type", + [ + f.AutoNumberField, + f.ButtonField, + f.CreatedByField, + f.CreatedTimeField, + f.RequiredAITextField, + f.RequiredBarcodeField, + f.RequiredCollaboratorField, + f.RequiredCountField, + f.RequiredCurrencyField, + f.RequiredDateField, + f.RequiredDatetimeField, + f.RequiredDurationField, + f.RequiredEmailField, + f.RequiredFloatField, + f.RequiredIntegerField, + f.RequiredNumberField, + f.RequiredPercentField, + f.RequiredPhoneNumberField, + f.RequiredRatingField, + f.RequiredRichTextField, + f.RequiredSelectField, + f.RequiredTextField, + f.RequiredUrlField, + ], +) +def test_rejects_null(field_type): + """ + Test field types that do not allow null values from Airtable. + """ + + class T(Model): + Meta = fake_meta() + the_field = field_type("Field Name") + + obj = T() + with pytest.raises(f.MissingValue): + obj.the_field + with pytest.raises(f.MissingValue): + obj.the_field = None + with pytest.raises(f.MissingValue): + T(the_field=None) + + def test_completeness(): """ Ensure that we test conversion of all readonly and writable fields. """ - assert_all_fields_tested_by(test_writable_fields, test_readonly_fields) + assert_all_fields_tested_by( + test_writable_fields, + test_readonly_fields, + exclude=(f.LinkField, f.SingleLinkField), + ) assert_all_fields_tested_by( test_type_validation, exclude=f.READONLY_FIELDS | {f.LinkField, f.SingleLinkField}, ) + assert_all_fields_tested_by( + test_accepts_null, + test_rejects_null, + exclude={f.LinkField, f.SingleLinkField}, + ) -def assert_all_fields_tested_by( - *test_fns, exclude=(f.Field, f.LinkField, f.SingleLinkField) -): +def assert_all_fields_tested_by(*test_fns, exclude=()): """ Allows meta-tests that fail if any new Field classes appear in pyairtable.orm.fields which are not covered by one of a few basic tests. This is intended to help remind diff --git a/tests/test_typing.py b/tests/test_typing.py index 7c49db00..2078d5f3 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -118,18 +118,36 @@ class EveryField(orm.Model): rich_text = orm.fields.RichTextField("Notes") select = orm.fields.SelectField("Status") url = orm.fields.UrlField("URL") + required_aitext = orm.fields.RequiredAITextField("AI Generated Text") + required_barcode = orm.fields.RequiredBarcodeField("Barcode") + required_collaborator = orm.fields.RequiredCollaboratorField("Assignee") + required_count = orm.fields.RequiredCountField("Count") + required_currency = orm.fields.RequiredCurrencyField("Dollars") + required_date = orm.fields.RequiredDateField("Date") + required_datetime = orm.fields.RequiredDatetimeField("DateTime") + required_duration = orm.fields.RequiredDurationField("Duration (h:mm)") + required_email = orm.fields.RequiredEmailField("Email") + required_float = orm.fields.RequiredFloatField("Decimal 1") + required_integer = orm.fields.RequiredIntegerField("Integer") + required_number = orm.fields.RequiredNumberField("Number") + required_percent = orm.fields.RequiredPercentField("Percent") + required_phone = orm.fields.RequiredPhoneNumberField("Phone") + required_rating = orm.fields.RequiredRatingField("Stars") + required_rich_text = orm.fields.RequiredRichTextField("Notes") + required_select = orm.fields.RequiredSelectField("Status") + required_url = orm.fields.RequiredUrlField("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.autonumber, int) assert_type(record.barcode, Optional[T.BarcodeDict]) - assert_type(record.button, Optional[T.ButtonDict]) + assert_type(record.button, T.ButtonDict) assert_type(record.checkbox, bool) assert_type(record.collaborator, Optional[T.CollaboratorDict]) assert_type(record.count, Optional[int]) - assert_type(record.created_by, Optional[T.CollaboratorDict]) - assert_type(record.created, Optional[datetime.datetime]) + assert_type(record.created_by, T.CollaboratorDict) + assert_type(record.created, datetime.datetime) assert_type(record.currency, Optional[Union[int, float]]) assert_type(record.date, Optional[datetime.date]) assert_type(record.datetime, Optional[datetime.datetime]) @@ -148,3 +166,21 @@ class EveryField(orm.Model): assert_type(record.rich_text, str) assert_type(record.select, Optional[str]) 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_count, int) + assert_type(record.required_currency, Union[int, float]) + assert_type(record.required_date, datetime.date) + assert_type(record.required_datetime, datetime.datetime) + assert_type(record.required_duration, datetime.timedelta) + assert_type(record.required_email, str) + assert_type(record.required_float, float) + assert_type(record.required_integer, int) + assert_type(record.required_number, Union[int, float]) + assert_type(record.required_percent, Union[int, float]) + assert_type(record.required_phone, str) + assert_type(record.required_rating, int) + assert_type(record.required_rich_text, str) + assert_type(record.required_select, str) + assert_type(record.required_url, str) From 9948c80db2973fdb2388d70e417d9e254ba51bc4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 1 Apr 2024 16:14:08 -0700 Subject: [PATCH 128/272] Simplify missing_value implementation --- pyairtable/orm/fields.py | 58 +++++++++++++++------------------------- tests/test_orm.py | 2 +- tests/test_orm_fields.py | 52 +++-------------------------------- 3 files changed, 27 insertions(+), 85 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index f44764fa..56c34aaf 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -89,6 +89,9 @@ class Field(Generic[T_API, T_ORM, T_Missing], metaclass=abc.ABCMeta): #: Types that are allowed to be passed to this field. valid_types: ClassVar[_ClassInfo] = () + #: The value to return when the field is missing + missing_value: ClassVar[Any] = None + #: Whether to allow modification of the value in this field. readonly: bool = False @@ -163,9 +166,12 @@ def __get__( if not instance: return self try: - return cast(T_ORM, instance._fields[self.field_name]) + value = instance._fields[self.field_name] except (KeyError, AttributeError): - return self._missing_value() + return cast(T_Missing, self.missing_value) + if value is None: + return cast(T_Missing, self.missing_value) + return cast(T_ORM, value) def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: self._raise_if_readonly() @@ -178,13 +184,6 @@ def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: def __delete__(self, instance: "Model") -> None: raise AttributeError(f"cannot delete {self._description}") - @classmethod - def _missing_value(cls) -> T_Missing: - # This assumes Field[T_API, T_ORM, None]. If a subclass defines T_Missing as - # something different, it needs to override _missing_value. - # This can be tidied in 3.13 with T_Missing(default=None). See PEP-696. - return cast(T_Missing, None) - def to_record_value(self, value: Any) -> Any: """ Calculate the value which should be persisted to the API. @@ -258,25 +257,7 @@ def lte(self, value: Any) -> "formulas.Comparison": return formulas.LTE(self, value) -class _FieldWithTypedDefaultValue(Generic[T], Field[T, T, T]): - """ - A generic Field with default value of the same type as internal and API representations. - - For now this is used for TextField and CheckboxField, because Airtable stores the empty - values for those types ("" and False) internally as None. - """ - - @classmethod - def _missing_value(cls) -> T: - first_type = cls.valid_types - while isinstance(first_type, tuple): - if not first_type: - raise RuntimeError(f"{cls.__qualname__}.valid_types is malformed") - first_type = first_type[0] - return cast(T, first_type()) - - -class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, None]): +class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, T_ORM]): """ A mix-in for a Field class which indicates two things: @@ -314,6 +295,7 @@ class MissingValue(ValueError): #: A generic Field with internal and API representations that are the same type. _BasicField: TypeAlias = Field[T, T, None] +_BasicFieldWithMissingValue: TypeAlias = Field[T, T, T] _BasicFieldWithRequiredValue: TypeAlias = _FieldWithRequiredValue[T, T] @@ -321,7 +303,7 @@ class MissingValue(ValueError): AnyField: TypeAlias = Field[Any, Any, Any] -class TextField(_FieldWithTypedDefaultValue[str]): +class TextField(_BasicFieldWithMissingValue[str]): """ Accepts ``str``. Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. @@ -330,6 +312,7 @@ class TextField(_FieldWithTypedDefaultValue[str]): and `Long text `__. """ + missing_value = "" valid_types = str @@ -394,7 +377,7 @@ def valid_or_raise(self, value: int) -> None: raise ValueError("rating cannot be below 1") -class CheckboxField(_FieldWithTypedDefaultValue[bool]): +class CheckboxField(_BasicFieldWithMissingValue[bool]): """ Accepts ``bool``. Returns ``False`` instead of ``None`` if the field is empty on the Airtable base. @@ -402,6 +385,7 @@ class CheckboxField(_FieldWithTypedDefaultValue[bool]): See `Checkbox `__. """ + missing_value = False valid_types = bool @@ -836,7 +820,7 @@ def __get__( try: return links[0] except IndexError: - return self._missing_value() + return None def __set__(self, instance: "Model", value: Optional[T_Linked]) -> None: values = None if value is None else [value] @@ -1055,16 +1039,18 @@ class UrlField(TextField): with open(cog.inFile) as fp: src = "".join(fp.readlines()[:cog.firstLineNum]) -Match = namedtuple('Match', 'cls generic bases annotation doc readonly') +Match = namedtuple('Match', 'cls generic bases annotation cls_kwargs doc readonly') expr = ( - r'(?ms)' - r'class ([A-Z]\w+Field)' + r'(?m)' + r'^class ([A-Z]\w+Field)' r'\(' # This particular regex will not pick up Field subclasses that have # multiple inheritance, which excludes anything using _NotNullField. - r'(?:(Generic\[.+?\]), )?([_A-Z][_A-Za-z]+)(?:\[(.+?)\])?' + r'(?:(Generic\[.+?\]), )?' + r'([_A-Z][_A-Za-z]+)(?:\[(.+?)\])?' + r'((?:, [a-z_]+=.+)+)?' r'\):\n' - r' \"\"\"\n (.+?) \"\"\"(?:\n| (?!readonly =)[^\n]*)*' + r' \"\"\"\n ((?:.|\n)+?) \"\"\"(?:\n| (?!readonly =).*)*' r'( readonly = True)?' ) classes = { diff --git a/tests/test_orm.py b/tests/test_orm.py index 60178fc1..1a4e2caf 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -86,7 +86,7 @@ def test_null_fields(): """ a = Address(number=None, street=None) assert a.number is None - assert a.street is None + assert a.street == "" def test_first(): diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 33cea7c6..76aeeea6 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -95,6 +95,7 @@ def test_repr(instance, expected): argvalues=[ (f.Field, None), (f.CheckboxField, False), + (f.TextField, ""), (f.LookupField, []), (f.AttachmentsField, []), (f.MultipleCollaboratorsField, []), @@ -114,6 +115,9 @@ class T(Model): t = T() assert t.the_field == default_value + t = T.from_record(fake_record({"Field Name": None})) + assert t.the_field == default_value + # Mapping from types to a test value for that type. TYPE_VALIDATION_TEST_VALUES = { @@ -984,54 +988,6 @@ def patch_callback(request, context): assert m.last_request.json()["fields"]["dt"] == "2024-03-01T11:22:33.000" -@pytest.mark.parametrize( - "classinfo,expected", - [ - (str, ""), - ((str, bool), ""), - ((((str,),),), ""), - (bool, False), - ], -) -def test_missing_value(classinfo, expected): - """ - Test that _FieldWithTypedDefaultValue._missing_value finds the first - valid type and calls it to create the "missing from Airtable" value. - """ - - class F(f._FieldWithTypedDefaultValue): - valid_types = classinfo - - class T: - the_field = F("Field Name") - - assert T().the_field == expected - - -@pytest.mark.parametrize( - "classinfo,exc_class", - [ - ((), RuntimeError), - ((((), str), bool), RuntimeError), - ], -) -def test_missing_value__invalid_classinfo(classinfo, exc_class): - """ - Test that _FieldWithTypedDefaultValue._missing_value raises an exception - if the class's valid_types is set to an invalid value. - """ - - class F(f._FieldWithTypedDefaultValue): - valid_types = classinfo - - class T: - the_field = F("Field Name") - - obj = T() - with pytest.raises(exc_class): - obj.the_field - - @pytest.mark.parametrize( "fields,expected", [ From e969df64b00de0459c0b646d88078fb648e7a396 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 1 Apr 2024 16:49:57 -0700 Subject: [PATCH 129/272] Documentation for Required*Field --- docs/source/changelog.rst | 14 +++-- docs/source/orm.rst | 127 ++++++++++++++++++++++++++++++++------ pyairtable/orm/fields.py | 2 - 3 files changed, 115 insertions(+), 28 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 13d93b3c..42e9cda6 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -5,24 +5,26 @@ Changelog 3.0 (TBD) ------------------------ -* Rewrite of :mod:`pyairtable.formulas` module. +* Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. - `PR #329 `_. -* ORM fields :class:`~pyairtable.orm.fields.TextField` and - :class:`~pyairtable.orm.fields.CheckboxField` will no longer - return ``None`` when the field is empty. +* :class:`~pyairtable.orm.fields.TextField` and + :class:`~pyairtable.orm.fields.CheckboxField` return ``""`` + or ``False`` instead of ``None``. - `PR #347 `_. * Changed the type of :data:`~pyairtable.orm.Model.created_time` from ``str`` to ``datetime``, along with all other timestamp fields used in :ref:`API: pyairtable.models`. - `PR #352 `_. * Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField` - for record link fields that (generally) link to a single record. + for record links that should only contain one record. - `PR #354 `_. -* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. * Support ``use_field_ids`` in the :ref:`ORM`. - `PR #355 `_. * Removed the ``pyairtable.metadata`` module. - `PR #360 `_. +* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. + - `PR #362 `_. +* Added ORM fields that :ref:`require a non-null value `. 2.3.2 (2024-03-18) ------------------------ diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 211b8a33..c2313a78 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -109,17 +109,31 @@ read `Field types and cell values `", cls.__doc__ or "") - ro = ' ๐Ÿ”’' if cls.readonly else '' - cog.outl(f" * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}") - cog.outl(f" - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}") + def cog_class_table(classes): + cog.outl(".. list-table::") + cog.outl(" :header-rows: 1\n") + cog.outl(" * - ORM field class") + cog.outl(" - Airtable field type(s)") + for cls in classes: + links = re.findall(r"`.+? <.*?field-model.*?>`", cls.__doc__ or "") + ro = ' ๐Ÿ”’' if cls.readonly else '' + cog.outl(f" * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}") + cog.outl(f" - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}") + + classes = sorted(fields.ALL_FIELDS, key=attrgetter("__name__")) + optional = [cls for cls in classes if not cls.__name__.startswith("Required")] + required = [cls for cls in classes if cls.__name__.startswith("Required")] + + cog.outl("..") # terminate the comment block + cog_class_table(optional) + cog.outl("") + cog.outl("Airtable does not have a concept of fields that require values,") + cog.outl("but pyAirtable allows you to enforce that concept within code") + cog.outl("using one of the following field classes.") + cog.outl("") + cog.outl("See :ref:`Required Values` for more details.") + cog.outl("") + cog_class_table(required) ]]] .. .. list-table:: @@ -183,6 +197,28 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.RatingField` - `Rating `__ + * - :class:`~pyairtable.orm.fields.RichTextField` + - `Rich text `__ + * - :class:`~pyairtable.orm.fields.SelectField` + - `Single select `__ + * - :class:`~pyairtable.orm.fields.SingleLinkField` + - `Link to another record `__ + * - :class:`~pyairtable.orm.fields.TextField` + - `Single line text `__, `Long text `__ + * - :class:`~pyairtable.orm.fields.UrlField` + - `Url `__ + +Airtable does not have a concept of fields that require values, +but pyAirtable allows you to enforce that concept within code +using one of the following field classes. + +See :ref:`Required Values` for more details. + +.. list-table:: + :header-rows: 1 + + * - ORM field class + - Airtable field type(s) * - :class:`~pyairtable.orm.fields.RequiredAITextField` ๐Ÿ”’ - `AI Text `__ * - :class:`~pyairtable.orm.fields.RequiredBarcodeField` @@ -199,6 +235,8 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.RequiredDurationField` - `Duration `__ + * - :class:`~pyairtable.orm.fields.RequiredEmailField` + - `Email `__ * - :class:`~pyairtable.orm.fields.RequiredFloatField` - `Number `__ * - :class:`~pyairtable.orm.fields.RequiredIntegerField` @@ -207,21 +245,19 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.RequiredPercentField` - `Percent `__ + * - :class:`~pyairtable.orm.fields.RequiredPhoneNumberField` + - `Phone `__ * - :class:`~pyairtable.orm.fields.RequiredRatingField` - `Rating `__ - * - :class:`~pyairtable.orm.fields.RequiredSelectField` - - `Single select `__ - * - :class:`~pyairtable.orm.fields.RichTextField` + * - :class:`~pyairtable.orm.fields.RequiredRichTextField` - `Rich text `__ - * - :class:`~pyairtable.orm.fields.SelectField` + * - :class:`~pyairtable.orm.fields.RequiredSelectField` - `Single select `__ - * - :class:`~pyairtable.orm.fields.SingleLinkField` - - `Link to another record `__ - * - :class:`~pyairtable.orm.fields.TextField` + * - :class:`~pyairtable.orm.fields.RequiredTextField` - `Single line text `__, `Long text `__ - * - :class:`~pyairtable.orm.fields.UrlField` + * - :class:`~pyairtable.orm.fields.RequiredUrlField` - `Url `__ -.. [[[end]]] +.. [[[end]]] (checksum: 131138e1071ba71d4f46f05da4d57570) Formula, Rollup, and Lookup Fields @@ -275,6 +311,57 @@ You can check for errors using the :func:`~pyairtable.api.types.is_airtable_erro True +Required Values +--------------- + +Airtable does not generally have a concept of fields that require values, but +pyAirtable allows you to enforce that a field must have a value before saving it. +To do this, use one of the "Required" field types, which will raise an exception +if either of the following occur: + + 1. If you try to set its value to ``None`` (or, sometimes, to the empty string). + 2. If the API returns a ``None`` (or empty string) as the field's value. + +For example, given this code: + +.. code-block:: python + + from pyairtable.orm import Model, fields as F + + class MyTable(Model): + class Meta: + ... + + name = F.RequiredTextField("Name") + +The following will all raise an exception: + +.. code-block:: python + + >>> MyTable(name=None) + Traceback (most recent call last): + ... + MissingValue: MyTable.name does not accept empty values + + >>> r = MyTable.from_record(fake_record(Name="Alice")) + >>> r.name + 'Alice' + >>> r.name = None + Traceback (most recent call last): + ... + MissingValue: MyTable.name does not accept empty values + + >>> r = MyTable.from_record(fake_record(Name=None)) + >>> r.name + Traceback (most recent call last): + ... + MissingValue: MyTable.name received an empty value + +One reason to use these fields (sparingly!) might be to avoid adding defensive +null-handling checks all over your code, if you are confident that the workflows +around your Airtable base will not produce an empty value (or that an empty value +is enough of a problem that your code should raise an exception). + Linked Records ---------------- diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 56c34aaf..ef70405a 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -1366,8 +1366,6 @@ class CreatedTimeField(RequiredDatetimeField): See `Created time `__. - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. """ From f31eef30cd96047850218a4e2c46e574890ce074 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 15 Apr 2024 12:18:47 -0700 Subject: [PATCH 130/272] Fix integration tests from #363 --- tests/integration/test_integration_api.py | 2 +- tests/integration/test_integration_orm.py | 30 ++++++++++++++++++++--- tests/test_orm_fields.py | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index fecb7d94..288621a3 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -279,7 +279,7 @@ 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)]}) rv_get = table.get(rec["id"]) - assert rv_get["fields"]["attachment"][0]["filename"] == "logo.png" + assert rv_get["fields"]["attachment"][0]["url"].endswith("logo.png") def test_integration_attachment_multiple(table, cols, valid_img_url): diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 099e6935..be67915a 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -67,7 +67,7 @@ class _Everything(Model): formula_nan = f.TextField("Formula NaN", readonly=True) addresses = f.LinkField("Address", _Address) link_count = f.CountField("Link to Self (Count)") - link_self = f.LinkField["_Everything"]( + link_self = f.SingleLinkField["_Everything"]( "Link to Self", model="test_integration_orm._Everything", lazy=False, @@ -80,6 +80,24 @@ class _Everything(Model): created_by = f.CreatedByField("Created By") last_modified = f.LastModifiedTimeField("Last Modified") last_modified_by = f.LastModifiedByField("Last Modified By") + required_barcode = f.RequiredBarcodeField("Barcode") + required_collaborator = f.RequiredCollaboratorField("Assignee") + required_count = f.RequiredCountField("Count") + required_currency = f.RequiredCurrencyField("Dollars") + required_date = f.RequiredDateField("Date") + required_datetime = f.RequiredDatetimeField("DateTime") + required_duration = f.RequiredDurationField("Duration (h:mm)") + required_email = f.RequiredEmailField("Email") + required_float = f.RequiredFloatField("Decimal 1") + required_integer = f.RequiredIntegerField("Integer") + required_number = f.RequiredNumberField("Number") + required_percent = f.RequiredPercentField("Percent") + required_phone = f.RequiredPhoneNumberField("Phone") + required_rating = f.RequiredRatingField("Stars") + required_rich_text = f.RequiredRichTextField("Notes") + required_select = f.RequiredSelectField("Status") + required_text = f.RequiredTextField("Name") + required_url = f.RequiredUrlField("URL") def _model_fixture(cls, monkeypatch, make_meta): @@ -180,7 +198,11 @@ def test_every_field(Everything): type(field) for field in vars(Everything).values() if isinstance(field, f.Field) } for field_class in f.ALL_FIELDS: - if field_class in {f.ExternalSyncSourceField, f.AITextField}: + if field_class in { + f.ExternalSyncSourceField, + f.AITextField, + f.RequiredAITextField, + }: continue assert field_class in classes_used @@ -213,8 +235,8 @@ def test_every_field(Everything): record.save() assert record.id assert record.addresses == [] - assert record.link_self == [] - record.link_self = [record] + assert record.link_self is None + record.link_self = record record.save() # The ORM won't refresh the model's field values after save() diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 76aeeea6..af8ef00d 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -823,7 +823,7 @@ class Book(Model): assert book.author.name == "Author 1" assert book._fields["Author"][1:] == [a2, a3] # not converted to models - # if no modifications made, the entire list will be sent back to the API + # if book.author.__set__ not called, the entire list will be sent back to the API with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: book.save() m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True) From 2b8ea03719469586b95109dcee1a55da26840f6c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 15 Apr 2024 10:55:49 -0700 Subject: [PATCH 131/272] Changelog for 3.0 --- docs/source/changelog.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 42e9cda6..fc5c23a6 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -25,6 +25,15 @@ Changelog * Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. - `PR #362 `_. * Added ORM fields that :ref:`require a non-null value `. + - `PR #363 `_. + +2.3.3 (2024-03-22) +------------------------ + +* Fixed a bug affecting ORM Meta values which are computed at runtime. + - `PR #357 `_. +* Fixed documentation for the ORM module. + - `PR #356 `_. 2.3.2 (2024-03-18) ------------------------ From 6ffffd8d75358d4d560cf9b024bfd78176007093 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 17 Apr 2024 11:00:39 -0700 Subject: [PATCH 132/272] Refactor how we access ORM model configuration --- docs/source/changelog.rst | 1 + docs/source/migrations.rst | 27 ++- pyairtable/orm/model.py | 228 ++++++++++++++-------- tests/integration/test_integration_orm.py | 11 +- tests/test_orm.py | 12 +- tests/test_orm_fields.py | 12 +- tests/test_orm_model.py | 12 +- 7 files changed, 199 insertions(+), 104 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index fc5c23a6..14e39ab4 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -26,6 +26,7 @@ Changelog - `PR #362 `_. * Added ORM fields that :ref:`require a non-null value `. - `PR #363 `_. +* Refactored methods for accessing ORM model configuration. 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 4bedda0c..271d71a6 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -9,10 +9,15 @@ Migration Guide Migrating from 2.x to 3.0 ============================ -In this release we've made breaking changes to the :mod:`pyairtable.formulas` module. -In general, most functions and methods in this module will return instances of +In this release we've made a number of breaking changes, summarized below. + +Changes to the formulas module +--------------------------------------------- + +Most functions and methods in :mod:`pyairtable.formulas` now return instances of :class:`~pyairtable.formulas.Formula`, which can be chained, combined, and eventually passed to the ``formula=`` keyword argument to methods like :meth:`~pyairtable.Table.all`. +Read the module documentation for more details. The full list of breaking changes is below: @@ -49,6 +54,24 @@ The full list of breaking changes is below: - These no longer return ``str``, and instead return instances of :class:`~pyairtable.formulas.FunctionCall`. +Changes to retrieving ORM model configuration +--------------------------------------------- + +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)`` Migrating from 2.2 to 2.3 ============================ diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 1c36e038..c07ab547 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,10 +1,21 @@ import datetime -from functools import lru_cache -from typing import Any, Dict, Iterable, List, Optional +from functools import cached_property +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Dict, + Iterable, + List, + Optional, + Type, + Union, +) from typing_extensions import Self as SelfType -from pyairtable.api.api import Api +from pyairtable.api import retrying +from pyairtable.api.api import Api, TimeoutTuple from pyairtable.api.base import Base from pyairtable.api.table import Table from pyairtable.api.types import ( @@ -19,6 +30,9 @@ from pyairtable.orm.fields import AnyField, Field from pyairtable.utils import datetime_from_iso_str, datetime_to_iso_str +if TYPE_CHECKING: + from builtins import _ClassInfo + class Model: """ @@ -71,12 +85,22 @@ def api_key(): return get_secret("AIRTABLE_API_KEY") """ + #: The Airtable record ID for this instance. If empty, the instance + #: has never been saved to the API. id: str = "" + + #: The time when the Airtable record was created. If empty, the instance + #: has never been saved to (or fetched from) the API. created_time: Optional[datetime.datetime] = None + + #: A wrapper allowing type-annotated access to ORM configuration. + meta: ClassVar["_Meta"] + _deleted: bool = False _fields: Dict[FieldName, Any] def __init_subclass__(cls, **kwargs: Any): + cls.meta = _Meta(cls) cls._validate_class() super().__init_subclass__(**kwargs) @@ -144,37 +168,12 @@ def __init__(self, **fields: Any): raise AttributeError(key) setattr(self, key, value) - @classmethod - def _get_meta( - cls, name: str, default: Any = None, required: bool = False, call: bool = True - ) -> Any: - """ - Retrieves the value of a Meta attribute. - - Args: - default: The default value to return if the attribute is not set. - required: Raise an exception if the attribute is not set. - call: If the value is callable, call it before returning a result. - """ - if not hasattr(cls, "Meta"): - raise AttributeError(f"{cls.__name__}.Meta must be defined") - if not hasattr(cls.Meta, name): - if required: - raise ValueError(f"{cls.__name__}.Meta.{name} must be defined") - return default - value = getattr(cls.Meta, name) - if call and callable(value): - value = value() - if required and value is None: - raise ValueError(f"{cls.__name__}.Meta.{name} cannot be None") - return value - @classmethod def _validate_class(cls) -> None: # Verify required Meta attributes were set (but don't call any callables) - assert cls._get_meta("api_key", required=True, call=False) - assert cls._get_meta("base_id", required=True, call=False) - assert cls._get_meta("table_name", required=True, call=False) + assert cls.meta.get("api_key", required=True, call=False) + assert cls.meta.get("base_id", required=True, call=False) + assert cls.meta.get("table_name", required=True, call=False) model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")] overridden = set(model_attributes).intersection(Model.__dict__.keys()) @@ -185,35 +184,6 @@ def _validate_class(cls) -> None: ) ) - @classmethod - def _get_meta_request_kwargs(cls) -> Dict[str, Any]: - return { - "user_locale": None, - "cell_format": "json", - "time_zone": None, - "use_field_ids": cls._get_meta("use_field_ids", default=False), - } - - @classmethod - @lru_cache - def get_api(cls) -> Api: - return Api( - api_key=cls._get_meta("api_key", required=True), - timeout=cls._get_meta("timeout"), - ) - - @classmethod - def get_base(cls) -> Base: - return cls.get_api().base(cls._get_meta("base_id", required=True)) - - @classmethod - def get_table(cls) -> Table: - return cls.get_base().table(cls._get_meta("table_name", required=True)) - - @classmethod - def _typecast(cls) -> bool: - return bool(cls._get_meta("typecast", default=True)) - def exists(self) -> bool: """ Whether the instance has been saved to Airtable already. @@ -232,14 +202,14 @@ def save(self) -> bool: """ if self._deleted: raise RuntimeError(f"{self.id} was deleted") - table = self.get_table() + table = self.meta.table fields = self.to_record(only_writable=True)["fields"] if not self.id: - record = table.create(fields, typecast=self._typecast()) + record = table.create(fields, typecast=self.meta.typecast) did_create = True else: - record = table.update(self.id, fields, typecast=self._typecast()) + record = table.update(self.id, fields, typecast=self.meta.typecast) did_create = False self.id = record["id"] @@ -255,7 +225,7 @@ def delete(self) -> bool: """ if not self.id: raise ValueError("cannot be deleted because it does not have id") - table = self.get_table() + table = self.meta.table result = table.delete(self.id) self._deleted = True # Is it even possible to get "deleted" False? @@ -267,9 +237,8 @@ def all(cls, **kwargs: Any) -> List[SelfType]: Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. """ - kwargs.update(cls._get_meta_request_kwargs()) - table = cls.get_table() - return [cls.from_record(record) for record in table.all(**kwargs)] + kwargs.update(cls.meta.request_kwargs) + return [cls.from_record(record) for record in cls.meta.table.all(**kwargs)] @classmethod def first(cls, **kwargs: Any) -> Optional[SelfType]: @@ -277,9 +246,8 @@ def first(cls, **kwargs: Any) -> Optional[SelfType]: Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. """ - kwargs.update(cls._get_meta_request_kwargs()) - table = cls.get_table() - if record := table.first(**kwargs): + kwargs.update(cls.meta.request_kwargs) + if record := cls.meta.table.first(**kwargs): return cls.from_record(record) return None @@ -356,7 +324,7 @@ def fetch(self) -> None: if not self.id: raise ValueError("cannot be fetched because instance does not have an id") - record = self.get_table().get(self.id) + record = self.meta.table.get(self.id) unused = self.from_record(record) self._fields = unused._fields self.created_time = unused.created_time @@ -383,7 +351,7 @@ def from_ids( return [cls.from_id(record_id, fetch=False) for record_id in record_ids] # There's no endpoint to query multiple IDs at once, but we can use a formula. formula = OR(EQ(RECORD_ID(), record_id) for record_id in record_ids) - record_data = cls.get_table().all(formula=formula) + record_data = cls.meta.table.all(formula=formula) records = [cls.from_record(record) for record in record_data] # Ensure we return records in the same order, and raise KeyError if any are missing records_by_id = {record.id: record for record in records} @@ -412,9 +380,9 @@ def batch_save(cls, models: List[SelfType]) -> None: if (record := model.to_record(only_writable=True)) ] - table = cls.get_table() - table.batch_update(update_records, typecast=cls._typecast()) - created_records = table.batch_create(create_records, typecast=cls._typecast()) + table = cls.meta.table + table.batch_update(update_records, typecast=cls.meta.typecast) + created_records = table.batch_create(create_records, typecast=cls.meta.typecast) for model, record in zip(create_models, created_records): model.id = record["id"] model.created_time = datetime_from_iso_str(record["createdTime"]) @@ -431,18 +399,122 @@ def batch_delete(cls, models: List[SelfType]) -> None: raise ValueError("cannot delete an unsaved model") if not all(isinstance(model, cls) for model in models): raise TypeError(set(type(model) for model in models)) - cls.get_table().batch_delete([model.id for model in models]) + cls.meta.table.batch_delete([model.id for model in models]) def comments(self) -> List[Comment]: """ Return a list of comments on this record. See :meth:`Table.comments `. """ - return self.get_table().comments(self.id) + return self.meta.table.comments(self.id) def add_comment(self, text: str) -> Comment: """ Add a comment to this record. See :meth:`Table.add_comment `. """ - return self.get_table().add_comment(self.id, text) + return self.meta.table.add_comment(self.id, text) + + +class _Meta: + """ + Wrapper around a Model.Meta class that provides easier, typed access to + configuration values (which may or may not be defined in the original class). + """ + + def __init__(self, model: Type[Model]) -> None: + if not (model_meta := getattr(model, "Meta", None)): + raise AttributeError(f"{model.__name__}.Meta must be defined") + self.model = model + self.model_meta = model_meta + + def get( + self, + name: str, + default: Any = None, + required: bool = False, + call: bool = True, + check_types: Optional["_ClassInfo"] = None, + ) -> Any: + """ + Given a name, retrieve the model configuration with that name. + + Args: + default: The default value to use if the name is not defined. + required: If ``True``, raises ``ValueError`` if the name is undefined or None. + call: If ``False``, does not execute any callables to retrieve this value; + it will consider the callable itself as the value. + check_types: If set, will raise a ``TypeError`` if the value is not + an instance of the given type(s). + """ + if required and not hasattr(self.model_meta, name): + raise ValueError(f"{self.model.__name__}.Meta.{name} must be defined") + value = getattr(self.model_meta, name, default) + if callable(value) and call: + value = value() + if required and value is None: + raise ValueError(f"{self.model.__name__}.Meta.{name} cannot be None") + if check_types is not None and not isinstance(value, check_types): + raise TypeError(f"expected {check_types!r}; got {type(value)}") + return value + + @property + def api_key(self) -> str: + return str(self.get("api_key", required=True)) + + @property + def timeout(self) -> Optional[TimeoutTuple]: + return self.get( # type: ignore[no-any-return] + "timeout", + default=None, + check_types=(type(None), tuple), + ) + + @property + def retry_strategy(self) -> Optional[Union[bool, retrying.Retry]]: + return self.get( # type: ignore[no-any-return] + "retry", + default=True, + check_types=(type(None), bool, retrying.Retry), + ) + + @cached_property + def api(self) -> Api: + return Api( + self.api_key, + timeout=self.timeout, + retry_strategy=self.retry_strategy, + ) + + @property + def base_id(self) -> str: + return str(self.get("base_id", required=True)) + + @property + def base(self) -> Base: + return self.api.base(self.base_id) + + @property + def table_name(self) -> str: + return str(self.get("table_name", required=True)) + + @property + def table(self) -> Table: + return self.base.table(self.table_name) + + @property + def typecast(self) -> bool: + return bool(self.get("typecast", default=True)) + + @property + def use_field_ids(self) -> bool: + return bool(self.get("use_field_ids", default=False)) + + @property + def request_kwargs(self) -> Dict[str, Any]: + return { + "user_locale": None, + "cell_format": "json", + "time_zone": None, + "use_field_ids": self.use_field_ids, + } diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index be67915a..d371c314 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -101,11 +101,12 @@ class _Everything(Model): def _model_fixture(cls, monkeypatch, make_meta): - monkeypatch.setattr(cls, "Meta", make_meta(cls.__name__.replace("_", ""))) + monkeypatch.setattr( + cls.meta, "model_meta", make_meta(cls.__name__.replace("_", "")) + ) yield cls - table = cls.get_table() - for page in table.iterate(): - table.batch_delete([record["id"] for record in page]) + for page in cls.meta.table.iterate(): + cls.meta.table.batch_delete([record["id"] for record in page]) @pytest.fixture @@ -171,7 +172,7 @@ class Contact(Model): first_name = f.TextField("First Name") last_name = f.TextField("Last Name") - table = Contact.get_table() + table = Contact.meta.table record = table.create( { "First Name": "Alice", diff --git a/tests/test_orm.py b/tests/test_orm.py index 1a4e2caf..784abd9e 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -175,7 +175,7 @@ def test_linked_record(): assert not contact.address[0].street with Mocker() as mock: - url = Address.get_table().record_url(address.id) + url = Address.meta.table.record_url(address.id) mock.get(url, status_code=200, json=record) contact.address[0].fetch() @@ -194,11 +194,11 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records): """ address_json = fake_record(Number=123, Street="Fake St") address_id = address_json["id"] - address_url_re = re.escape(Address.get_table().url + "?filterByFormula=") + address_url_re = re.escape(Address.meta.table.url + "?filterByFormula=") contact_json = fake_record(Email="alice@example.com", Link=[address_id]) contact_id = contact_json["id"] - contact_url = Contact.get_table().record_url(contact_id) - contact_url_re = re.escape(Contact.get_table().url + "?filterByFormula=") + contact_url = Contact.meta.table.record_url(contact_id) + contact_url_re = re.escape(Contact.meta.table.url + "?filterByFormula=") requests_mock.get(re.compile(address_url_re), json={"records": [address_json]}) requests_mock.get(re.compile(contact_url_re), json={"records": [contact_json]}) requests_mock.get(contact_url, json=contact_json) @@ -257,12 +257,12 @@ def test_undeclared_field(requests_mock, test_case): ) requests_mock.get( - Address.get_table().url, + Address.meta.table.url, status_code=200, json={"records": [record]}, ) requests_mock.get( - Address.get_table().record_url(record["id"]), + Address.meta.table.record_url(record["id"]), status_code=200, json=record, ) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index af8ef00d..e55e1d94 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -686,14 +686,12 @@ def test_link_field__cycle(requests_mock): rec_b = {"id": id_b, "createdTime": DATETIME_S, "fields": {"Friends": [id_c]}} rec_c = {"id": id_c, "createdTime": DATETIME_S, "fields": {"Friends": [id_a]}} - requests_mock.get(Person.get_table().record_url(id_a), json=rec_a) + requests_mock.get(Person.meta.table.record_url(id_a), json=rec_a) a = Person.from_id(id_a) for record in (rec_a, rec_b, rec_c): url_re = re.compile( - re.escape(Person.get_table().url + "?filterByFormula=") - + ".*" - + record["id"] + re.escape(Person.meta.table.url + "?filterByFormula=") + ".*" + record["id"] ) requests_mock.get(url_re, json={"records": [record]}) @@ -709,7 +707,7 @@ def test_link_field__load_many(requests_mock): """ person_id = fake_id("rec", "A") - person_url = Person.get_table().record_url(person_id) + person_url = Person.meta.table.record_url(person_id) friend_ids = [fake_id("rec", c) for c in "123456789ABCDEF"] person_json = { @@ -731,7 +729,7 @@ def test_link_field__load_many(requests_mock): # The mocked URL specifically includes every record ID in our test set, # to ensure the library isn't somehow dropping records from its query. url_regex = ".*".join( - [re.escape(Person.get_table().url + "?filterByFormula="), *friend_ids] + [re.escape(Person.meta.table.url + "?filterByFormula="), *friend_ids] ) mock_list = requests_mock.get( re.compile(url_regex), @@ -961,7 +959,7 @@ def patch_callback(request, context): "fields": request.json()["fields"], } - m = requests_mock.patch(M.get_table().record_url(obj.id), json=patch_callback) + m = requests_mock.patch(M.meta.table.record_url(obj.id), json=patch_callback) # Test that we parse the "Z" into UTC correctly assert obj.dt.date() == datetime.date(2024, 2, 29) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 81e84e2f..4d1c287e 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -178,8 +178,8 @@ def test_from_ids(mock_api): contacts = FakeModel.from_ids(fake_ids) mock_api.assert_called_once_with( method="get", - url=FakeModel.get_table().url, - fallback=("post", FakeModel.get_table().url + "/listRecords"), + url=FakeModel.meta.table.url, + fallback=("post", FakeModel.meta.table.url + "/listRecords"), options={ "formula": "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in fake_ids) }, @@ -248,7 +248,7 @@ def test_get_fields_by_id(fake_records_by_id): """ with Mocker() as mock: mock.get( - f"{FakeModelByIds.get_table().url}?&returnFieldsByFieldId=1&cellFormat=json", + f"{FakeModelByIds.meta.table.url}?&returnFieldsByFieldId=1&cellFormat=json", json=fake_records_by_id, complete_qs=True, status_code=200, @@ -286,7 +286,7 @@ class Meta: f = Fake() Fake.Meta.table_name.assert_not_called() - assert f._get_meta("api_key") == data["api_key"] - assert f._get_meta("base_id") == data["base_id"] - assert f._get_meta("table_name") == data["table_name"] + assert f.meta.get("api_key") == data["api_key"] + assert f.meta.get("base_id") == data["base_id"] + assert f.meta.get("table_name") == data["table_name"] Fake.Meta.table_name.assert_called_once() From 54c22c02d21e7856baf33c17e114f3e3089c1c59 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 17 Apr 2024 11:17:08 -0700 Subject: [PATCH 133/272] Allow Meta to be a dict rather than a class --- pyairtable/orm/model.py | 54 +++++++++++++++++------ tests/integration/test_integration_orm.py | 4 +- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index c07ab547..86c8fcb4 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,4 +1,5 @@ import datetime +from dataclasses import dataclass from functools import cached_property from typing import ( TYPE_CHECKING, @@ -7,6 +8,7 @@ Dict, Iterable, List, + Mapping, Optional, Type, Union, @@ -39,7 +41,7 @@ class Model: Supports creating ORM-style classes representing Airtable tables. For more details, see :ref:`orm`. - A nested class called ``Meta`` is required and can specify + A nested class or dict called ``Meta`` is required and can specify the following attributes: * ``api_key`` (required) - API key or personal access token. @@ -49,14 +51,13 @@ class Model: * ``typecast`` - |kwarg_typecast| Defaults to ``True``. * ``use_field_ids`` - |kwarg_use_field_ids| Defaults to ``False``. + For example, the following two are equivalent: + .. code-block:: python from pyairtable.orm import Model, fields class Contact(Model): - first_name = fields.TextField("First Name") - age = fields.IntegerField("Age") - class Meta: base_id = "appaPqizdsNHDvlEm" table_name = "Contact" @@ -64,18 +65,33 @@ class Meta: timeout = (5, 5) typecast = True - You can implement meta attributes as callables if certain values - need to be dynamically provided or are unavailable at import time: + first_name = fields.TextField("First Name") + age = fields.IntegerField("Age") .. code-block:: python from pyairtable.orm import Model, fields - from your_app.config import get_secret class Contact(Model): + Meta = { + "base_id": "appaPqizdsNHDvlEm", + "table_name": "Contact", + "api_key": "keyapikey", + "timeout": (5, 5), + "typecast": True, + } first_name = fields.TextField("First Name") age = fields.IntegerField("Age") + You can implement meta attributes as callables if certain values + need to be dynamically provided or are unavailable at import time: + + .. code-block:: python + + from pyairtable.orm import Model, fields + from your_app.config import get_secret + + class Contact(Model): class Meta: base_id = "appaPqizdsNHDvlEm" table_name = "Contact" @@ -83,6 +99,9 @@ class Meta: @staticmethod def api_key(): return get_secret("AIRTABLE_API_KEY") + + first_name = fields.TextField("First Name") + age = fields.IntegerField("Age") """ #: The Airtable record ID for this instance. If empty, the instance @@ -416,17 +435,24 @@ def add_comment(self, text: str) -> Comment: return self.meta.table.add_comment(self.id, text) +@dataclass class _Meta: """ Wrapper around a Model.Meta class that provides easier, typed access to configuration values (which may or may not be defined in the original class). """ - def __init__(self, model: Type[Model]) -> None: - if not (model_meta := getattr(model, "Meta", None)): - raise AttributeError(f"{model.__name__}.Meta must be defined") - self.model = model - self.model_meta = model_meta + model: Type[Model] + + @property + def _config(self) -> Mapping[str, Any]: + if not (model_meta := getattr(self.model, "Meta", None)): + raise AttributeError(f"{self.model.__name__}.Meta must be defined") + if isinstance(model_meta, dict): + return model_meta + if isinstance(model_meta, type): + return model_meta.__dict__ + raise TypeError(type(model_meta)) def get( self, @@ -447,9 +473,9 @@ def get( check_types: If set, will raise a ``TypeError`` if the value is not an instance of the given type(s). """ - if required and not hasattr(self.model_meta, name): + if required and name not in self._config: raise ValueError(f"{self.model.__name__}.Meta.{name} must be defined") - value = getattr(self.model_meta, name, default) + value = self._config.get(name, default) if callable(value) and call: value = value() if required and value is None: diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index d371c314..223c6722 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -101,9 +101,7 @@ class _Everything(Model): def _model_fixture(cls, monkeypatch, make_meta): - monkeypatch.setattr( - cls.meta, "model_meta", make_meta(cls.__name__.replace("_", "")) - ) + monkeypatch.setattr(cls, "Meta", make_meta(cls.__name__.replace("_", ""))) yield cls for page in cls.meta.table.iterate(): cls.meta.table.batch_delete([record["id"] for record in page]) From 05c044d9584fa8acc6952e7ecd43a02fb753a147 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 3 Apr 2024 11:00:43 -0700 Subject: [PATCH 134/272] Fix broken reference to pytest.Mark --- tests/test_orm_fields.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index af8ef00d..636c3af3 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -18,6 +18,13 @@ ) from pyairtable.utils import datetime_to_iso_str +try: + from pytest import Mark as _PytestMark +except ImportError: + # older versions of pytest don't expose pytest.Mark directly + from _pytest.mark import Mark as _PytestMark + + DATE_S = "2023-01-01" DATE_V = datetime.date(2023, 1, 1) DATETIME_S = "2023-04-12T09:30:00.000Z" @@ -494,7 +501,7 @@ def assert_all_fields_tested_by(*test_fns, exclude=()): """ def extract_fields(obj): - if isinstance(obj, pytest.Mark): + if isinstance(obj, _PytestMark): yield from [*extract_fields(obj.args), *extract_fields(obj.kwargs)] elif isinstance(obj, str): pass @@ -511,7 +518,7 @@ def extract_fields(obj): field_class for test_function in test_fns for pytestmark in getattr(test_function, "pytestmark", []) - if isinstance(pytestmark, pytest.Mark) and pytestmark.name == "parametrize" + if isinstance(pytestmark, _PytestMark) and pytestmark.name == "parametrize" for field_class in extract_fields(pytestmark) if field_class not in exclude } From 513e9b39a9c457048f29254cf2dbddc492ff0683 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 20 Apr 2024 00:06:37 -0700 Subject: [PATCH 135/272] Fix gap in test coverage --- pyairtable/orm/model.py | 16 ++++++++++------ pyairtable/testing.py | 8 +++++++- tests/test_orm_model.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 86c8fcb4..65bbb905 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -12,6 +12,7 @@ Optional, Type, Union, + cast, ) from typing_extensions import Self as SelfType @@ -446,13 +447,16 @@ class _Meta: @property def _config(self) -> Mapping[str, Any]: - if not (model_meta := getattr(self.model, "Meta", None)): + if not (meta := getattr(self.model, "Meta", None)): raise AttributeError(f"{self.model.__name__}.Meta must be defined") - if isinstance(model_meta, dict): - return model_meta - if isinstance(model_meta, type): - return model_meta.__dict__ - raise TypeError(type(model_meta)) + if isinstance(meta, dict): + return meta + try: + return cast(Mapping[str, Any], meta.__dict__) + except AttributeError: + raise TypeError( + f"{self.model.__name__}.Meta must be a dict or class; got {type(meta)}" + ) def get( self, diff --git a/pyairtable/testing.py b/pyairtable/testing.py index a594bf24..359ad33b 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -5,8 +5,10 @@ import datetime import random import string -from typing import Any, Optional +from typing import Any, Optional, Union +from pyairtable.api import retrying +from pyairtable.api.api import TimeoutTuple from pyairtable.api.types import AttachmentDict, CollaboratorDict, Fields, RecordDict @@ -34,6 +36,8 @@ def fake_meta( base_id: str = "appFakeTestingApp", table_name: str = "tblFakeTestingTbl", api_key: str = "patFakePersonalAccessToken", + timeout: Optional[TimeoutTuple] = None, + retry: Optional[Union[bool, retrying.Retry]] = None, use_field_ids: bool = False, ) -> type: """ @@ -43,6 +47,8 @@ def fake_meta( "base_id": base_id, "table_name": table_name, "api_key": api_key, + "timeout": timeout, + "retry": retry, "use_field_ids": use_field_ids, } return type("Meta", (), attrs) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 4d1c287e..df8a7e8c 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -68,6 +68,47 @@ class Address(Model): m.assert_not_called() +def test_model_meta_dict(): + """ + Test that we can define Meta as a dict rather than a class. + """ + + class Address(Model): + Meta = { + "api_key": "fake_api_key", + "base_id": "fake_base_id", + "table_name": "fake_table_name", + "timeout": (1, 1), + "retry": False, + } + + assert Address.meta.api.api_key == "fake_api_key" + + +@pytest.mark.parametrize("invalid_meta", ([1, 2, 3], "invalid", True)) +def test_model_invalid_meta(invalid_meta): + """ + Test that model creation raises a TypeError if Meta is an invalid type. + """ + with pytest.raises(TypeError): + + class Address(Model): + Meta = invalid_meta + + +@pytest.mark.parametrize("meta_kwargs", [{"timeout": 1}, {"retry": "sure"}]) +def test_model_meta_checks_types(meta_kwargs): + """ + Test that accessing meta raises a TypeError if a value is an invalid type. + """ + + class Address(Model): + Meta = fake_meta(**meta_kwargs) + + with pytest.raises(TypeError): + Address.meta.api + + @pytest.mark.parametrize("name", ("exists", "id")) def test_model_overlapping(name): """ From 25903162f6dc2934c9b01d236861c3c448b5012e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 28 Mar 2024 22:23:14 -0700 Subject: [PATCH 136/272] Add `memoize` option to Model Meta and methods --- pyairtable/orm/fields.py | 19 ++- pyairtable/orm/model.py | 88 +++++++++--- pyairtable/testing.py | 22 ++- tests/test_api_enterprise.py | 2 +- tests/test_orm_model.py | 23 ++- tests/test_orm_model__memoization.py | 201 +++++++++++++++++++++++++++ tests/test_testing.py | 5 + tox.ini | 1 + 8 files changed, 326 insertions(+), 35 deletions(-) create mode 100644 tests/test_orm_model__memoization.py diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index ef70405a..a8cdb55e 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -608,7 +608,13 @@ def _repr_fields(self) -> List[Tuple[str, Any]]: ("lazy", self._lazy), ] - def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None: + def populate( + self, + instance: "Model", + /, + lazy: Optional[bool] = None, + memoize: Optional[bool] = None, + ) -> None: """ Populates the field's value for the given instance. This allows you to selectively load models in either lazy or non-lazy fashion, depending on @@ -648,6 +654,7 @@ class Meta: ... record.id: record for record in self.linked_model.from_ids( cast(List[RecordId], new_record_ids), + memoize=memoize, fetch=(not lazy), ) } @@ -833,8 +840,14 @@ def __set_name__(self, owner: Any, name: str) -> None: def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: return self._link_field.to_record_value(value) - def populate(self, instance: "Model", lazy: Optional[bool] = None) -> None: - self._link_field.populate(instance, lazy=lazy) + def populate( + self, + instance: "Model", + /, + lazy: Optional[bool] = None, + memoize: Optional[bool] = None, + ) -> None: + self._link_field.populate(instance, lazy=lazy, memoize=memoize) @property def linked_model(self) -> Type[T_Linked]: diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 65bbb905..176d0aaf 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -50,7 +50,13 @@ class Model: * ``table_name`` (required) - Table ID or name. * ``timeout`` - A tuple indicating a connect and read timeout. Defaults to no timeout. * ``typecast`` - |kwarg_typecast| Defaults to ``True``. - * ``use_field_ids`` - |kwarg_use_field_ids| Defaults to ``False``. + * ``retry`` - An instance of `urllib3.util.Retry `_. + If ``None`` or ``False``, requests will not be retried. + If ``True``, the default strategy will be applied + (see :func:`~pyairtable.retry_strategy` for details). + * ``use_field_ids`` - Whether fields will be defined by ID, rather than name. Defaults to ``False``. + * ``memoize`` - Whether the model should reuse models it creates between requests. + See :ref:`ORM memoization` for more information. For example, the following two are equivalent: @@ -117,10 +123,13 @@ def api_key(): meta: ClassVar["_Meta"] _deleted: bool = False + _fetched: bool = False _fields: Dict[FieldName, Any] + _memoized: ClassVar[Dict[RecordId, SelfType]] def __init_subclass__(cls, **kwargs: Any): cls.meta = _Meta(cls) + cls._memoized = {} cls._validate_class() super().__init_subclass__(**kwargs) @@ -176,8 +185,10 @@ def __init__(self, **fields: Any): """ - if "id" in fields: + try: self.id = fields.pop("id") + except KeyError: + pass # Field values in internal (not API) representation self._fields = {} @@ -252,23 +263,28 @@ def delete(self) -> bool: return bool(result["deleted"]) @classmethod - def all(cls, **kwargs: Any) -> List[SelfType]: + def all(cls, /, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType]: """ Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. """ kwargs.update(cls.meta.request_kwargs) - return [cls.from_record(record) for record in cls.meta.table.all(**kwargs)] + return [ + cls.from_record(record, memoize=memoize) + for record in cls.meta.table.all(**kwargs) + ] @classmethod - def first(cls, **kwargs: Any) -> Optional[SelfType]: + def first( + cls, /, memoize: Optional[bool] = None, **kwargs: Any + ) -> Optional[SelfType]: """ Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. """ kwargs.update(cls.meta.request_kwargs) if record := cls.meta.table.first(**kwargs): - return cls.from_record(record) + return cls.from_record(record, memoize=memoize) return None def to_record(self, only_writable: bool = False) -> RecordDict: @@ -293,7 +309,9 @@ def to_record(self, only_writable: bool = False) -> RecordDict: return {"id": self.id, "createdTime": ct, "fields": fields} @classmethod - def from_record(cls, record: RecordDict) -> SelfType: + def from_record( + cls, record: RecordDict, /, memoize: Optional[bool] = None + ) -> SelfType: """ Create an instance from a record dict. """ @@ -314,14 +332,16 @@ def from_record(cls, record: RecordDict) -> SelfType: # any readonly fields, instead we directly set instance._fields. instance = cls(id=record["id"]) instance._fields = field_values + instance._fetched = True instance.created_time = datetime_from_iso_str(record["createdTime"]) + memoize = cls.meta.memoize if memoize is None else memoize + if memoize: + cls._memoized[instance.id] = instance return instance @classmethod def from_id( - cls, - record_id: RecordId, - fetch: bool = True, + cls, record_id: RecordId, /, fetch: bool = True, memoize: Optional[bool] = None ) -> SelfType: """ Create an instance from a record ID. @@ -332,9 +352,15 @@ def from_id( updated. If ``False``, a new instance is created with the provided ID, but field values are unset. """ - instance = cls(id=record_id) - if fetch: + try: + instance = cast(SelfType, cls._memoized[record_id]) + except KeyError: + instance = cls(id=record_id) + if fetch and not instance._fetched: instance.fetch() + memoize = cls.meta.memoize if memoize is None else memoize + if memoize: + cls._memoized[record_id] = instance return instance def fetch(self) -> None: @@ -345,14 +371,17 @@ def fetch(self) -> None: raise ValueError("cannot be fetched because instance does not have an id") record = self.meta.table.get(self.id) - unused = self.from_record(record) + unused = self.from_record(record, memoize=False) self._fields = unused._fields + self._fetched = True self.created_time = unused.created_time @classmethod def from_ids( cls, record_ids: Iterable[RecordId], + /, + memoize: Optional[bool] = None, fetch: bool = True, ) -> List[SelfType]: """ @@ -366,16 +395,31 @@ def from_ids( updated. If ``False``, new instances are created with the provided IDs, but field values are unset. """ - record_ids = list(record_ids) if not fetch: return [cls.from_id(record_id, fetch=False) for record_id in record_ids] - # There's no endpoint to query multiple IDs at once, but we can use a formula. - formula = OR(EQ(RECORD_ID(), record_id) for record_id in record_ids) - record_data = cls.meta.table.all(formula=formula) - records = [cls.from_record(record) for record in record_data] + + record_ids = list(record_ids) + by_id: Dict[RecordId, SelfType] = {} + + if cls._memoized: + for record_id in record_ids: + try: + by_id[record_id] = cast(SelfType, cls._memoized[record_id]) + except KeyError: + pass + + if remaining := sorted(set(record_ids) - set(by_id)): + # Only retrieve records that aren't already memoized + formula = OR(EQ(RECORD_ID(), record_id) for record_id in sorted(remaining)) + by_id.update( + { + record["id"]: cls.from_record(record, memoize=memoize) + for record in cls.meta.table.all(formula=formula) + } + ) + # Ensure we return records in the same order, and raise KeyError if any are missing - records_by_id = {record.id: record for record in records} - return [records_by_id[record_id] for record_id in record_ids] + return [by_id[record_id] for record_id in record_ids] @classmethod def batch_save(cls, models: List[SelfType]) -> None: @@ -540,6 +584,10 @@ def typecast(self) -> bool: def use_field_ids(self) -> bool: return bool(self.get("use_field_ids", default=False)) + @property + def memoize(self) -> bool: + return bool(self.get("memoize", default=False)) + @property def request_kwargs(self) -> Dict[str, Any]: return { diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 359ad33b..af58dce7 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -10,6 +10,7 @@ from pyairtable.api import retrying from pyairtable.api.api import TimeoutTuple from pyairtable.api.types import AttachmentDict, CollaboratorDict, Fields, RecordDict +from pyairtable.utils import is_airtable_id def fake_id(type: str = "rec", value: Any = None) -> str: @@ -33,23 +34,27 @@ def fake_id(type: str = "rec", value: Any = None) -> str: def fake_meta( - base_id: str = "appFakeTestingApp", - table_name: str = "tblFakeTestingTbl", + base_id: str = "", + table_name: str = "", api_key: str = "patFakePersonalAccessToken", timeout: Optional[TimeoutTuple] = None, retry: Optional[Union[bool, retrying.Retry]] = None, + typecast: bool = True, use_field_ids: bool = False, + memoize: bool = False, ) -> type: """ Generate a ``Meta`` class for inclusion in a ``Model`` subclass. """ attrs = { - "base_id": base_id, - "table_name": table_name, + "base_id": base_id or fake_id("app"), + "table_name": table_name or fake_id("tbl"), "api_key": api_key, "timeout": timeout, "retry": retry, + "typecast": typecast, "use_field_ids": use_field_ids, + "memoize": memoize, } return type("Meta", (), attrs) @@ -65,14 +70,17 @@ def fake_record( >>> fake_record({"Name": "Alice"}) {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}} - >>> fake_record(name='Alice', address='123 Fake St') + >>> fake_record(name="Alice", address="123 Fake St") {'id': '...', 'createdTime': '...', 'fields': {'name': 'Alice', 'address': '123 Fake St'}} - >>> fake_record(name='Alice', id='123') + >>> fake_record(name="Alice", id="123") {'id': 'rec00000000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}} + + >>> fake_record(name="Alice", id="recABC00000000123") + {'id': 'recABC00000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}} """ return { - "id": fake_id(value=id), + "id": str(id) if is_airtable_id(id, "rec") else fake_id(value=id), "createdTime": datetime.datetime.now().isoformat() + "Z", "fields": {**(fields or {}), **other_fields}, } diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index c435bb26..e52c676b 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -223,7 +223,7 @@ def test_audit_log__sortorder( list(enterprise.audit_log(*fncall.args, **fncall.kwargs)) request = enterprise_mocks.get_audit_log.last_request - assert request.qs["sortorder"] == [sortorder] + assert request.qs["sortOrder"] == [sortorder] assert m.mock_calls[-1].kwargs["offset_field"] == offset_field diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index df8a7e8c..8d902767 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -222,7 +222,9 @@ def test_from_ids(mock_api): url=FakeModel.meta.table.url, fallback=("post", FakeModel.meta.table.url + "/listRecords"), options={ - "formula": "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in fake_ids) + "formula": ( + "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(fake_ids)) + ) }, ) assert len(contacts) == len(fake_records) @@ -306,6 +308,19 @@ def test_get_fields_by_id(fake_records_by_id): _ = getattr(fake_models[1], fake_records_by_id[0]["Age"]) +def test_meta_wrapper(): + """ + Test that Model subclasses have access to the _Meta wrapper. + """ + original_meta = fake_meta(api_key="asdf") + + class Dummy(Model): + Meta = original_meta + + assert Dummy.meta.model is Dummy + assert Dummy.meta.api.api_key == "asdf" + + def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide @@ -327,7 +342,7 @@ class Meta: f = Fake() Fake.Meta.table_name.assert_not_called() - assert f.meta.get("api_key") == data["api_key"] - assert f.meta.get("base_id") == data["base_id"] - assert f.meta.get("table_name") == data["table_name"] + assert f.meta.api_key == data["api_key"] + assert f.meta.base_id == data["base_id"] + assert f.meta.table_name == data["table_name"] Fake.Meta.table_name.assert_called_once() diff --git a/tests/test_orm_model__memoization.py b/tests/test_orm_model__memoization.py new file mode 100644 index 00000000..053d02d1 --- /dev/null +++ b/tests/test_orm_model__memoization.py @@ -0,0 +1,201 @@ +from unittest.mock import Mock + +import pytest + +from pyairtable.orm import Model +from pyairtable.orm import fields as f +from pyairtable.testing import fake_meta, fake_record + + +class Author(Model): + Meta = fake_meta(memoize=True) + name = f.TextField("Name") + books = f.LinkField["Book"]("Books", "Book") + + +class Book(Model): + Meta = fake_meta() + name = f.TextField("Title") + author = f.SingleLinkField("Author", Author) + + +@pytest.fixture(autouse=True) +def clear_memoization_and_forbid_api_calls(requests_mock): + Author._memoized.clear() + Book._memoized.clear() + + +@pytest.fixture +def record_mocks(requests_mock): + mocks = Mock() + mocks.authors = { + record["id"]: record + for record in [ + fake_record(Name="Abigail Adams"), + fake_record(Name="Babette Brown"), + fake_record(Name="Cristina Cubas"), + ] + } + mocks.books = { + record["id"]: record + for author_id, n in zip(mocks.authors, range(3)) + if (record := fake_record(Title=f"Book {n}", Author=[author_id])) + } + for book_id, book_record in mocks.books.items(): + author_record = mocks.authors[book_record["fields"]["Author"][0]] + author_record["fields"]["Books"] = [book_id] + + # for Model.all + mocks.get_authors = requests_mock.get( + Author.meta.table.url, + json={"records": list(mocks.authors.values())}, + ) + mocks.get_books = requests_mock.get( + Book.meta.table.url, + json={"records": list(mocks.books.values())}, + ) + + # for Model.from_id + mocks.get_author = { + record_id: requests_mock.get( + Author.meta.table.record_url(record_id), json=record_data + ) + for record_id, record_data in mocks.authors.items() + } + mocks.get_book = { + record_id: requests_mock.get( + Book.meta.table.record_url(record_id), json=record_data + ) + for record_id, record_data in mocks.books.items() + } + mocks.get = {**mocks.get_author, **mocks.get_book} + + return mocks + + +parametrized_memoization_test = pytest.mark.parametrize( + "cls,kwargs,expect_memoized", + [ + # Meta.memoize is True, memoize= is not provided + (Author, {}, True), + # Meta.memoize is True, memoize=False + (Author, {"memoize": False}, False), + # Meta.memoize is False, memoize= is not provided + (Book, {}, False), + # Meta.memoize is False, memoize=True + (Book, {"memoize": True}, True), + ], +) + + +@parametrized_memoization_test +def test_memoize__from_record(cls, kwargs, expect_memoized): + """ + Test whether Model.from_record saves objects to Model._memoized + """ + obj = cls.from_record(fake_record(), **kwargs) + assert_memoized(obj, expect_memoized) + + +@parametrized_memoization_test +def test_memoize__from_id(record_mocks, cls, kwargs, expect_memoized): + """ + Test whether Model.from_id saves objects to Model._memoized + """ + record_id = list(getattr(record_mocks, cls.__name__.lower() + "s"))[0] + obj = cls.from_id(record_id, **kwargs) + assert record_mocks.get[record_id].call_count == 1 + assert_memoized(obj, expect_memoized) + + +@parametrized_memoization_test +def test_memoize__all(record_mocks, cls, kwargs, expect_memoized): + """ + Test whether Model.all saves objects to Model._memoized + """ + for obj in cls.all(**kwargs): + assert_memoized(obj, expect_memoized) + + +@parametrized_memoization_test +def test_memoize__first(record_mocks, cls, kwargs, expect_memoized): + """ + Test whether Model.all saves objects to Model._memoized + """ + assert_memoized(cls.first(**kwargs), expect_memoized) + + +def assert_memoized(obj: Model, expect_memoized: bool = True): + if expect_memoized: + assert obj.__class__._memoized[obj.id] is obj + else: + assert obj.id not in obj.__class__._memoized + + +def test_from_id(): + """ + Test that Model.from_id pulls from Model._memoized, regardless + of whether Model.Meta.memoize is True or False. + """ + book = Book.from_record(fake_record()) + Book._memoized[book.id] = book + assert Book.from_id(book.id) is book + + +def test_from_ids(record_mocks): + """ + Test that Model.from_ids pulls from Model._memoized, regardless + of whether Model.Meta.memoize is True or False. + """ + book = Book.from_record(fake_record()) + Book._memoized = {book.id: book} + books = Book.from_ids([book.id, *list(record_mocks.books)]) + # We got all four, but only requested the non-memoized three from the API + assert len(books) == 4 + assert record_mocks.get_books.call_count == 1 + assert record_mocks.get_books.last_request.qs["filterByFormula"] == [ + "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(record_mocks.books)) + ] + + +def test_memoize__link_field(record_mocks): + """ + Test that Model.link_field writes to Model._memoized if Model.Meta.memoize is True + """ + book_id = list(record_mocks.books)[0] + book = Book.from_id(book_id) + assert record_mocks.get[book_id].call_count == 1 + + # no memoization yet + assert not Book._memoized + assert not Author._memoized + + book.author # this makes the call + assert book.author.id == record_mocks.books[book_id]["fields"]["Author"][0] + assert Author._memoized[book.author.id] is book.author + + # test that we only ever made one network call per object + assert record_mocks.get[book.id].call_count == 1 + assert record_mocks.get[book.author.id].call_count == 0 + assert record_mocks.get_authors.call_count == 1 + assert record_mocks.get_authors.last_request.qs["filterByFormula"] == [ + f"OR(RECORD_ID()='{book.author.id}')" + ] + + +def test_memoize__link_field__populate(record_mocks): + """ + Test that Model.link_field.populate writes to Model._memoized if memoize=True + """ + author_id = list(record_mocks.authors)[0] + author = Author.from_id(author_id) + Author.books.populate(author, memoize=True) + assert len(author.books) == 1 + for book in author.books: + assert Book._memoized[book.id] is book + assert record_mocks.get[book.id].call_count == 0 + # test that we only ever made one network call + assert record_mocks.get_books.call_count == 1 + assert record_mocks.get_books.last_request.qs["filterByFormula"] == [ + "OR(%s)" % ", ".join(f"RECORD_ID()='{book.id}'" for book in author.books) + ] diff --git a/tests/test_testing.py b/tests/test_testing.py index 93ca3d9e..98b3d301 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -15,6 +15,11 @@ call(id=123), {"id": "rec00000000000123", "createdTime": ANY, "fields": {}}, ), + ( + "fake_record", + call(id="recABC00000000123"), + {"id": "recABC00000000123", "createdTime": ANY, "fields": {}}, + ), ( "fake_record", call({"A": 1}, 123), diff --git a/tox.ini b/tox.ini index b3c023cb..2fc856ad 100644 --- a/tox.ini +++ b/tox.ini @@ -53,6 +53,7 @@ commands = python -m sphinx -T -E -b html {toxinidir}/docs/source {toxinidir}/docs/build [pytest] +requests_mock_case_sensitive = true markers = integration: integration tests, hit airtable api From 8321483475cd9c67ee0cae8c5d792a493c6826c6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 3 Apr 2024 11:01:40 -0700 Subject: [PATCH 137/272] Documentation for ORM memoization --- docs/source/_substitutions.rst | 14 +++++++ docs/source/changelog.rst | 1 + docs/source/orm.rst | 70 ++++++++++++++++++++++++++++++++++ pyairtable/orm/fields.py | 46 +++++++++------------- pyairtable/orm/model.py | 30 +++++++++------ pyairtable/testing.py | 2 + pyairtable/utils.py | 12 ++++-- tests/test_orm_model.py | 33 +++++++++++++++- 8 files changed, 164 insertions(+), 44 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index 1aea2549..b5e230ca 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -69,6 +69,20 @@ If ``True``, will fetch information from the metadata API and validate the ID/name exists, raising ``KeyError`` if it does not. +.. |kwarg_orm_fetch| replace:: + If ``True``, records will be fetched and field values will be + updated. If ``False``, new instances are created with the provided IDs, + but field values are unset. + +.. |kwarg_orm_memoize| replace:: + If ``True``, any objects created will be memoized for future reuse. + If ``False``, objects created will *not* be memoized. + The default behavior is defined on the :class:`~pyairtable.orm.Model` subclass. + +.. |kwarg_orm_lazy| replace:: + If ``True``, this field will return empty objects with only IDs; + call :meth:`~pyairtable.orm.Model.fetch` to retrieve values. + .. |kwarg_permission_level| replace:: See `application permission levels `__. diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 14e39ab4..a05567f1 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -27,6 +27,7 @@ Changelog * Added ORM fields that :ref:`require a non-null value `. - `PR #363 `_. * Refactored methods for accessing ORM model configuration. +* Added support for :ref:`memoization of ORM models `. 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/orm.rst b/docs/source/orm.rst index c2313a78..dfa156b4 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -467,6 +467,76 @@ there are four components: 4. The model class, the path to the model class, or :data:`~pyairtable.orm.fields.LinkSelf` +Memoizing linked records +""""""""""""""""""""""""""""" + +There are cases where your application may need to retrieve hundreds of nested +models through the ORM, and you don't want to make hundreds of Airtable API calls. +pyAirtable provides a way to pre-fetch and memoize instances for each record, +which will then be reused later by record link fields. + +The usual way to do this is passing ``memoize=True`` to a retrieval method +at the beginning of your code to pre-fetch any records you might need. +For example, you might have the following: + +.. code-block:: python + + from pyairtable.orm import Model, fields as F + from operator import attrgetter + + class Book(Model): + class Meta: ... + title = F.TextField("Title") + published = F.DateField("Publication Date") + + class Author(Model): + class Meta: ... + name = F.TextField("Name") + books = F.LinkField("Books", Book) + + def main(): + books = Book.all(memoize=True) + authors = Author.all(memoize=True) + for author in authors: + print(f"* {author.name}") + for book in sorted(author.books, key=attrgetter("published")): + print(f" - {book.title} ({book.published.isoformat()})") + +This code will perform a series of API calls at the beginning to fetch +all records from the Books and Authors tables, so that ``author.books`` +does not need to request linked records one at a time during the loop. + +You can also set ``memoize = True`` in the ``Meta`` configuration for your model, +which indicates that you always want to memoize models retrieved from the API: + +.. code-block:: python + + class Book(Model): + Meta = {..., "memoize": True} + title = F.TextField("Title") + + class Author(Model): + Meta = {...} + name = F.TextField("Name") + books = F.LinkField("Books", Book) + + Book.first() # this will memoize the object it creates + Author.first().books # this will memoize all objects created + Book.all(memoize=False) # this will skip memoization + +The following methods support the ``memoize=`` keyword argument. +You can pass ``memoize=False`` to override memoization that is +enabled on the model configuration. + + * :meth:`Model.all ` + * :meth:`Model.first ` + * :meth:`Model.from_record ` + * :meth:`Model.from_id ` + * :meth:`Model.from_ids ` + * :meth:`LinkField.populate ` + * :meth:`SingleLinkField.populate ` + + Comments ---------- diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index a8cdb55e..e062307b 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -617,8 +617,13 @@ def populate( ) -> None: """ Populates the field's value for the given instance. This allows you to - selectively load models in either lazy or non-lazy fashion, depending on - your need, without having to decide at the time of field construction. + control how linked models are loaded, depending on your need, without + having to decide at the time of field or model construction. + + Args: + instance: An instance of this field's :class:`~pyairtable.orm.Model` class. + lazy: |kwarg_orm_lazy| + memoize: |kwarg_orm_memoize| Usage: @@ -634,7 +639,7 @@ class Meta: ... books = F.LinkField("Books", Book) author = Author.from_id("reculZ6qSLw0OCA61") - Author.books.populate(author, lazy=True) + Author.books.populate(author, lazy=True, memoize=False) """ if self._model and not isinstance(instance, self._model): raise RuntimeError( @@ -755,6 +760,14 @@ class Meta: ... """ + @utils.docstring_from( + LinkField.__init__, + append=""" + raise_if_many: If ``True``, this field will raise a + :class:`~pyairtable.orm.fields.MultipleValues` exception upon + being accessed if the underlying field contains multiple values. + """, + ) def __init__( self, field_name: str, @@ -764,31 +777,6 @@ def __init__( lazy: bool = False, raise_if_many: bool = False, ): - """ - Args: - field_name: Name of the Airtable field. - model: - Model class representing the linked table. There are a few options: - - 1. You can provide a ``str`` that is the fully qualified module and class name. - For example, ``"your.module.Model"`` will import ``Model`` from ``your.module``. - 2. You can provide a ``str`` that is *just* the class name, and it will be imported - from the same module as the model class. - 3. You can provide the sentinel value :data:`~LinkSelf`, and the link field - will point to the same model where the link field is created. - - validate_type: Whether to raise a TypeError if attempting to write - an object of an unsupported type as a field value. If ``False``, you - may encounter unpredictable behavior from the Airtable API. - readonly: If ``True``, any attempt to write a value to this field will - raise an ``AttributeError``. This will not, however, prevent any - modification of the list object returned by this field. - lazy: If ``True``, this field will return empty objects with only IDs; - call :meth:`~pyairtable.orm.Model.fetch` to retrieve values. - raise_if_many: If ``True``, this field will raise a - :class:`~pyairtable.orm.fields.MultipleValues` exception upon - being accessed if the underlying field contains multiple values. - """ super().__init__(field_name, validate_type=validate_type, readonly=readonly) self._raise_if_many = raise_if_many # composition is easier than inheritance in this case ยฏ\_(ใƒ„)_/ยฏ @@ -840,6 +828,7 @@ def __set_name__(self, owner: Any, name: str) -> None: def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: return self._link_field.to_record_value(value) + @utils.docstring_from(LinkField.populate) def populate( self, instance: "Model", @@ -850,6 +839,7 @@ def populate( self._link_field.populate(instance, lazy=lazy, memoize=memoize) @property + @utils.docstring_from(LinkField.linked_model) def linked_model(self) -> Type[T_Linked]: return self._link_field.linked_model diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 176d0aaf..7b6dfc4d 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -51,12 +51,12 @@ class Model: * ``timeout`` - A tuple indicating a connect and read timeout. Defaults to no timeout. * ``typecast`` - |kwarg_typecast| Defaults to ``True``. * ``retry`` - An instance of `urllib3.util.Retry `_. - If ``None`` or ``False``, requests will not be retried. - If ``True``, the default strategy will be applied - (see :func:`~pyairtable.retry_strategy` for details). + If ``None`` or ``False``, requests will not be retried. + If ``True``, the default strategy will be applied + (see :func:`~pyairtable.retry_strategy` for details). * ``use_field_ids`` - Whether fields will be defined by ID, rather than name. Defaults to ``False``. * ``memoize`` - Whether the model should reuse models it creates between requests. - See :ref:`ORM memoization` for more information. + See :ref:`Memoization` for more information. For example, the following two are equivalent: @@ -267,6 +267,9 @@ def all(cls, /, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType] """ Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. + + Args: + memoize: |kwarg_orm_memoize| """ kwargs.update(cls.meta.request_kwargs) return [ @@ -281,6 +284,9 @@ def first( """ Retrieve the first record for this model. For all supported keyword arguments, see :meth:`Table.first `. + + Args: + memoize: |kwarg_orm_memoize| """ kwargs.update(cls.meta.request_kwargs) if record := cls.meta.table.first(**kwargs): @@ -314,6 +320,10 @@ def from_record( ) -> SelfType: """ Create an instance from a record dict. + + Args: + record: The record data from the Airtable API. + memoize: |kwarg_orm_memoize| """ name_field_map = cls._field_name_descriptor_map() # Convert Column Names into model field names @@ -348,9 +358,8 @@ def from_id( Args: record_id: |arg_record_id| - fetch: If ``True``, record will be fetched and field values will be - updated. If ``False``, a new instance is created with the provided ID, - but field values are unset. + fetch: |kwarg_orm_fetch| + memoize: |kwarg_orm_memoize| """ try: instance = cast(SelfType, cls._memoized[record_id]) @@ -381,8 +390,8 @@ def from_ids( cls, record_ids: Iterable[RecordId], /, - memoize: Optional[bool] = None, fetch: bool = True, + memoize: Optional[bool] = None, ) -> List[SelfType]: """ Create a list of instances from record IDs. If any record IDs returned @@ -391,9 +400,8 @@ def from_ids( Args: record_ids: |arg_record_id| - fetch: If ``True``, records will be fetched and field values will be - updated. If ``False``, new instances are created with the provided IDs, - but field values are unset. + fetch: |kwarg_orm_fetch| + memoize: |kwarg_orm_memoize| """ if not fetch: return [cls.from_id(record_id, fetch=False) for record_id in record_ids] diff --git a/pyairtable/testing.py b/pyairtable/testing.py index af58dce7..ccbdd7a3 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -53,6 +53,8 @@ def fake_meta( "timeout": timeout, "retry": retry, "typecast": typecast, + "timeout": timeout, + "retry": retry, "use_field_ids": use_field_ids, "memoize": memoize, } diff --git a/pyairtable/utils.py b/pyairtable/utils.py index b89297e8..81b01a72 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -25,6 +25,7 @@ P = ParamSpec("P") R = TypeVar("R", covariant=True) T = TypeVar("T") +F = TypeVar("F", bound=Callable[..., Any]) def datetime_to_iso_str(value: datetime) -> str: @@ -141,9 +142,6 @@ def is_airtable_id(value: Any, prefix: str = "") -> bool: is_user_id = partial(is_airtable_id, prefix="usr") -F = TypeVar("F", bound=Callable[..., Any]) - - def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F: """ Wrap a function or method so that if Airtable returns a 404, @@ -193,6 +191,14 @@ def _append_docstring_text(obj: Any, text: str) -> None: obj.__doc__ = f"{doc}\n\n{text}" +def docstring_from(obj: Any, append: str = "") -> Callable[[F], F]: + def _wrapper(func: F) -> F: + func.__doc__ = obj.__doc__ + append + return func + + return _wrapper + + class FetchMethod(Protocol, Generic[R]): def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: ... diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 8d902767..6b25bd5c 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -312,15 +312,44 @@ def test_meta_wrapper(): """ Test that Model subclasses have access to the _Meta wrapper. """ - original_meta = fake_meta(api_key="asdf") class Dummy(Model): - Meta = original_meta + Meta = fake_meta(api_key="asdf") assert Dummy.meta.model is Dummy assert Dummy.meta.api.api_key == "asdf" +def test_meta_dict(): + """ + Test that Meta can be a dict instead of a class. + """ + + class Dummy(Model): + Meta = { + "api_key": "asdf", + "base_id": "qwer", + "table_name": "zxcv", + "timeout": (1, 1), + } + + assert Dummy.meta.model is Dummy + assert Dummy.meta.api.api_key == "asdf" + + +@pytest.mark.parametrize("meta_kwargs", [{"timeout": 1}, {"retry": "asdf"}]) +def test_meta_type_check(meta_kwargs): + """ + Test that we check types on certain Meta attributes. + """ + + class Dummy(Model): + Meta = fake_meta(**meta_kwargs) + + with pytest.raises(TypeError): + Dummy.meta.api + + def test_dynamic_model_meta(): """ Test that we can provide callables in our Meta class to provide From 193a533e9b7d872282f11f8d0d63d149ab200ff5 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 29 Apr 2024 14:37:03 -0700 Subject: [PATCH 138/272] Fix keyword-only parameter notation --- pyairtable/orm/fields.py | 4 ++-- pyairtable/orm/model.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index e062307b..1b92d6ad 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -611,7 +611,7 @@ def _repr_fields(self) -> List[Tuple[str, Any]]: def populate( self, instance: "Model", - /, + *, lazy: Optional[bool] = None, memoize: Optional[bool] = None, ) -> None: @@ -832,7 +832,7 @@ def to_record_value(self, value: List[Union[str, T_Linked]]) -> List[str]: def populate( self, instance: "Model", - /, + *, lazy: Optional[bool] = None, memoize: Optional[bool] = None, ) -> None: diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 7b6dfc4d..a8d3f6c4 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -263,7 +263,7 @@ def delete(self) -> bool: return bool(result["deleted"]) @classmethod - def all(cls, /, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType]: + def all(cls, *, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType]: """ Retrieve all records for this model. For all supported keyword arguments, see :meth:`Table.all `. @@ -279,7 +279,7 @@ def all(cls, /, memoize: Optional[bool] = None, **kwargs: Any) -> List[SelfType] @classmethod def first( - cls, /, memoize: Optional[bool] = None, **kwargs: Any + cls, *, memoize: Optional[bool] = None, **kwargs: Any ) -> Optional[SelfType]: """ Retrieve the first record for this model. For all supported @@ -316,7 +316,7 @@ def to_record(self, only_writable: bool = False) -> RecordDict: @classmethod def from_record( - cls, record: RecordDict, /, memoize: Optional[bool] = None + cls, record: RecordDict, *, memoize: Optional[bool] = None ) -> SelfType: """ Create an instance from a record dict. @@ -351,7 +351,11 @@ def from_record( @classmethod def from_id( - cls, record_id: RecordId, /, fetch: bool = True, memoize: Optional[bool] = None + cls, + record_id: RecordId, + *, + fetch: bool = True, + memoize: Optional[bool] = None, ) -> SelfType: """ Create an instance from a record ID. @@ -389,7 +393,7 @@ def fetch(self) -> None: def from_ids( cls, record_ids: Iterable[RecordId], - /, + *, fetch: bool = True, memoize: Optional[bool] = None, ) -> List[SelfType]: From ca27517bd18b52ec2c84caeabad4cbf04395c1b2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 2 May 2024 16:12:05 -0700 Subject: [PATCH 139/272] Fix type annotations for py312, and run mypy on py38-py312 --- pyairtable/utils.py | 13 +++++++------ tox.ini | 22 ++++++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 81b01a72..c4d44205 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -25,6 +25,7 @@ P = ParamSpec("P") R = TypeVar("R", covariant=True) T = TypeVar("T") +C = TypeVar("C", contravariant=True) F = TypeVar("F", bound=Callable[..., Any]) @@ -199,13 +200,13 @@ def _wrapper(func: F) -> F: return _wrapper -class FetchMethod(Protocol, Generic[R]): - def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: ... +class FetchMethod(Protocol, Generic[C, R]): + def __get__(self, instance: C, owner: Any) -> Callable[..., R]: ... - def __call__(self_, self: Any, *, force: bool = False) -> R: ... + def __call__(self_, self: C, *, force: bool = False) -> R: ... -def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: +def cache_unless_forced(func: Callable[[C], R]) -> FetchMethod[C, R]: """ Wrap a method (e.g. ``Base.shares()``) in a decorator that will save a memoized version of the return value for future reuse, but will also @@ -217,7 +218,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]: attr = "_cached_" + attr.lstrip("_") @wraps(func) - def _inner(self: Any, *, force: bool = False) -> R: + def _inner(self: C, *, force: bool = False) -> R: if force or getattr(self, attr, None) is None: setattr(self, attr, func(self)) return cast(R, getattr(self, attr)) @@ -225,7 +226,7 @@ def _inner(self: Any, *, force: bool = False) -> R: _inner.__annotations__["force"] = bool _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|") - return _inner + return cast(FetchMethod[C, R], _inner) def coerce_iso_str(value: Any) -> Optional[str]: diff --git a/tox.ini b/tox.ini index 2fc856ad..43e0ff96 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,30 @@ [tox] envlist = pre-commit - mypy + mypy-py3{8,9,10,11,12} py3{8,9,10,11,12}{,-pydantic1,-requestsmin} integration coverage [gh-actions] python = - 3.8: py38, mypy - 3.9: py39, mypy - 3.10: py310, mypy - 3.11: py311, mypy - 3.12: coverage, mypy + 3.8: py38, mypy-py38 + 3.9: py39, mypy-py39 + 3.10: py310, mypy-py310 + 3.11: py311, mypy-py311 + 3.12: coverage, mypy-py312 [testenv:pre-commit] deps = pre-commit commands = pre-commit run --all-files -[testenv:mypy] +[testenv:mypy-py3{8,9,10,11,12}] +basepython = + py38: python3.8 + py39: python3.9 + py310: python3.10 + py311: python3.11 + py312: python3.12 deps = -r requirements-dev.txt commands = mypy --strict pyairtable tests/test_typing.py @@ -61,7 +67,7 @@ markers = filename = *.py count = True # See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html -ignore = E203, E266, E501, E704, W503 +ignore = E203, E226, E266, E501, E704, W503 select = B,C,E,F,W,T4,B9 max-line-length = 88 max-complexity = 15 From 348e0ffe2a78b5a88eceb195c27c663f788e008a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 6 May 2024 16:25:45 -0700 Subject: [PATCH 140/272] DRY up memoization logic, in case it gets more complex later --- pyairtable/orm/model.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index a8d3f6c4..0b7455f8 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -293,6 +293,15 @@ def first( return cls.from_record(record, memoize=memoize) return None + @classmethod + def _maybe_memoize(cls, instance: SelfType, memoize: Optional[bool]) -> None: + """ + If memoization is enabled, save the instance to the memoization cache. + """ + memoize = cls.meta.memoize if memoize is None else memoize + if memoize: + cls._memoized[instance.id] = instance + def to_record(self, only_writable: bool = False) -> RecordDict: """ Build a :class:`~pyairtable.api.types.RecordDict` to represent this instance. @@ -344,9 +353,7 @@ def from_record( instance._fields = field_values instance._fetched = True instance.created_time = datetime_from_iso_str(record["createdTime"]) - memoize = cls.meta.memoize if memoize is None else memoize - if memoize: - cls._memoized[instance.id] = instance + cls._maybe_memoize(instance, memoize) return instance @classmethod @@ -371,9 +378,7 @@ def from_id( instance = cls(id=record_id) if fetch and not instance._fetched: instance.fetch() - memoize = cls.meta.memoize if memoize is None else memoize - if memoize: - cls._memoized[record_id] = instance + cls._maybe_memoize(instance, memoize) return instance def fetch(self) -> None: From 87a767f619eca35974f656f68e1e9c18e7e48280 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 10 May 2024 10:53:14 -0700 Subject: [PATCH 141/272] Fix docs for memoization --- docs/source/changelog.rst | 2 +- docs/source/orm.rst | 10 ++++++++-- pyairtable/orm/model.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a05567f1..e91e8583 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -27,7 +27,7 @@ Changelog * Added ORM fields that :ref:`require a non-null value `. - `PR #363 `_. * Refactored methods for accessing ORM model configuration. -* Added support for :ref:`memoization of ORM models `. +* Added support for :ref:`memoization of ORM models `. 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/orm.rst b/docs/source/orm.rst index dfa156b4..3a37cc47 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -506,6 +506,12 @@ This code will perform a series of API calls at the beginning to fetch all records from the Books and Authors tables, so that ``author.books`` does not need to request linked records one at a time during the loop. +.. note:: + Memoization does not affect whether pyAirtable will make an API call. + It only affects whether pyAirtable will reuse a model instance that + was already created, or create a new one. For example, calling + ``model.all(memoize=True)`` N times will still result in N calls to the API. + You can also set ``memoize = True`` in the ``Meta`` configuration for your model, which indicates that you always want to memoize models retrieved from the API: @@ -520,8 +526,8 @@ which indicates that you always want to memoize models retrieved from the API: name = F.TextField("Name") books = F.LinkField("Books", Book) - Book.first() # this will memoize the object it creates - Author.first().books # this will memoize all objects created + Book.first() # this will memoize the book it creates + Author.first().books # this will memoize all books created Book.all(memoize=False) # this will skip memoization The following methods support the ``memoize=`` keyword argument. diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 0b7455f8..d661d7f4 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -56,7 +56,7 @@ class Model: (see :func:`~pyairtable.retry_strategy` for details). * ``use_field_ids`` - Whether fields will be defined by ID, rather than name. Defaults to ``False``. * ``memoize`` - Whether the model should reuse models it creates between requests. - See :ref:`Memoization` for more information. + See :ref:`Memoizing linked records` for more information. For example, the following two are equivalent: From dba1cb347825cdc709c24c81843c8c106b8043e5 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 10 May 2024 11:04:02 -0700 Subject: [PATCH 142/272] Release 3.0.0a2 --- docs/source/changelog.rst | 2 ++ pyairtable/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e91e8583..9b7036ae 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -27,7 +27,9 @@ Changelog * Added ORM fields that :ref:`require a non-null value `. - `PR #363 `_. * Refactored methods for accessing ORM model configuration. + - `PR #366 `_. * Added support for :ref:`memoization of ORM models `. + - `PR #369 `_. 2.3.3 (2024-03-22) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index c7d7a050..fed194b0 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0a1" +__version__ = "3.0.0a2" from .api import Api, Base, Table from .api.enterprise import Enterprise From 1d3347a30157305b8df4025a6fde6012807b43fb Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 13 May 2024 10:00:17 -0700 Subject: [PATCH 143/272] Address edge cases in `formulas` module --- pyairtable/formulas.py | 105 ++++++++++++++++++++++++++++------------- tests/test_formulas.py | 88 +++++++++++++++++++++++++++++----- 2 files changed, 148 insertions(+), 45 deletions(-) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index a6be5747..da3bbeea 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -38,17 +38,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"{self.__class__.__name__}({self.value!r})" - def __and__(self, other: "Formula") -> "Formula": - return AND(self, other) + def __and__(self, other: Any) -> "Formula": + return AND(self, to_formula(other)) - def __or__(self, other: "Formula") -> "Formula": - return OR(self, other) + def __or__(self, other: Any) -> "Formula": + return OR(self, to_formula(other)) - def __xor__(self, other: "Formula") -> "Formula": - return XOR(self, other) + def __xor__(self, other: Any) -> "Formula": + return XOR(self, to_formula(other)) def __eq__(self, other: Any) -> bool: - if not isinstance(other, Formula): + if not isinstance(other, type(self)): return False return other.value == self.value @@ -56,41 +56,44 @@ def __invert__(self) -> "Formula": return NOT(self) def flatten(self) -> "Formula": + """ + Return a new formula with nested boolean statements flattened. + """ return self def eq(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.EQ` comparison using this field. + Build an :class:`~pyairtable.formulas.EQ` comparison using this formula. """ return EQ(self, value) def ne(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.NE` comparison using this field. + Build an :class:`~pyairtable.formulas.NE` comparison using this formula. """ return NE(self, value) def gt(self, value: Any) -> "Comparison": """ - Build a :class:`~pyairtable.formulas.GT` comparison using this field. + Build a :class:`~pyairtable.formulas.GT` comparison using this formula. """ return GT(self, value) def lt(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.LT` comparison using this field. + Build an :class:`~pyairtable.formulas.LT` comparison using this formula. """ return LT(self, value) def gte(self, value: Any) -> "Comparison": """ - Build a :class:`~pyairtable.formulas.GTE` comparison using this field. + Build a :class:`~pyairtable.formulas.GTE` comparison using this formula. """ return GTE(self, value) def lte(self, value: Any) -> "Comparison": """ - Build an :class:`~pyairtable.formulas.LTE` comparison using this field. + Build an :class:`~pyairtable.formulas.LTE` comparison using this formula. """ return LTE(self, value) @@ -101,7 +104,7 @@ class Field(Formula): """ def __str__(self) -> str: - return "{%s}" % escape_quotes(self.value) + return field_name(self.value) class Comparison(Formula): @@ -372,6 +375,56 @@ def match(field_values: Fields, *, match_any: bool = False) -> Formula: return AND(*expressions) +def to_formula(value: Any) -> Formula: + """ + Converts the given value into a Formula object. + + When given a Formula object, it returns the object as-is: + + >>> to_formula(EQ(F.Formula("a"), "b")) + EQ(Formula('a'), 'b') + + When given a scalar value, it simply wraps that value's string representation + in a Formula object: + + >>> to_formula(1) + Formula('1') + >>> to_formula('foo') + Formula("'foo'") + + Boolean and date values receive custom function calls: + + >>> to_formula(True) + TRUE() + >>> to_formula(False) + FALSE() + >>> to_formula(datetime.date(2023, 12, 1)) + DATETIME_PARSE('2023-12-01') + >>> to_formula(datetime.datetime(2023, 12, 1, 12, 34, 56)) + DATETIME_PARSE('2023-12-01T12:34:56.000Z') + """ + if isinstance(value, Formula): + return value + if isinstance(value, bool): + return TRUE() if value else FALSE() + if isinstance(value, (int, float, Decimal, Fraction)): + return Formula(str(value)) + if isinstance(value, str): + return Formula(quoted(value)) + if isinstance(value, datetime.datetime): + return DATETIME_PARSE(datetime_to_iso_str(value)) + if isinstance(value, datetime.date): + return DATETIME_PARSE(date_to_iso_str(value)) + + # Runtime import to avoid circular dependency + import pyairtable.orm + + if isinstance(value, pyairtable.orm.fields.Field): + return Field(value.field_name) + + raise TypeError(value, type(value)) + + def to_formula_str(value: Any) -> str: """ Converts the given value into a string representation that can be used @@ -400,24 +453,7 @@ def to_formula_str(value: Any) -> str: >>> to_formula_str(datetime.datetime(2023, 12, 1, 12, 34, 56)) "DATETIME_PARSE('2023-12-01T12:34:56.000Z')" """ - # Runtime import to avoid circular dependency - from pyairtable import orm - - if isinstance(value, Formula): - return str(value) - if isinstance(value, bool): - return "TRUE()" if value else "FALSE()" - if isinstance(value, (int, float, Decimal, Fraction)): - return str(value) - if isinstance(value, str): - return "'{}'".format(escape_quotes(value)) - if isinstance(value, datetime.datetime): - return str(DATETIME_PARSE(datetime_to_iso_str(value))) - if isinstance(value, datetime.date): - return str(DATETIME_PARSE(date_to_iso_str(value))) - if isinstance(value, orm.fields.Field): - return field_name(value.field_name) - raise TypeError(value, type(value)) + return str(to_formula(value)) def quoted(value: str) -> str: @@ -463,7 +499,10 @@ def field_name(name: str) -> str: >>> field_name("Guest's Name") "{Guest\\'s Name}" """ - return "{%s}" % escape_quotes(name) + # This will not actually work with field names that contain more + # than one closing curly brace; that's a limitation of Airtable. + # Our library will escape all closing braces, but the API will fail. + return "{%s}" % escape_quotes(name.replace("}", r"\}")) class FunctionCall(Formula): diff --git a/tests/test_formulas.py b/tests/test_formulas.py index b7b44c17..0eb8a3a3 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -213,6 +213,48 @@ def test_not(): NOT() +@pytest.mark.parametrize( + "input,expected", + [ + (EQ(F.Formula("a"), "b"), EQ(F.Formula("a"), "b")), + (True, F.TRUE()), + (False, F.FALSE()), + (3, F.Formula("3")), + (3.5, F.Formula("3.5")), + (Decimal("3.14159265"), F.Formula("3.14159265")), + (Fraction("4/19"), F.Formula("4/19")), + ("asdf", F.Formula("'asdf'")), + ("Jane's", F.Formula("'Jane\\'s'")), + ([1, 2, 3], TypeError), + ((1, 2, 3), TypeError), + ({1, 2, 3}, TypeError), + ({1: 2, 3: 4}, TypeError), + ( + date(2023, 12, 1), + F.DATETIME_PARSE("2023-12-01"), + ), + ( + datetime(2023, 12, 1, 12, 34, 56), + F.DATETIME_PARSE("2023-12-01T12:34:56.000"), + ), + ( + datetime(2023, 12, 1, 12, 34, 56, tzinfo=timezone.utc), + F.DATETIME_PARSE("2023-12-01T12:34:56.000Z"), + ), + (orm.fields.Field("Foo"), F.Field("Foo")), + ], +) +def test_to_formula(input, expected): + """ + Test that certain values are not changed at all by to_formula() + """ + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + F.to_formula(input) + else: + assert F.to_formula(input) == expected + + @pytest.mark.parametrize( "input,expected", [ @@ -241,9 +283,10 @@ def test_not(): datetime(2023, 12, 1, 12, 34, 56, tzinfo=timezone.utc), "DATETIME_PARSE('2023-12-01T12:34:56.000Z')", ), + (orm.fields.Field("Foo"), "{Foo}"), ], ) -def test_to_formula(input, expected): +def test_to_formula_str(input, expected): if isinstance(expected, type) and issubclass(expected, Exception): with pytest.raises(expected): F.to_formula_str(input) @@ -286,9 +329,16 @@ def test_function_call_equivalence(): assert F.TODAY() != F.Formula("TODAY()") -def test_field_name(): - assert F.field_name("First Name") == "{First Name}" - assert F.field_name("Guest's Name") == "{Guest\\'s Name}" +@pytest.mark.parametrize( + "input,expected", + [ + ("First Name", "{First Name}"), + ("Guest's Name", r"{Guest\'s Name}"), + ("With {Curly Braces}", r"{With {Curly Braces\}}"), + ], +) +def test_field_name(input, expected): + assert F.field_name(input) == expected def test_quoted(): @@ -296,6 +346,13 @@ def test_quoted(): assert F.quoted("Guest's Name") == "'Guest\\'s Name'" +class FakeModel(orm.Model): + Meta = fake_meta() + name = orm.fields.TextField("Name") + email = orm.fields.EmailField("Email") + phone = orm.fields.PhoneNumberField("Phone") + + @pytest.mark.parametrize( "methodname,op", [ @@ -307,15 +364,22 @@ def test_quoted(): ("lte", "<="), ], ) -def test_orm_field(methodname, op): - class FakeModel(orm.Model): - Meta = fake_meta() - name = orm.fields.TextField("Name") - age = orm.fields.IntegerField("Age") - +def test_orm_field_comparison_shortcuts(methodname, op): + """ + Test each shortcut method on an ORM field. + """ formula = getattr(FakeModel.name, methodname)("Value") - formula &= GTE(FakeModel.age, 21) - assert F.to_formula_str(formula) == f"AND({{Name}}{op}'Value', {{Age}}>=21)" + assert F.to_formula_str(formula) == f"{{Name}}{op}'Value'" + + +def test_orm_field_as_formula(): + """ + Test different ways of using an ORM field in a formula. + """ + formula = FakeModel.email.ne(F.BLANK()) | NE(FakeModel.phone, F.BLANK()) + formula &= FakeModel.name + result = F.to_formula_str(formula.flatten()) + assert result == "AND(OR({Email}!=BLANK(), {Phone}!=BLANK()), {Name})" @pytest.mark.parametrize( From 4907ada9b89dca0a1442bfd2196a1a2b0aa6716d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 14 May 2024 08:34:42 -0700 Subject: [PATCH 144/272] Add support for grant/revoke admin access --- docs/source/enterprise.rst | 12 +++++++++ docs/source/migrations.rst | 12 +++++++++ pyairtable/api/enterprise.py | 50 +++++++++++++++++++++++++++++++----- pyairtable/models/schema.py | 2 ++ tests/test_api_enterprise.py | 25 ++++++++++++++++-- 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index a1d5c439..d43a229c 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -158,3 +158,15 @@ via the following methods. `Delete users by email `__ >>> enterprise.delete_users(["foo@example.com", "bar@example.com"]) + +`Grant admin access `__ + + >>> enterprise.grant_admin("usrUserId") + >>> enterprise.grant_admin("user@example.com") + >>> enterprise.grant_admin(enterprise.user("usrUserId")) + +`Revoke admin access `__ + + >>> enterprise.revoke_admin("usrUserId") + >>> enterprise.revoke_admin("user@example.com") + >>> enterprise.revoke_admin(enterprise.user("usrUserId")) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 271d71a6..79245b45 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -73,6 +73,18 @@ The 3.0 release has changed the API for retrieving ORM model configuration: * - ``Model._get_meta(name)`` - ``Model.meta.get(name)`` +Miscellaneous name changes +--------------------------------------------- + +.. list-table:: + :header-rows: 1 + + * - Old name + - New name + * - :class:`~pyairtable.api.enterprise.ClaimUsersResponse` + - :class:`~pyairtable.api.enterprise.ManageUsersResponse` + + Migrating from 2.2 to 2.3 ============================ diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 5eaa7118..5dee6727 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,6 +1,7 @@ import datetime from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union +from pyairtable._compat import pydantic from pyairtable.models._base import AirtableModel, update_forward_refs from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo @@ -253,7 +254,7 @@ def remove_user( def claim_users( self, users: Dict[str, Literal["managed", "unmanaged"]] - ) -> "ClaimUsersResponse": + ) -> "ManageUsersResponse": """ Batch manage organizations enterprise account users. This endpoint allows you to change a user's membership status from being unmanaged to being an @@ -276,7 +277,7 @@ def claim_users( ] } response = self.api.post(f"{self.url}/users/claim", json=payload) - return ClaimUsersResponse.from_api(response, self.api, context=self) + return ManageUsersResponse.from_api(response, self.api, context=self) def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse": """ @@ -288,6 +289,41 @@ def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse": response = self.api.delete(f"{self.url}/users", params={"email": list(emails)}) return DeleteUsersResponse.from_api(response, self.api, context=self) + def grant_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse": + """ + Grant admin access to one or more users. + + Args: + users: One or more user IDs, email addresses, or instances of + :class:`~pyairtable.models.schema.UserInfo`. + """ + return self._post_admin_access("grant", users) + + def revoke_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse": + """ + Revoke admin access to one or more users. + + Args: + users: One or more user IDs, email addresses, or instances of + :class:`~pyairtable.models.schema.UserInfo`. + """ + return self._post_admin_access("revoke", users) + + def _post_admin_access( + self, action: str, users: Iterable[Union[str, UserInfo]] + ) -> "ManageUsersResponse": + response = self.api.post( + f"{self.url}/users/{action}AdminAccess", + json={ + "users": [ + {"email": user_id} if "@" in user_id else {"id": user_id} + for user in users + for user_id in [user.id if isinstance(user, UserInfo) else user] + ] + }, + ) + return ManageUsersResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): """ @@ -352,13 +388,15 @@ class Error(AirtableModel): message: Optional[str] = None -class ClaimUsersResponse(AirtableModel): +class ManageUsersResponse(AirtableModel): """ - Returned from the `Manage user membership `__ - endpoint. + Returned from the `Manage user membership `__, + `Grant admin access `__, and + `Revoke admin access `__ + endpoints. """ - errors: List["ClaimUsersResponse.Error"] + errors: List["ManageUsersResponse.Error"] = pydantic.Field(default_factory=list) class Error(AirtableModel): id: Optional[str] = None diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 1b4dd9f8..214ca309 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -565,6 +565,8 @@ class UserInfo( enterprise_user_type: Optional[str] invited_to_airtable_by_user_id: Optional[str] is_managed: bool = False + is_admin: bool = False + is_super_admin: bool = False groups: List[NestedId] = _FL() collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index e52c676b..27c3a1ac 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -4,9 +4,9 @@ import pytest from pyairtable.api.enterprise import ( - ClaimUsersResponse, DeleteUsersResponse, Enterprise, + ManageUsersResponse, ) from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.testing import fake_id @@ -281,7 +281,7 @@ def test_claim_users(enterprise, enterprise_mocks): "someone@example.com": "unmanaged", } ) - assert isinstance(result, ClaimUsersResponse) + assert isinstance(result, ManageUsersResponse) assert enterprise_mocks.claim_users.call_count == 1 assert enterprise_mocks.claim_users.last_request.json() == { "users": [ @@ -310,3 +310,24 @@ def test_delete_users(enterprise, requests_mock): assert isinstance(parsed, DeleteUsersResponse) assert parsed.deleted_users[0].email == "foo@bar.com" assert parsed.errors[0].type == "INVALID_PERMISSIONS" + + +@pytest.mark.parametrize("action", ["grant", "revoke"]) +def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action): + user = enterprise.user(enterprise_mocks.user_id) + m = requests_mock.post(f"{enterprise.url}/users/{action}AdminAccess", json={}) + method = getattr(enterprise, f"{action}_admin") + result = method( + fake_user_id := fake_id("usr"), + fake_email := "fake@example.com", + user, + ) + assert isinstance(result, ManageUsersResponse) + assert m.call_count == 1 + assert m.last_request.json() == { + "users": [ + {"id": fake_user_id}, + {"email": fake_email}, + {"id": user.id}, + ] + } From 6aac2c6be6a59a151d1411751ecd54c19edb0356 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 16 May 2024 09:35:47 -0700 Subject: [PATCH 145/272] Move exceptions into pyairtable.exceptions --- docs/source/migrations.rst | 17 ++++++++++------- pyairtable/api/params.py | 8 ++------ pyairtable/exceptions.py | 28 ++++++++++++++++++++++++++++ pyairtable/formulas.py | 9 ++------- pyairtable/orm/fields.py | 21 ++++++--------------- tests/test_formulas.py | 3 ++- tests/test_orm_fields.py | 9 +++++---- tests/test_params.py | 4 ++-- 8 files changed, 57 insertions(+), 42 deletions(-) create mode 100644 pyairtable/exceptions.py diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 79245b45..4cbae28e 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -76,13 +76,16 @@ The 3.0 release has changed the API for retrieving ORM model configuration: Miscellaneous name changes --------------------------------------------- -.. list-table:: - :header-rows: 1 - - * - Old name - - New name - * - :class:`~pyairtable.api.enterprise.ClaimUsersResponse` - - :class:`~pyairtable.api.enterprise.ManageUsersResponse` + * - | ``pyairtable.api.enterprise.ClaimUsersResponse`` + | has become :class:`pyairtable.api.enterprise.ManageUsersResponse` + * - | ``pyairtable.formulas.CircularDependency`` + | has become :class:`pyairtable.exceptions.CircularFormulaError` + * - | ``pyairtable.params.InvalidParamException`` + | has become :class:`pyairtable.exceptions.InvalidParameterError` + * - | ``pyairtable.orm.fields.MissingValue`` + | has become :class:`pyairtable.exceptions.MissingValueError` + * - | ``pyairtable.orm.fields.MultipleValues`` + | has become :class:`pyairtable.exceptions.MultipleValuesError` Migrating from 2.2 to 2.3 diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 10a09e87..07c7a2bc 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -1,10 +1,6 @@ from typing import Any, Dict, List, Tuple - -class InvalidParamException(ValueError): - """ - Raised when invalid parameters are passed to ``all()``, ``first()``, etc. - """ +from pyairtable.exceptions import InvalidParameterError def dict_list_to_request_params( @@ -85,7 +81,7 @@ def _option_to_param(name: str) -> str: try: return OPTIONS_TO_PARAMETERS[name] except KeyError: - raise InvalidParamException(name) + raise InvalidParameterError(name) #: List of option names that cannot be passed via POST, only GET diff --git a/pyairtable/exceptions.py b/pyairtable/exceptions.py new file mode 100644 index 00000000..74360c7d --- /dev/null +++ b/pyairtable/exceptions.py @@ -0,0 +1,28 @@ +class PyAirtableError(Exception): + """ + Base class for all exceptions raised by PyAirtable. + """ + + +class CircularFormulaError(PyAirtableError, RecursionError): + """ + A circular dependency was encountered when flattening nested conditions. + """ + + +class InvalidParameterError(PyAirtableError, ValueError): + """ + Raised when invalid parameters are passed to ``all()``, ``first()``, etc. + """ + + +class MissingValueError(PyAirtableError, ValueError): + """ + A required field received an empty value, either from Airtable or other code. + """ + + +class MultipleValuesError(PyAirtableError, ValueError): + """ + SingleLinkField received more than one value from either Airtable or calling code. + """ diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index da3bbeea..dac7ad54 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -14,6 +14,7 @@ from typing_extensions import Self as SelfType from pyairtable.api.types import Fields +from pyairtable.exceptions import CircularFormulaError from pyairtable.utils import date_to_iso_str, datetime_to_iso_str @@ -235,7 +236,7 @@ def flatten(self, /, memo: Optional[Set[int]] = None) -> "Compound": flattened: List[Formula] = [] for item in self.components: if id(item) in memo: - raise CircularDependency(item) + raise CircularFormulaError(item) if isinstance(item, Compound) and item.operator == self.operator: flattened.extend(item.flatten(memo=memo).components) else: @@ -315,12 +316,6 @@ def NOT(component: Optional[Formula] = None, /, **fields: Any) -> Compound: return Compound.build("NOT", items) -class CircularDependency(RecursionError): - """ - A circular dependency was encountered when flattening nested conditions. - """ - - def match(field_values: Fields, *, match_any: bool = False) -> Formula: r""" Create one or more equality expressions for each provided value, diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 1b92d6ad..e7ca4456 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -57,6 +57,7 @@ CollaboratorDict, RecordId, ) +from pyairtable.exceptions import MissingValueError, MultipleValuesError if TYPE_CHECKING: from pyairtable.orm import Model # noqa @@ -278,21 +279,15 @@ def __get__( ) -> Union[SelfType, T_ORM]: value = super().__get__(instance, owner) if value is None or value == "": - raise MissingValue(f"{self._description} received an empty value") + raise MissingValueError(f"{self._description} received an empty value") return value def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: if value in (None, ""): - raise MissingValue(f"{self._description} does not accept empty values") + raise MissingValueError(f"{self._description} does not accept empty values") super().__set__(instance, value) -class MissingValue(ValueError): - """ - A required field received an empty value, either from Airtable or other code. - """ - - #: A generic Field with internal and API representations that are the same type. _BasicField: TypeAlias = Field[T, T, None] _BasicFieldWithMissingValue: TypeAlias = Field[T, T, T] @@ -810,7 +805,9 @@ def __get__( if not instance: return self if self._raise_if_many and len(instance._fields.get(self.field_name) or []) > 1: - raise MultipleValues(f"{self._description} got more than one linked record") + raise MultipleValuesError( + f"{self._description} got more than one linked record" + ) links = self._link_field.__get__(instance, owner) try: return links[0] @@ -844,12 +841,6 @@ def linked_model(self) -> Type[T_Linked]: return self._link_field.linked_model -class MultipleValues(ValueError): - """ - SingleLinkField received more than one value from either Airtable or calling code. - """ - - # Many of these are "passthrough" subclasses for now. E.g. there is no real # difference between `field = TextField()` and `field = PhoneNumberField()`. # diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 0eb8a3a3..41396dac 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -5,6 +5,7 @@ import pytest from mock import call +import pyairtable.exceptions from pyairtable import formulas as F from pyairtable import orm from pyairtable.formulas import AND, EQ, GT, GTE, LT, LTE, NE, NOT, OR @@ -181,7 +182,7 @@ def test_compound_flatten(): def test_compound_flatten_circular_dependency(): circular = NOT(F.Formula("x")) circular.components = [circular] - with pytest.raises(F.CircularDependency): + with pytest.raises(pyairtable.exceptions.CircularFormulaError): circular.flatten() diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 40f93552..3b68aff2 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -6,6 +6,7 @@ import pytest from requests_mock import NoMockAddress +import pyairtable.exceptions from pyairtable.formulas import OR, RECORD_ID from pyairtable.orm import fields as f from pyairtable.orm.model import Model @@ -465,11 +466,11 @@ class T(Model): the_field = field_type("Field Name") obj = T() - with pytest.raises(f.MissingValue): + with pytest.raises(pyairtable.exceptions.MissingValueError): obj.the_field - with pytest.raises(f.MissingValue): + with pytest.raises(pyairtable.exceptions.MissingValueError): obj.the_field = None - with pytest.raises(f.MissingValue): + with pytest.raises(pyairtable.exceptions.MissingValueError): T(the_field=None) @@ -855,7 +856,7 @@ class Book(Model): author = f.SingleLinkField("Author", Author, raise_if_many=True) book = Book.from_record(fake_record(Author=[fake_id(), fake_id()])) - with pytest.raises(f.MultipleValues): + with pytest.raises(pyairtable.exceptions.MultipleValuesError): book.author diff --git a/tests/test_params.py b/tests/test_params.py index 89b00aa7..bd23820c 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -3,12 +3,12 @@ from requests_mock import Mocker from pyairtable.api.params import ( - InvalidParamException, dict_list_to_request_params, field_names_to_sorting_dict, options_to_json_and_params, options_to_params, ) +from pyairtable.exceptions import InvalidParameterError def test_params_integration(table, mock_records, mock_response_iterator): @@ -178,7 +178,7 @@ def test_convert_options_to_json(option, value, expected): def test_process_params_invalid(): - with pytest.raises(InvalidParamException): + with pytest.raises(InvalidParameterError): options_to_params({"ffields": "x"}) From 63ceffb7f0b82ae810f67294738a1affb8bd8996 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 20 May 2024 09:15:04 -0400 Subject: [PATCH 146/272] Add more to the public API docs --- docs/source/api.rst | 42 +++++++++++++++++++++++++++++++---- pyairtable/api/retrying.py | 2 +- pyairtable/models/__init__.py | 7 ++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 2c0d9baa..3f26a131 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -27,6 +27,14 @@ API: pyairtable .. autofunction:: pyairtable.retry_strategy +API: pyairtable.api.enterprise +******************************* + +.. automodule:: pyairtable.api.enterprise + :members: + :exclude-members: Enterprise + + API: pyairtable.api.types ******************************* @@ -34,6 +42,13 @@ API: pyairtable.api.types :members: +API: pyairtable.exceptions +******************************* + +.. automodule:: pyairtable.exceptions + :members: + + API: pyairtable.formulas ******************************* @@ -49,21 +64,33 @@ API: pyairtable.models :inherited-members: AirtableModel -API: pyairtable.models.audit -******************************** +API: pyairtable.models.comment +------------------------------- -.. automodule:: pyairtable.models.audit +.. automodule:: pyairtable.models.comment :members: + :exclude-members: Comment :inherited-members: AirtableModel API: pyairtable.models.schema -******************************** +------------------------------- .. automodule:: pyairtable.models.schema :members: :inherited-members: AirtableModel +.. automethod:: pyairtable.models.schema.parse_field_schema + + +API: pyairtable.models.webhook +------------------------------- + +.. automodule:: pyairtable.models.webhook + :members: + :exclude-members: Webhook, WebhookNotification, WebhookPayload + :inherited-members: AirtableModel + API: pyairtable.orm ******************************* @@ -81,6 +108,13 @@ API: pyairtable.orm.fields :no-inherited-members: +API: pyairtable.testing +******************************* + +.. automodule:: pyairtable.testing + :members: + + API: pyairtable.utils ******************************* diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py index 3a33bc5f..893714bb 100644 --- a/pyairtable/api/retrying.py +++ b/pyairtable/api/retrying.py @@ -74,5 +74,5 @@ def __init__(self, retry_strategy: Retry): __all__ = [ "Retry", - "_RetryingSession", + "retry_strategy", ] diff --git a/pyairtable/models/__init__.py b/pyairtable/models/__init__.py index 0ddaa611..765a613f 100644 --- a/pyairtable/models/__init__.py +++ b/pyairtable/models/__init__.py @@ -6,13 +6,20 @@ pyAirtable will wrap certain API responses in type-annotated models, some of which will be deeply nested within each other. Models which implementers can interact with directly are documented below. +Nested or internal models are documented in each submodule. + +Due to its complexity, the :mod:`pyairtable.models.schema` module is +documented separately, and none of its classes are exposed here. """ +from .audit import AuditLogEvent, AuditLogResponse from .collaborator import Collaborator from .comment import Comment from .webhook import Webhook, WebhookNotification, WebhookPayload __all__ = [ + "AuditLogResponse", + "AuditLogEvent", "Collaborator", "Comment", "Webhook", From 0f2ed673261c7eabfabb473122c6fe3f88a01a7e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 20 May 2024 09:15:22 -0400 Subject: [PATCH 147/272] comment.mentioned should always be dict, never None --- pyairtable/models/comment.py | 4 +++- tests/integration/test_integration_api.py | 2 +- tests/test_models_comment.py | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 3769e2cf..aa6adb4e 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import Dict, Optional +from pyairtable._compat import pydantic + from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs from .collaborator import Collaborator @@ -58,7 +60,7 @@ class Comment( author: Collaborator #: Users or groups that were mentioned in the text. - mentioned: Optional[Dict[str, "Mentioned"]] + mentioned: Dict[str, "Mentioned"] = pydantic.Field(default_factory=dict) class Mentioned(AirtableModel): diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 288621a3..65265e17 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -313,7 +313,7 @@ def test_integration_comments(api, table: Table, cols): comments[0].text = "Never mind!" comments[0].save() assert whoami not in comments[0].text - assert comments[0].mentioned is None + assert not comments[0].mentioned # Test that we can delete the comment comments[0].delete() diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index fc4f223d..79fc8b0b 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -45,14 +45,15 @@ def test_parse(comment_json): Comment.parse_obj(comment_json) -@pytest.mark.parametrize("attr", ["mentioned", "last_updated_time"]) -def test_missing_attributes(comment_json, attr): +def test_missing_attributes(comment_json): """ Test that we can parse the payload when missing optional values. """ - del comment_json[Comment.__fields__[attr].alias] + del comment_json["lastUpdatedTime"] + del comment_json["mentioned"] comment = Comment.parse_obj(comment_json) - assert getattr(comment, attr) is None + assert comment.mentioned == {} + assert comment.last_updated_time is None @pytest.mark.parametrize( From aae7601cbde1918e1c20b69fead55a47a873a341 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 20 May 2024 09:22:22 -0400 Subject: [PATCH 148/272] Remove wayward (empty) file --- pyairtable/models/record.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 pyairtable/models/record.py diff --git a/pyairtable/models/record.py b/pyairtable/models/record.py deleted file mode 100644 index e69de29b..00000000 From a75e30fa78cb502d5f5c1e9786f22d60c9468e23 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 20 May 2024 09:17:41 -0400 Subject: [PATCH 149/272] Fix docs for Workspace.move_base --- docs/source/api.rst | 3 --- pyairtable/api/workspace.py | 4 ++-- pyairtable/models/schema.py | 6 +++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 3f26a131..fcfa1aa0 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -78,9 +78,6 @@ API: pyairtable.models.schema .. automodule:: pyairtable.models.schema :members: - :inherited-members: AirtableModel - -.. automethod:: pyairtable.models.schema.parse_field_schema API: pyairtable.models.webhook diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 3da75fdc..ed1cc453 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -104,9 +104,9 @@ def move_base( See https://airtable.com/developers/web/api/move-base Usage: + >>> base = api.base("appCwFmhESAta6clC") >>> ws = api.workspace("wspmhESAta6clCCwF") - >>> base = api.workspace("appCwFmhESAta6clC") - >>> workspace.move_base(base, "wspSomeOtherPlace", index=0) + >>> ws.move_base(base, "wspSomeOtherPlace", index=0) """ base_id = base if isinstance(base, str) else base.id target_id = target if isinstance(target, str) else target.id diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 214ca309..4d02651d 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1345,7 +1345,11 @@ class _HasFieldSchema(AirtableModel): field_schema: FieldSchema -def parse_field_schema(obj: Any) -> FieldSchema: +def parse_field_schema(obj: Dict[str, Any]) -> FieldSchema: + """ + Given a ``dict`` representing a field schema, + parse it into the appropriate FieldSchema subclass. + """ return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema From b5f7c9b58b6f5fb2ebd8a8a01a4a1554f0a6c4ee Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 25 Feb 2024 00:52:39 -0800 Subject: [PATCH 150/272] Refine type annotations in orm.fields, models.schema --- pyairtable/models/schema.py | 30 +++++++++++++++--------------- pyairtable/orm/fields.py | 18 ++++++++++-------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 214ca309..a8bafd31 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -653,7 +653,7 @@ class CheckboxFieldConfig(AirtableModel): """ type: Literal["checkbox"] - options: Optional["CheckboxFieldOptions"] + options: "CheckboxFieldOptions" class CheckboxFieldOptions(AirtableModel): @@ -667,7 +667,7 @@ class CountFieldConfig(AirtableModel): """ type: Literal["count"] - options: Optional["CountFieldOptions"] + options: "CountFieldOptions" class CountFieldOptions(AirtableModel): @@ -747,7 +747,7 @@ class DurationFieldConfig(AirtableModel): """ type: Literal["duration"] - options: Optional["DurationFieldOptions"] + options: "DurationFieldOptions" class DurationFieldOptions(AirtableModel): @@ -768,7 +768,7 @@ class ExternalSyncSourceFieldConfig(AirtableModel): """ type: Literal["externalSyncSource"] - options: Optional["SingleSelectFieldOptions"] + options: "SingleSelectFieldOptions" class FormulaFieldConfig(AirtableModel): @@ -777,7 +777,7 @@ class FormulaFieldConfig(AirtableModel): """ type: Literal["formula"] - options: Optional["FormulaFieldOptions"] + options: "FormulaFieldOptions" class FormulaFieldOptions(AirtableModel): @@ -801,7 +801,7 @@ class LastModifiedTimeFieldConfig(AirtableModel): """ type: Literal["lastModifiedTime"] - options: Optional["LastModifiedTimeFieldOptions"] + options: "LastModifiedTimeFieldOptions" class LastModifiedTimeFieldOptions(AirtableModel): @@ -824,7 +824,7 @@ class MultipleAttachmentsFieldConfig(AirtableModel): """ type: Literal["multipleAttachments"] - options: Optional["MultipleAttachmentsFieldOptions"] + options: "MultipleAttachmentsFieldOptions" class MultipleAttachmentsFieldOptions(AirtableModel): @@ -849,7 +849,7 @@ class MultipleLookupValuesFieldConfig(AirtableModel): """ type: Literal["multipleLookupValues"] - options: Optional["MultipleLookupValuesFieldOptions"] + options: "MultipleLookupValuesFieldOptions" class MultipleLookupValuesFieldOptions(AirtableModel): @@ -865,7 +865,7 @@ class MultipleRecordLinksFieldConfig(AirtableModel): """ type: Literal["multipleRecordLinks"] - options: Optional["MultipleRecordLinksFieldOptions"] + options: "MultipleRecordLinksFieldOptions" class MultipleRecordLinksFieldOptions(AirtableModel): @@ -882,7 +882,7 @@ class MultipleSelectsFieldConfig(AirtableModel): """ type: Literal["multipleSelects"] - options: Optional["SingleSelectFieldOptions"] + options: "SingleSelectFieldOptions" class NumberFieldConfig(AirtableModel): @@ -891,7 +891,7 @@ class NumberFieldConfig(AirtableModel): """ type: Literal["number"] - options: Optional["NumberFieldOptions"] + options: "NumberFieldOptions" class NumberFieldOptions(AirtableModel): @@ -904,7 +904,7 @@ class PercentFieldConfig(AirtableModel): """ type: Literal["percent"] - options: Optional["NumberFieldOptions"] + options: "NumberFieldOptions" class PhoneNumberFieldConfig(AirtableModel): @@ -921,7 +921,7 @@ class RatingFieldConfig(AirtableModel): """ type: Literal["rating"] - options: Optional["RatingFieldOptions"] + options: "RatingFieldOptions" class RatingFieldOptions(AirtableModel): @@ -944,7 +944,7 @@ class RollupFieldConfig(AirtableModel): """ type: Literal["rollup"] - options: Optional["RollupFieldOptions"] + options: "RollupFieldOptions" class RollupFieldOptions(AirtableModel): @@ -977,7 +977,7 @@ class SingleSelectFieldConfig(AirtableModel): """ type: Literal["singleSelect"] - options: Optional["SingleSelectFieldOptions"] + options: "SingleSelectFieldOptions" class SingleSelectFieldOptions(AirtableModel): diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 1b92d6ad..06ebad51 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -33,10 +33,12 @@ TYPE_CHECKING, Any, ClassVar, + Dict, Generic, List, Literal, Optional, + Set, Tuple, Type, TypeVar, @@ -1378,7 +1380,7 @@ class CreatedTimeField(RequiredDatetimeField): #: Set of all Field subclasses exposed by the library. #: #: :meta hide-value: -ALL_FIELDS = { +ALL_FIELDS: Set[Type[AnyField]] = { field_class for name, field_class in vars().items() if isinstance(field_class, type) @@ -1391,7 +1393,7 @@ class CreatedTimeField(RequiredDatetimeField): #: Set of all read-only Field subclasses exposed by the library. #: #: :meta hide-value: -READONLY_FIELDS = {cls for cls in ALL_FIELDS if cls.readonly} +READONLY_FIELDS: Set[Type[AnyField]] = {cls for cls in ALL_FIELDS if cls.readonly} #: Mapping of Airtable field type names to their ORM classes. @@ -1404,7 +1406,7 @@ class CreatedTimeField(RequiredDatetimeField): #: field type names are mapped to the constant ``NotImplemented``. #: #: :meta hide-value: -FIELD_TYPES_TO_CLASSES = { +FIELD_TYPES_TO_CLASSES: Dict[str, Type[AnyField]] = { "aiText": AITextField, "autoNumber": AutoNumberField, "barcode": BarcodeField, @@ -1426,6 +1428,7 @@ class CreatedTimeField(RequiredDatetimeField): "multilineText": TextField, "multipleAttachments": AttachmentsField, "multipleCollaborators": MultipleCollaboratorsField, + "multipleLookupValues": LookupField, "multipleRecordLinks": LinkField, "multipleSelects": MultipleSelectField, "number": NumberField, @@ -1444,7 +1447,7 @@ class CreatedTimeField(RequiredDatetimeField): #: Mapping of field classes to the set of supported Airtable field types. #: #: :meta hide-value: -FIELD_CLASSES_TO_TYPES = { +FIELD_CLASSES_TO_TYPES: Dict[Type[AnyField], Set[str]] = { cls: {key for (key, val) in FIELD_TYPES_TO_CLASSES.items() if val == cls} for cls in ALL_FIELDS } @@ -1459,19 +1462,18 @@ class CreatedTimeField(RequiredDatetimeField): # src = fp.read() # # classes = re.findall(r"class ((?:[A-Z]\w+)?Field)", src) -# constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+) = ", src, re.MULTILINE) +# constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+)(?:: [^=]+)? = ", src, re.MULTILINE) # extras = ["LinkSelf"] # names = sorted(classes) + constants + extras # # cog.outl("\n\n__all__ = [") -# for name in ["Field", *names]: +# for name in names: # cog.outl(f' "{name}",') # cog.outl("]") # [[[out]]] __all__ = [ - "Field", "AITextField", "AttachmentsField", "AutoNumberField", @@ -1531,7 +1533,7 @@ class CreatedTimeField(RequiredDatetimeField): "FIELD_CLASSES_TO_TYPES", "LinkSelf", ] -# [[[end]]] (checksum: 21316c688401f32f59d597c496d48bf3) +# [[[end]]] (checksum: 4e24bb038e7e79db2f563afd1708eeef) # Delayed import to avoid circular dependency From 7e0e89e6bc69499aed4b46a461ce7d789c868d01 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 25 Feb 2024 01:00:20 -0800 Subject: [PATCH 151/272] First WIP of cli with ORM generator --- pyairtable/cli.py | 150 ++++++++++++++++++++++++++++++++++ pyairtable/orm/generate.py | 161 +++++++++++++++++++++++++++++++++++++ setup.cfg | 4 + tox.ini | 4 +- 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 pyairtable/cli.py create mode 100644 pyairtable/orm/generate.py diff --git a/pyairtable/cli.py b/pyairtable/cli.py new file mode 100644 index 00000000..9b7b88b9 --- /dev/null +++ b/pyairtable/cli.py @@ -0,0 +1,150 @@ +""" +pyAirtable exposes a command-line interface that allows you to interact with the API. +""" + +import datetime +import functools +import json +import os +import sys +from dataclasses import dataclass +from typing import Any, Callable, List, Optional + +import click +from typing_extensions import ParamSpec, TypeVar + +from pyairtable.api.api import Api +from pyairtable.api.base import Base +from pyairtable.models._base import AirtableModel +from pyairtable.orm.generate import ModelFileBuilder + +T = TypeVar("T") +F = TypeVar("F", bound=Callable[..., Any]) +P = ParamSpec("P") + + +@dataclass +class CliContext: + access_token: str = "" + base_id: str = "" + click_context: Optional["click.Context"] = None + + @functools.cached_property + def api(self) -> Api: + return Api(self.access_token) + + @functools.cached_property + def base(self) -> Base: + return self.api.base(self.base_id) + + @property + def click(self) -> click.Context: + assert self.click_context is not None + return self.click_context + + def default_subcommand(self, cmd: F) -> None: + if not self.click.invoked_subcommand: + self.click.invoke(cmd) + + +def needs_context(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) + @click.pass_context + def _wrapped(click_ctx: click.Context, /, *args: P.args, **kwargs: P.kwargs) -> T: + obj = click_ctx.ensure_object(CliContext) + obj.click_context = click_ctx + return click_ctx.invoke(func, obj, *args, **kwargs) + + return _wrapped + + +# fmt: off +@click.group() +@click.option("-k", "--key", help="Your API key") +@click.option("-kf", "--key-file", type=click.Path(exists=True), help="File containing your API key") +@click.option("-ke", "--key-env", metavar="VAR", help="Env var containing your API key") +@needs_context +# fmt: on +def cli( + ctx: CliContext, + key: str = "", + key_file: str = "", + key_env: str = "", +) -> None: + if not any([key, key_file, key_env]): + try: + key_file = os.environ["AIRTABLE_API_KEY_FILE"] + except KeyError: + try: + key = os.environ["AIRTABLE_API_KEY"] + except KeyError: + raise click.UsageError("--key, --key-file, or --key-env required") + + if len([arg for arg in (key, key_file, key_env) if arg]) > 1: + raise click.UsageError("only one of --key, --key-file, --key-env allowed") + + if key_file: + with open(key_file) as inputf: + key = inputf.read().strip() + + if key_env: + key = os.environ[key_env] + + ctx.access_token = key + + +@cli.command() +@needs_context +def bases(ctx: CliContext) -> None: + """ + Output a JSON list of available bases. + """ + _dump(ctx.api._base_info().bases) + + +@cli.group(invoke_without_command=True) +@click.argument("base_id") +@needs_context +def base(ctx: CliContext, base_id: str) -> None: + """ + Retrieve information about a base. + """ + ctx.base_id = base_id + ctx.default_subcommand(base_schema) + + +@base.command("schema") +@needs_context +def base_schema(ctx: CliContext) -> None: + """ + Output a JSON representation of the base schema. + """ + _dump(ctx.base.schema()) + + +@base.command("orm") +@needs_context +@click.option("-t", "--table", "table_names", multiple=True) +def base_auto_orm(ctx: CliContext, table_names: List[str]) -> None: + """ + Generate a module with ORM classes for the given base. + """ + generator = ModelFileBuilder(ctx.base, table_names=table_names) + print(str(generator)) + + +class JSONEncoder(json.JSONEncoder): + def default(self, o: Any) -> Any: + if isinstance(o, AirtableModel): + return o._raw + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return super().default(o) + + +def _dump(obj: Any) -> None: + json.dump(obj, cls=JSONEncoder, fp=sys.stdout) + + +if __name__ == "__main__": + cli() diff --git a/pyairtable/orm/generate.py b/pyairtable/orm/generate.py new file mode 100644 index 00000000..dcafef5a --- /dev/null +++ b/pyairtable/orm/generate.py @@ -0,0 +1,161 @@ +""" +pyAirtable can generate ORM models that reflect the schema of an Airtable base. +""" + +import datetime +import re +from dataclasses import dataclass +from functools import cached_property +from typing import Any, Dict, List, Optional, Sequence, Type + +import inflection + +from pyairtable.api.base import Base +from pyairtable.api.table import Table +from pyairtable.models import schema as S +from pyairtable.orm import fields + + +class ModelFileBuilder: + def __init__(self, base: Base, table_names: Optional[Sequence[str]] = None): + tables = base.tables() + if table_names: + tables = [t for t in tables if t.name in table_names] + self.model_builders = [ModelBuilder(self, table) for table in tables] + + @cached_property + def model_lookup(self) -> Dict[str, "ModelBuilder"]: + return { + key: builder + for builder in self.model_builders + for key in (builder.table.id, builder.table.name) + } + + def __str__(self) -> str: + now = datetime.datetime.now().isoformat() + models_expr = "\n\n\n".join(str(builder) for builder in self.model_builders) + import_exprs = [ + "import os", + "from functools import partial", + "from typing import Any" if "[Any]" in models_expr else "", + "from typing import Union" if "[Union[" in models_expr else "", + ] + preamble = "\n".join( + [ + f"# This file was generated by pyairtable.orm.generate at {now}.", + "# Any modifications to this file will be lost if it is rebuilt.", + "", + "from __future__ import annotations", + "", + *(line for line in import_exprs if line), + "", + "from pyairtable.orm import Model", + "from pyairtable.orm import fields as F", + ] + ) + all_expr = "\n".join( + [ + "__all__ = [", + *sorted(f" {b.class_name!r}," for b in self.model_builders), + "]", + ] + ) + return "\n\n\n".join([preamble, models_expr, all_expr]) + + +@dataclass +class ModelBuilder: + file_generator: ModelFileBuilder + table: Table + + @property + def field_builders(self) -> List["FieldBuilder"]: + return [ + FieldBuilder(field_schema, lookup=self.file_generator.model_lookup) + for field_schema in self.table.schema().fields + ] + + @property + def class_name(self) -> str: + name = inflection.singularize(self.table.schema().name) + name = re.sub(r"[^a-zA-Z0-9]+", " ", name) + return "".join(part.capitalize() for part in name.split()) + + def __str__(self) -> str: + return "\n".join( + [ + f"class {self.class_name}(Model):", + " class Meta:", + " api_key = partial(os.environ.get, 'AIRTABLE_API_KEY')", + f" base_id = {self.table.base.id!r}", + f" table_name = {self.table.schema().name!r}", + "", + *(f" {fg}" for fg in self.field_builders), + ] + ) + + +@dataclass +class FieldBuilder: + schema: S.FieldSchema + lookup: Dict[str, ModelBuilder] + + @property + def var_name(self) -> str: + name = re.sub(r"[^a-zA-Z0-9]+", " ", self.schema.name) + name = name.strip().lower().replace(" ", "_") + return name + + @property + def field_class(self) -> Type[fields.AnyField]: + field_type = self.schema.type + if isinstance(self.schema, (S.FormulaFieldSchema, S.RollupFieldSchema)): + if self.schema.options.result: + field_type = self.schema.options.result.type + return fields.FIELD_TYPES_TO_CLASSES[field_type] + + def __str__(self) -> str: + args: List[Any] = [self.schema.name] + kwargs: Dict[str, Any] = {} + generic = "" + + if isinstance(self.schema, S.MultipleLookupValuesFieldSchema): + generic = f"[{_lookup_field_type_annotation(self.schema)}]" + + if isinstance(self.schema, S.MultipleRecordLinksFieldSchema): + linked_model = self.lookup[self.schema.options.linked_table_id] + kwargs["model"] = linked_model.class_name + generic = f"[{linked_model.class_name!r}]" + + if self.schema.type in ("formula", "rollup"): + kwargs["readonly"] = True + + args_repr = [repr(arg) for arg in args] + args_repr.extend(f"{k}={v!r}" for (k, v) in kwargs.items()) + args_join = ", ".join(args_repr) + return f"{self.var_name} = F.{self.field_class.__name__}{generic}({args_join})" + + +def _lookup_field_type_annotation(schema: S.MultipleLookupValuesFieldSchema) -> str: + if not schema.options.result: + return "Any" + lookup_type = schema.options.result.type + if lookup_type == "multipleRecordLinks": + return "str" # otherwise this will be 'list' + cls = fields.FIELD_TYPES_TO_CLASSES[lookup_type] + if isinstance(contained_type := getattr(cls, "contains_type", None), type): + return contained_type.__name__ + valid_types = _flatten(cls.valid_types) + if len(valid_types) == 1: + return valid_types[0].__name__ + return "Union[%s]" % ", ".join(t.__name__ for t in _flatten(cls.valid_types)) + + +def _flatten(class_info: fields._ClassInfo) -> List[Type[Any]]: + if isinstance(class_info, type): + return [class_info] + flattened = [t for t in class_info if isinstance(t, type)] + for t in class_info: + if isinstance(t, tuple): + flattened.extend(_flatten(t)) + return flattened diff --git a/setup.cfg b/setup.cfg index b70ab5f4..787c2895 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,5 +36,9 @@ install_requires = typing_extensions urllib3 >= 1.26 +[options.extras_require] +cli = + click + [aliases] test=pytest diff --git a/tox.ini b/tox.ini index 43e0ff96..e08ef2a8 100644 --- a/tox.ini +++ b/tox.ini @@ -34,8 +34,8 @@ passenv = AIRTABLE_ENTERPRISE_ID addopts = -v testpaths = tests -commands = - python -m pytest {posargs:-m 'not integration'} +commands = python -m pytest {posargs:-m 'not integration'} +extras = orm.generate deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg From edac03753ef5d302bc952cacbbb71d1391dd7f2d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 25 Feb 2024 22:56:30 -0800 Subject: [PATCH 152/272] Test coverage for orm.generate --- pyairtable/cli.py | 15 ++- pyairtable/orm/generate.py | 84 ++++++++++++---- tests/conftest.py | 16 ++++ tests/test_models_schema.py | 16 ---- tests/test_orm_generate.py | 186 ++++++++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 33 deletions(-) create mode 100644 tests/test_orm_generate.py diff --git a/pyairtable/cli.py b/pyairtable/cli.py index 9b7b88b9..50453999 100644 --- a/pyairtable/cli.py +++ b/pyairtable/cli.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from typing import Any, Callable, List, Optional -import click from typing_extensions import ParamSpec, TypeVar from pyairtable.api.api import Api @@ -18,6 +17,20 @@ from pyairtable.models._base import AirtableModel from pyairtable.orm.generate import ModelFileBuilder +try: + import click +except ImportError: # pragma: no cover + print( + "You are missing the 'click' library, which means you did not install\n" + "the optional dependencies required for the pyairtable command line.\n" + "Try again after running:\n\n" + " % pip install 'pyairtable[cli]'", + "\n", + file=sys.stderr, + ) + raise + + T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) P = ParamSpec("P") diff --git a/pyairtable/orm/generate.py b/pyairtable/orm/generate.py index dcafef5a..0b18fa95 100644 --- a/pyairtable/orm/generate.py +++ b/pyairtable/orm/generate.py @@ -15,6 +15,14 @@ from pyairtable.models import schema as S from pyairtable.orm import fields +_ANNOTATION_IMPORTS = { + "date": "from datetime import date", + "datetime": "from datetime import datetime", + "timedelta": "from datetime import timedelta", + "Any": "from typing import Any", + r"Union\[.+\]": "from typing import Union", +} + class ModelFileBuilder: def __init__(self, base: Base, table_names: Optional[Sequence[str]] = None): @@ -37,8 +45,11 @@ def __str__(self) -> str: import_exprs = [ "import os", "from functools import partial", - "from typing import Any" if "[Any]" in models_expr else "", - "from typing import Union" if "[Union[" in models_expr else "", + *( + import_text + for import_expr, import_text in _ANNOTATION_IMPORTS.items() + if re.search(rf"\[{import_expr}\]", models_expr) + ), ] preamble = "\n".join( [ @@ -77,9 +88,7 @@ def field_builders(self) -> List["FieldBuilder"]: @property def class_name(self) -> str: - name = inflection.singularize(self.table.schema().name) - name = re.sub(r"[^a-zA-Z0-9]+", " ", name) - return "".join(part.capitalize() for part in name.split()) + return table_class_name(self.table.schema().name) def __str__(self) -> str: return "\n".join( @@ -102,9 +111,7 @@ class FieldBuilder: @property def var_name(self) -> str: - name = re.sub(r"[^a-zA-Z0-9]+", " ", self.schema.name) - name = name.strip().lower().replace(" ", "_") - return name + return field_variable_name(self.schema.name) @property def field_class(self) -> Type[fields.AnyField]: @@ -112,31 +119,72 @@ def field_class(self) -> Type[fields.AnyField]: if isinstance(self.schema, (S.FormulaFieldSchema, S.RollupFieldSchema)): if self.schema.options.result: field_type = self.schema.options.result.type + if isinstance(self.schema, S.MultipleRecordLinksFieldSchema): + try: + self.lookup[self.schema.options.linked_table_id] + except KeyError: + return fields._ValidatingListField return fields.FIELD_TYPES_TO_CLASSES[field_type] def __str__(self) -> str: args: List[Any] = [self.schema.name] kwargs: Dict[str, Any] = {} generic = "" + cls = self.field_class if isinstance(self.schema, S.MultipleLookupValuesFieldSchema): - generic = f"[{_lookup_field_type_annotation(self.schema)}]" + generic = lookup_field_type_annotation(self.schema) - if isinstance(self.schema, S.MultipleRecordLinksFieldSchema): + if cls is fields.LinkField: + assert isinstance(self.schema, S.MultipleRecordLinksFieldSchema) linked_model = self.lookup[self.schema.options.linked_table_id] kwargs["model"] = linked_model.class_name - generic = f"[{linked_model.class_name!r}]" + generic = repr(linked_model.class_name) + + if cls is fields._ValidatingListField: + generic = "str" if self.schema.type in ("formula", "rollup"): + assert isinstance(self.schema, (S.FormulaFieldSchema, S.RollupFieldSchema)) + cls = fields.Field + if self.schema.options.result: + cls = fields.FIELD_TYPES_TO_CLASSES[self.schema.options.result.type] kwargs["readonly"] = True + generic = generic and f"[{generic}]" args_repr = [repr(arg) for arg in args] args_repr.extend(f"{k}={v!r}" for (k, v) in kwargs.items()) args_join = ", ".join(args_repr) - return f"{self.var_name} = F.{self.field_class.__name__}{generic}({args_join})" - - -def _lookup_field_type_annotation(schema: S.MultipleLookupValuesFieldSchema) -> str: + return f"{self.var_name} = F.{cls.__name__}{generic}({args_join})" + + +def table_class_name(table_name: str) -> str: + """ + Convert an Airtable table name into a Python class name. + """ + name = inflection.singularize(table_name) + name = re.sub(r"[^a-zA-Z0-9]+", " ", name) + name = re.sub(r"([0-9]) +([0-9])", r"\1_\2", name) + name = re.sub(r"^([0-9])", r"_\1", name) + return "".join(part.capitalize() for part in name.split()) + + +def field_variable_name(field_name: str) -> str: + """ + Convert an Airtable field name into a Python variable name. + """ + name = re.sub(r"[^a-zA-Z0-9]+", " ", field_name) + name = re.sub(r"([0-9]) +([0-9])", r"\1_\2", name) + name = re.sub(r"^([0-9])", r"_\1", name) + name = name.strip().lower().replace(" ", "_") + return name + + +def lookup_field_type_annotation(schema: S.MultipleLookupValuesFieldSchema) -> str: + """ + Given the schema for a multipleLookupValues field, determine the type annotation + we should use when creating the field descriptor. + """ if not schema.options.result: return "Any" lookup_type = schema.options.result.type @@ -152,10 +200,14 @@ def _lookup_field_type_annotation(schema: S.MultipleLookupValuesFieldSchema) -> def _flatten(class_info: fields._ClassInfo) -> List[Type[Any]]: + """ + Given a _ClassInfo tuple (which can contain multiple levels of nested tuples) + return a single list of all the actual types contained. + """ if isinstance(class_info, type): return [class_info] flattened = [t for t in class_info if isinstance(t, type)] for t in class_info: if isinstance(t, tuple): - flattened.extend(_flatten(t)) + flattened.extend(_flatten(t)) # pragma: no cover return flattened diff --git a/tests/conftest.py b/tests/conftest.py index 06119da0..df9fd90b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -178,3 +178,19 @@ def _get_schema_obj(name: str, *, context: Any = None) -> Any: return obj return _get_schema_obj + + +@pytest.fixture +def mock_base_metadata(base, sample_json, requests_mock): + base_json = sample_json("BaseCollaborators") + requests_mock.get(base.meta_url(), json=base_json) + requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) + for pbd_id, pbd_json in base_json["interfaces"].items(): + requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) + + +@pytest.fixture +def mock_workspace_metadata(workspace, sample_json, requests_mock): + workspace_json = sample_json("WorkspaceCollaborators") + requests_mock.get(workspace.url, json=workspace_json) diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index ad9a23c3..250f0cf7 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -9,22 +9,6 @@ from pyairtable.testing import fake_id -@pytest.fixture -def mock_base_metadata(base, sample_json, requests_mock): - base_json = sample_json("BaseCollaborators") - requests_mock.get(base.meta_url(), json=base_json) - requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) - requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) - for pbd_id, pbd_json in base_json["interfaces"].items(): - requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) - - -@pytest.fixture -def mock_workspace_metadata(workspace, sample_json, requests_mock): - workspace_json = sample_json("WorkspaceCollaborators") - requests_mock.get(workspace.url, json=workspace_json) - - @pytest.mark.parametrize( "clsname", [ diff --git a/tests/test_orm_generate.py b/tests/test_orm_generate.py new file mode 100644 index 00000000..84f4a924 --- /dev/null +++ b/tests/test_orm_generate.py @@ -0,0 +1,186 @@ +import pytest + +from pyairtable.models import schema +from pyairtable.orm import generate +from pyairtable.testing import fake_id + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Apartments", "Apartment"), + ("Apartment", "Apartment"), + ("Ice Cold Slushees", "IceColdSlushee"), + ("Table 5.6", "Table5_6"), + ("53rd Avenue", "_53rdAvenue"), + ], +) +def test_table_class_name(value, expected): + assert generate.table_class_name(value) == expected + + +@pytest.mark.parametrize( + "value,expected", + [ + ("Apartments", "apartments"), + ("Apartment", "apartment"), + ("Ice Cold Slushees", "ice_cold_slushees"), + ("Checked?", "checked"), + ("* Something weird (but kinda long!)", "something_weird_but_kinda_long"), + ("Section 5.6", "section_5_6"), + ("53rd Avenue", "_53rd_avenue"), + ], +) +def test_field_variable_name(value, expected): + assert generate.field_variable_name(value) == expected + + +@pytest.mark.parametrize( + "result_schema,expected", + [ + (None, "Any"), + ({"type": "multipleRecordLinks"}, "str"), + ({"type": "singleLineText"}, "str"), + ({"type": "number"}, "Union[int, float]"), + ({"type": "date"}, "date"), + ({"type": "dateTime"}, "datetime"), + ({"type": "rating"}, "int"), + ({"type": "duration"}, "timedelta"), + ({"type": "checkbox"}, "bool"), + ({"type": "multipleAttachments"}, "dict"), + ({"type": "multipleSelects"}, "str"), + ], +) +def test_lookup_field_type_annotation(result_schema, expected): + struct = { + "id": fake_id("fld"), + "name": "Fake Field", + "type": "multipleLookupValues", + "options": {"isValid": True, "result": result_schema}, + } + obj = schema.MultipleLookupValuesFieldSchema.parse_obj(struct) + assert generate.lookup_field_type_annotation(obj) == expected + + +@pytest.mark.parametrize( + "schema_data,expected", + [ + # basic field is looked up from the type + ( + {"type": "singleLineText"}, + "field = F.TextField('Field')", + ), + # formula field that's missing result.type gets a generic field + ( + {"type": "formula", "options": {"formula": "1", "isValid": True}}, + "field = F.Field('Field', readonly=True)", + ), + # formula field with result.type should look up the right class + ( + { + "type": "formula", + "options": { + "formula": "1", + "isValid": True, + "result": {"type": "multipleAttachments"}, + }, + }, + "field = F.AttachmentsField('Field', readonly=True)", + ), + # lookup field should share more about types + ( + { + "type": "multipleLookupValues", + "options": { + "isValid": True, + "fieldIdInLinkedValue": fake_id("fld"), + "recordLinkFieldId": fake_id("fld"), + "result": {"type": "duration"}, + }, + }, + "field = F.LookupField[timedelta]('Field')", + ), + ], +) +def test_field_builder(schema_data, expected): + schema_data = {"id": fake_id("fld"), "name": "Field", **schema_data} + field_schema = schema.parse_field_schema(schema_data) + builder = generate.FieldBuilder(field_schema, lookup={}) + assert str(builder) == expected + + +def test_generate(base, mock_base_metadata): + builder = generate.ModelFileBuilder(base) + code = str(builder) + assert code.endswith( + r""" +from __future__ import annotations + +import os +from functools import partial + +from pyairtable.orm import Model +from pyairtable.orm import fields as F + + +class Apartment(Model): + class Meta: + api_key = partial(os.environ.get, 'AIRTABLE_API_KEY') + base_id = 'appLkNDICXNqxSDhG' + table_name = 'Apartments' + + name = F.TextField('Name') + pictures = F.AttachmentsField('Pictures') + district = F.LinkField['District']('District', model='District') + + +class District(Model): + class Meta: + api_key = partial(os.environ.get, 'AIRTABLE_API_KEY') + base_id = 'appLkNDICXNqxSDhG' + table_name = 'Districts' + + name = F.TextField('Name') + apartments = F.LinkField['Apartment']('Apartments', model='Apartment') + + +__all__ = [ + 'Apartment', + 'District', +]""" + ) + + +def test_generate__table_names(base, mock_base_metadata): + """ + Test that we can generate only some tables, and link fields + will reflect the fact that some tables are not represented. + """ + builder = generate.ModelFileBuilder(base, table_names=["Apartments"]) + code = str(builder) + assert code.endswith( + r""" +from __future__ import annotations + +import os +from functools import partial + +from pyairtable.orm import Model +from pyairtable.orm import fields as F + + +class Apartment(Model): + class Meta: + api_key = partial(os.environ.get, 'AIRTABLE_API_KEY') + base_id = 'appLkNDICXNqxSDhG' + table_name = 'Apartments' + + name = F.TextField('Name') + pictures = F.AttachmentsField('Pictures') + district = F._ValidatingListField[str]('District') + + +__all__ = [ + 'Apartment', +]""" + ) From 2edde65d2c697ec2c3a5aa215b7505384be1cb4a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 26 Feb 2024 08:14:57 -0800 Subject: [PATCH 153/272] Test coverage for cli --- pyairtable/cli.py | 27 ++++++--- pyairtable/orm/generate.py | 43 ++++++++++---- tests/conftest.py | 1 + tests/test_cli.py | 118 +++++++++++++++++++++++++++++++++++++ tests/test_orm_generate.py | 22 +++++-- tox.ini | 2 +- 6 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 tests/test_cli.py diff --git a/pyairtable/cli.py b/pyairtable/cli.py index 50453999..225fbb43 100644 --- a/pyairtable/cli.py +++ b/pyairtable/cli.py @@ -2,12 +2,12 @@ pyAirtable exposes a command-line interface that allows you to interact with the API. """ -import datetime import functools import json import os import sys from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Callable, List, Optional from typing_extensions import ParamSpec, TypeVar @@ -106,11 +106,20 @@ def cli( ctx.access_token = key +@cli.command() +@needs_context +def whoami(ctx: CliContext) -> None: + """ + Prints information about the current user. + """ + _dump(ctx.api.whoami()) + + @cli.command() @needs_context def bases(ctx: CliContext) -> None: """ - Output a JSON list of available bases. + Prints a JSON list of available bases. """ _dump(ctx.api._base_info().bases) @@ -138,11 +147,15 @@ def base_schema(ctx: CliContext) -> None: @base.command("orm") @needs_context @click.option("-t", "--table", "table_names", multiple=True) -def base_auto_orm(ctx: CliContext, table_names: List[str]) -> None: +def base_orm(ctx: CliContext, table_names: List[str]) -> None: """ Generate a module with ORM classes for the given base. """ generator = ModelFileBuilder(ctx.base, table_names=table_names) + now = datetime.now(timezone.utc).isoformat() + print("# This file was generated by pyAirtable at", now) + print("# Any modifications to this file will be lost if it is rebuilt.") + print() print(str(generator)) @@ -150,14 +163,12 @@ class JSONEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, AirtableModel): return o._raw - if isinstance(o, (datetime.date, datetime.datetime)): - return o.isoformat() - return super().default(o) + return super().default(o) # pragma: no cover def _dump(obj: Any) -> None: - json.dump(obj, cls=JSONEncoder, fp=sys.stdout) + print(json.dumps(obj, cls=JSONEncoder)) if __name__ == "__main__": - cli() + cli() # pragma: no cover diff --git a/pyairtable/orm/generate.py b/pyairtable/orm/generate.py index 0b18fa95..6f7642b1 100644 --- a/pyairtable/orm/generate.py +++ b/pyairtable/orm/generate.py @@ -1,8 +1,14 @@ """ pyAirtable can generate ORM models that reflect the schema of an Airtable base. + +The simplest way to use this functionality is with the command line utility: + +.. code-block:: + + % pip install 'pyairtable[cli]' + % pyairtable base YOUR_BASE_ID orm > your_models.py """ -import datetime import re from dataclasses import dataclass from functools import cached_property @@ -25,10 +31,28 @@ class ModelFileBuilder: - def __init__(self, base: Base, table_names: Optional[Sequence[str]] = None): + """ + Produces the code for a Python module file that contains ORM classes + representing all tables in the given base. + """ + + def __init__( + self, + base: Base, + table_ids: Optional[Sequence[str]] = None, + table_names: Optional[Sequence[str]] = None, + ): + """ + Args: + base: The base to use when inspecting table schemas. + table_ids: An optional list of table IDs to limit the output. + table_names: An optional list of table names to limit the output. + """ + table_ids = table_ids or [] + table_names = table_names or [] tables = base.tables() - if table_names: - tables = [t for t in tables if t.name in table_names] + if table_names or table_ids: + tables = [t for t in tables if t.name in table_names or t.id in table_ids] self.model_builders = [ModelBuilder(self, table) for table in tables] @cached_property @@ -40,7 +64,6 @@ def model_lookup(self) -> Dict[str, "ModelBuilder"]: } def __str__(self) -> str: - now = datetime.datetime.now().isoformat() models_expr = "\n\n\n".join(str(builder) for builder in self.model_builders) import_exprs = [ "import os", @@ -53,9 +76,6 @@ def __str__(self) -> str: ] preamble = "\n".join( [ - f"# This file was generated by pyairtable.orm.generate at {now}.", - "# Any modifications to this file will be lost if it is rebuilt.", - "", "from __future__ import annotations", "", *(line for line in import_exprs if line), @@ -78,6 +98,7 @@ def __str__(self) -> str: class ModelBuilder: file_generator: ModelFileBuilder table: Table + meta_envvar: str = "AIRTABLE_API_KEY" @property def field_builders(self) -> List["FieldBuilder"]: @@ -95,7 +116,7 @@ def __str__(self) -> str: [ f"class {self.class_name}(Model):", " class Meta:", - " api_key = partial(os.environ.get, 'AIRTABLE_API_KEY')", + f" api_key = partial(os.environ.get, {self.meta_envvar!r})", f" base_id = {self.table.base.id!r}", f" table_name = {self.table.schema().name!r}", "", @@ -163,7 +184,7 @@ def table_class_name(table_name: str) -> str: Convert an Airtable table name into a Python class name. """ name = inflection.singularize(table_name) - name = re.sub(r"[^a-zA-Z0-9]+", " ", name) + name = re.sub(r"[^a-zA-Z0-9]+", " ", name).strip() name = re.sub(r"([0-9]) +([0-9])", r"\1_\2", name) name = re.sub(r"^([0-9])", r"_\1", name) return "".join(part.capitalize() for part in name.split()) @@ -174,9 +195,9 @@ def field_variable_name(field_name: str) -> str: Convert an Airtable field name into a Python variable name. """ name = re.sub(r"[^a-zA-Z0-9]+", " ", field_name) + name = name.strip().lower().replace(" ", "_") name = re.sub(r"([0-9]) +([0-9])", r"\1_\2", name) name = re.sub(r"^([0-9])", r"_\1", name) - name = name.strip().lower().replace(" ", "_") return name diff --git a/tests/conftest.py b/tests/conftest.py index df9fd90b..0d6bb8a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -183,6 +183,7 @@ def _get_schema_obj(name: str, *, context: Any = None) -> Any: @pytest.fixture def mock_base_metadata(base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") + requests_mock.get(base.api.build_url("meta/bases"), json=sample_json("Bases")) requests_mock.get(base.meta_url(), json=base_json) requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..6756275d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,118 @@ +import json + +import pytest +from click.testing import CliRunner + +import pyairtable.cli +import pyairtable.orm.generate +from pyairtable.testing import fake_id + + +@pytest.fixture +def user_id(api, requests_mock): + user_id = fake_id("usr") + requests_mock.get(api.build_url("meta/whoami"), json={"id": user_id}) + return user_id + + +@pytest.fixture(autouse=True) +def mock_metadata(user_id, mock_base_metadata, mock_workspace_metadata): + pass + + +@pytest.fixture +def run(mock_base_metadata): + default_env = {"AIRTABLE_API_KEY": "test"} + + def _runner(*args: str, env: dict = default_env, fails: bool = False): + runner = CliRunner(env=env) + result = runner.invoke(pyairtable.cli.cli, args) + print(result.output) # if a test fails, show the command's output + if fails: + assert result.exit_code != 0 + else: + assert result.exit_code == 0 + return result + + def _runner_with_json(*args, **kwargs): + result = _runner(*args, **kwargs) + return json.loads(result.stdout) + + _runner.json = _runner_with_json + + return _runner + + +def test_help(run): + result = run("--help") + commands = [ + words[0] + for line in result.output.split("Commands:", 1)[1].splitlines() + if (words := line.strip().split()) + ] + assert commands == ["base", "bases", "whoami"] + + +def test_error_without_key(run): + result = run("whoami", env={}, fails=True) + assert "--key, --key-file, or --key-env required" in result.output + + +def test_invalid_key_args(run, tmp_path): + keyfile = tmp_path / "keyfile.txt" + keyfile.write_text("fakeKey") + for args in [ + ("--key", "key", "--key-file", keyfile), + ("--key", "key", "--key-env", "key"), + ("--key-env", "key", "--key-file", keyfile), + ]: + result = run(*args, "whoami", env={}, fails=True) + print(args) + assert "only one of --key, --key-file, --key-env allowed" in result.output + + +def test_whoami(run, user_id): + result = run.json("whoami") + assert result == {"id": user_id} + + +def test_whoami__key(run, user_id): + result = run.json("-k", "key", "whoami", env={}) + assert result == {"id": user_id} + + +def test_whoami__keyenv(run, user_id): + env = {"THE_KEY": "fakeKey"} + result = run.json("-ke", "THE_KEY", "whoami", env=env) + assert result == {"id": user_id} + + +def test_whoami__keyfile(run, user_id, tmp_path): + keyfile = tmp_path / "keyfile.txt" + keyfile.write_text("fakeKey") + result = run.json("-kf", keyfile, "whoami", env={}) + assert result == {"id": user_id} + + +def test_bases(run): + result = run.json("bases") + assert len(result) == 2 + assert result[0]["id"] == "appLkNDICXNqxSDhG" + + +def test_base(run): + result = run("base", fails=True) + assert "Missing argument 'BASE_ID'" in result.output + + +@pytest.mark.parametrize("extra_args", [[], ["schema"]]) +def test_base_schema(run, extra_args): + result = run.json("base", "appLkNDICXNqxSDhG", *extra_args) + assert list(result) == ["tables"] + assert result["tables"][0]["name"] == "Apartments" + + +def test_base_orm(base, run): + result = run("base", "appLkNDICXNqxSDhG", "orm") + expected = str(pyairtable.orm.generate.ModelFileBuilder(base)) + assert result.output.rstrip().endswith(expected) diff --git a/tests/test_orm_generate.py b/tests/test_orm_generate.py index 84f4a924..3225540f 100644 --- a/tests/test_orm_generate.py +++ b/tests/test_orm_generate.py @@ -13,6 +13,7 @@ ("Ice Cold Slushees", "IceColdSlushee"), ("Table 5.6", "Table5_6"), ("53rd Avenue", "_53rdAvenue"), + ("(53rd Avenue)", "_53rdAvenue"), ], ) def test_table_class_name(value, expected): @@ -26,9 +27,11 @@ def test_table_class_name(value, expected): ("Apartment", "apartment"), ("Ice Cold Slushees", "ice_cold_slushees"), ("Checked?", "checked"), + ("Is checked?", "is_checked"), ("* Something weird (but kinda long!)", "something_weird_but_kinda_long"), ("Section 5.6", "section_5_6"), ("53rd Avenue", "_53rd_avenue"), + ("(53rd Avenue)", "_53rd_avenue"), ], ) def test_field_variable_name(value, expected): @@ -112,8 +115,8 @@ def test_field_builder(schema_data, expected): def test_generate(base, mock_base_metadata): builder = generate.ModelFileBuilder(base) code = str(builder) - assert code.endswith( - r""" + assert code == ( + """\ from __future__ import annotations import os @@ -151,15 +154,22 @@ class Meta: ) -def test_generate__table_names(base, mock_base_metadata): +@pytest.mark.parametrize( + "kwargs", + [ + {"table_names": ["Apartments"]}, + {"table_ids": ["tbltp8DGLhqbUmjK1"]}, + ], +) +def test_generate__table_names(base, kwargs, mock_base_metadata): """ Test that we can generate only some tables, and link fields will reflect the fact that some tables are not represented. """ - builder = generate.ModelFileBuilder(base, table_names=["Apartments"]) + builder = generate.ModelFileBuilder(base, **kwargs) code = str(builder) - assert code.endswith( - r""" + assert code == ( + """\ from __future__ import annotations import os diff --git a/tox.ini b/tox.ini index e08ef2a8..ea4a1a41 100644 --- a/tox.ini +++ b/tox.ini @@ -35,7 +35,7 @@ passenv = addopts = -v testpaths = tests commands = python -m pytest {posargs:-m 'not integration'} -extras = orm.generate +extras = cli deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg From 53a10a3d1483a8598ac55a5e7ffdf1b1602bdc53 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 3 Jun 2024 08:49:09 -0700 Subject: [PATCH 154/272] Create `pyairtable` entry point --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index 787c2895..f1d65a84 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,5 +40,9 @@ install_requires = cli = click +[options.entry_points] +console_scripts = + pyairtable = pyairtable.cli:cli + [aliases] test=pytest From 1d4ae0958c1cf61924f7d06a54045ba7fe913ce6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 3 Jun 2024 08:40:54 -0700 Subject: [PATCH 155/272] Add `base records` and more documentation --- docs/source/cli.rst | 130 ++++++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 3 +- pyairtable/cli.py | 92 ++++++++++++++++++++++++++---- tests/test_cli.py | 55 +++++++++++++++--- tox.ini | 2 +- 5 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 docs/source/cli.rst diff --git a/docs/source/cli.rst b/docs/source/cli.rst new file mode 100644 index 00000000..9dd842d8 --- /dev/null +++ b/docs/source/cli.rst @@ -0,0 +1,130 @@ +Using the Command Line +======================= + +pyAirtable ships with a rudimentary command line interface for interacting with Airtable. +This does not have full support for all Airtable API endpoints, but it does provide a way +to interact with the most common use cases. It will usually output JSON. + +CLI Quickstart +-------------- + +.. code-block:: shell + + % pip install 'pyairtable[cli]' + % read -s AIRTABLE_API_KEY + ... + % export AIRTABLE_API_KEY + % pyairtable whoami + {"id": "usrXXXXXXXXXXXX", "email": "you@example.com"} + % pyairtable base YOUR_BASE_ID table YOUR_TABLE_NAME records + [{"id": "recXXXXXXXXXXXX", "fields": {...}}, ...] + +Authentication +-------------- + +There are a few ways to pass your authentication information to the CLI: + +1. Put your API key into the ``AIRTABLE_API_KEY`` environment variable. + If you need to use a different variable name, you can pass the + appropriate variable name with the ``-ke/--key-env`` option. +2. Put your API key into a file, and put the full path to the file + into the ``AIRTABLE_API_KEY_FILE`` environment variable. + If you need to use a different variable name, you can pass the + appropriate variable name with the ``-kf/--key-file`` option. +3. Pass the API key as an argument to the CLI. This is not recommended + as it could be visible to other processes or stored in your shell history. + If you must do it, use the ``-k/--key`` option. + +Command list +------------ + +.. [[[cog + from contextlib import redirect_stdout + from io import StringIO + from pyairtable.cli import cli + import textwrap + + cog.outl() + + indent = " " * 4 + commands = [ + "--help", + "base --help", + "base BASE_ID table --help", + "base BASE_ID table TABLE_NAME records --help", + ] + for cmd in commands: + with redirect_stdout(StringIO()) as f: + cli( + ["-k", "fake", *cmd.split()], + prog_name="pyairtable", + standalone_mode=False + ) + + cog.outl(".. code-block:: shell") + cog.outl() + cog.outl(f"{indent}% pyairtable {cmd}") + cog.outl(textwrap.indent(f.getvalue(), indent)) + ]]] + +.. code-block:: shell + + % pyairtable --help + Usage: pyairtable [OPTIONS] COMMAND [ARGS]... + + Options: + -k, --key TEXT Your API key. + -kf, --key-file PATH File containing your API key. + -ke, --key-env VAR Env var containing your API key. + --help Show this message and exit. + + Commands: + base Print information about a base. + bases List all available bases. + whoami Print information about the current user. + +.. code-block:: shell + + % pyairtable base --help + Usage: pyairtable base [OPTIONS] BASE_ID COMMAND [ARGS]... + + Print information about a base. + + Options: + --help Show this message and exit. + + Commands: + orm Print a Python module with ORM models. + schema Print the base schema. + table Print information about a table. + +.. code-block:: shell + + % pyairtable base BASE_ID table --help + Usage: pyairtable base BASE_ID table [OPTIONS] ID_OR_NAME COMMAND [ARGS]... + + Print information about a table. + + Options: + --help Show this message and exit. + + Commands: + records Retrieve records from the table. + schema Print a JSON representation of the table schema. + +.. code-block:: shell + + % pyairtable base BASE_ID table TABLE_NAME records --help + Usage: pyairtable base BASE_ID table ID_OR_NAME records [OPTIONS] + + Retrieve records from the table. + + Options: + -f, --formula TEXT Filter records with a formula. + -v, --view TEXT Filter records by a view. + -n, --limit INTEGER Limit the number of records returned. + -S, --sort TEXT Sort records by field(s). + -F, --field TEXT Limit output to certain field(s). + --help Show this message and exit. + +.. [[[end]]] (checksum: 320534bcf1749b598527336a32ec0c01) diff --git a/docs/source/index.rst b/docs/source/index.rst index 0cb16ec9..2e62d0ba 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -31,6 +31,7 @@ pyAirtable metadata webhooks enterprise + cli api @@ -38,8 +39,8 @@ pyAirtable :caption: More :hidden: - migrations about + migrations changelog contributing GitHub diff --git a/pyairtable/cli.py b/pyairtable/cli.py index 225fbb43..8581c9f2 100644 --- a/pyairtable/cli.py +++ b/pyairtable/cli.py @@ -8,14 +8,16 @@ import sys from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Callable, List, Optional +from typing import Any, Callable, Optional, Sequence from typing_extensions import ParamSpec, TypeVar from pyairtable.api.api import Api from pyairtable.api.base import Base +from pyairtable.api.table import Table from pyairtable.models._base import AirtableModel from pyairtable.orm.generate import ModelFileBuilder +from pyairtable.utils import is_table_id try: import click @@ -40,6 +42,7 @@ class CliContext: access_token: str = "" base_id: str = "" + table_id_or_name: str = "" click_context: Optional["click.Context"] = None @functools.cached_property @@ -50,6 +53,10 @@ def api(self) -> Api: def base(self) -> Base: return self.api.base(self.base_id) + @functools.cached_property + def table(self) -> Table: + return self.base.table(self.table_id_or_name) + @property def click(self) -> click.Context: assert self.click_context is not None @@ -73,9 +80,9 @@ def _wrapped(click_ctx: click.Context, /, *args: P.args, **kwargs: P.kwargs) -> # fmt: off @click.group() -@click.option("-k", "--key", help="Your API key") -@click.option("-kf", "--key-file", type=click.Path(exists=True), help="File containing your API key") -@click.option("-ke", "--key-env", metavar="VAR", help="Env var containing your API key") +@click.option("-k", "--key", help="Your API key.") +@click.option("-kf", "--key-file", type=click.Path(exists=True), help="File containing your API key.") +@click.option("-ke", "--key-env", metavar="VAR", help="Env var containing your API key.") @needs_context # fmt: on def cli( @@ -110,7 +117,7 @@ def cli( @needs_context def whoami(ctx: CliContext) -> None: """ - Prints information about the current user. + Print information about the current user. """ _dump(ctx.api.whoami()) @@ -119,7 +126,7 @@ def whoami(ctx: CliContext) -> None: @needs_context def bases(ctx: CliContext) -> None: """ - Prints a JSON list of available bases. + List all available bases. """ _dump(ctx.api._base_info().bases) @@ -129,7 +136,7 @@ def bases(ctx: CliContext) -> None: @needs_context def base(ctx: CliContext, base_id: str) -> None: """ - Retrieve information about a base. + Print information about a base. """ ctx.base_id = base_id ctx.default_subcommand(base_schema) @@ -139,19 +146,27 @@ def base(ctx: CliContext, base_id: str) -> None: @needs_context def base_schema(ctx: CliContext) -> None: """ - Output a JSON representation of the base schema. + Print the base schema. """ _dump(ctx.base.schema()) @base.command("orm") @needs_context -@click.option("-t", "--table", "table_names", multiple=True) -def base_orm(ctx: CliContext, table_names: List[str]) -> None: +@click.option( + "-t", + "--table", + help="Only generate specific table(s).", + metavar="NAME_OR_ID", + multiple=True, +) +def base_orm(ctx: CliContext, table: Sequence[str]) -> None: """ - Generate a module with ORM classes for the given base. + Print a Python module with ORM models. """ - generator = ModelFileBuilder(ctx.base, table_names=table_names) + table_ids = [t for t in table if is_table_id(t)] + table_names = [t for t in table if not is_table_id(t)] + generator = ModelFileBuilder(ctx.base, table_ids=table_ids, table_names=table_names) now = datetime.now(timezone.utc).isoformat() print("# This file was generated by pyAirtable at", now) print("# Any modifications to this file will be lost if it is rebuilt.") @@ -159,6 +174,59 @@ def base_orm(ctx: CliContext, table_names: List[str]) -> None: print(str(generator)) +@base.group("table", invoke_without_command=True) +@needs_context +@click.argument("id_or_name") +def base_table(ctx: CliContext, id_or_name: str) -> None: + """ + Print information about a table. + """ + ctx.table_id_or_name = id_or_name + ctx.default_subcommand(base_table_schema) + + +@base_table.command("records") +@needs_context +# fmt: off +@click.option("-f", "--formula", help="Filter records with a formula.") +@click.option("-v", "--view", help="Filter records by a view.") +@click.option("-n", "--limit", "max_records", type=int, help="Limit the number of records returned.") +@click.option("-S", "--sort", help="Sort records by field(s).", multiple=True) +@click.option("-F", "--field", "fields", help="Limit output to certain field(s).", multiple=True) +# fmt: on +def base_table_records( + ctx: CliContext, + formula: Optional[str], + view: Optional[str], + max_records: Optional[int], + fields: Sequence[str], + sort: Sequence[str], +) -> None: + """ + Retrieve records from the table. + """ + fields = list(fields) + sort = list(sort) + _dump( + ctx.table.all( + formula=formula, + view=view, + max_records=max_records, + fields=fields, + sort=sort, + ) + ) + + +@base_table.command("schema") +@needs_context +def base_table_schema(ctx: CliContext) -> None: + """ + Print a JSON representation of the table schema. + """ + _dump(ctx.table.schema()) + + class JSONEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, AirtableModel): diff --git a/tests/test_cli.py b/tests/test_cli.py index 6756275d..32397ab0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import json +from unittest import mock import pytest from click.testing import CliRunner @@ -94,10 +95,10 @@ def test_whoami__keyfile(run, user_id, tmp_path): assert result == {"id": user_id} -def test_bases(run): +def test_bases(run, base): result = run.json("bases") assert len(result) == 2 - assert result[0]["id"] == "appLkNDICXNqxSDhG" + assert result[0]["id"] == base.id def test_base(run): @@ -105,14 +106,52 @@ def test_base(run): assert "Missing argument 'BASE_ID'" in result.output +def test_base_orm(base, run): + result = run("base", base.id, "orm") + expected = str(pyairtable.orm.generate.ModelFileBuilder(base)) + assert result.output.rstrip().endswith(expected) + + @pytest.mark.parametrize("extra_args", [[], ["schema"]]) -def test_base_schema(run, extra_args): - result = run.json("base", "appLkNDICXNqxSDhG", *extra_args) +def test_base_schema(run, base, extra_args): + result = run.json("base", base.id, *extra_args) assert list(result) == ["tables"] assert result["tables"][0]["name"] == "Apartments" -def test_base_orm(base, run): - result = run("base", "appLkNDICXNqxSDhG", "orm") - expected = str(pyairtable.orm.generate.ModelFileBuilder(base)) - assert result.output.rstrip().endswith(expected) +@pytest.mark.parametrize( + "extra_args,expected_kwargs", + [ + ([], {}), + (["-f", "$formula"], {"formula": "$formula"}), + (["--formula", "$formula"], {"formula": "$formula"}), + (["-v", "$view"], {"view": "$view"}), + (["--view", "$view"], {"view": "$view"}), + (["-n", 10], {"max_records": 10}), + (["--limit", 10], {"max_records": 10}), + (["-F", "$fld1", "--field", "$fld2"], {"fields": ["$fld1", "$fld2"]}), + (["-S", "fld1", "--sort", "-fld2"], {"sort": ["fld1", "-fld2"]}), + ], +) +@mock.patch("pyairtable.Table.all") +def test_base_table_records(mock_table_all, run, base, extra_args, expected_kwargs): + defaults = { + "formula": None, + "view": None, + "max_records": None, + "fields": [], + "sort": [], + } + expected = {**defaults, **expected_kwargs} + fake_ids = [fake_id() for _ in range(3)] + mock_table_all.return_value = [{"id": id} for id in fake_ids] + result = run.json("base", base.id, "table", "Apartments", "records", *extra_args) + mock_table_all.assert_called_once_with(**expected) + assert len(result) == 3 + assert set(record["id"] for record in result) == set(fake_ids) + + +@pytest.mark.parametrize("extra_args", [[], ["schema"]]) +def test_base_table_schema(run, base, extra_args): + result = run.json("base", base.id, "table", "Apartments", *extra_args) + assert result["fields"][0]["id"] == "fld1VnoyuotSTyxW1" diff --git a/tox.ini b/tox.ini index ea4a1a41..65bb829b 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ python = deps = pre-commit commands = pre-commit run --all-files -[testenv:mypy-py3{8,9,10,11,12}] +[testenv:mypy,mypy-py3{8,9,10,11,12}] basepython = py38: python3.8 py39: python3.9 From ef1dae6af39978d23d103b09f32d814ab83d8189 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 3 Jun 2024 18:50:46 -0700 Subject: [PATCH 156/272] Add more enterprise features to CLI --- docs/source/cli.rst | 219 ++++++++++++++++++++++++++++------- pyairtable/cli.py | 219 ++++++++++++++++++++++++++++++----- tests/conftest.py | 6 + tests/test_api_enterprise.py | 12 +- tests/test_cli.py | 146 +++++++++++++++++++---- 5 files changed, 498 insertions(+), 104 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 9dd842d8..b2d8da0e 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -35,86 +35,103 @@ There are a few ways to pass your authentication information to the CLI: as it could be visible to other processes or stored in your shell history. If you must do it, use the ``-k/--key`` option. +Shortcuts +--------- + +If you pass a partial command to the CLI, it will try to match it to a full command. +This only works if there is a single unambiguous completion for the partial command. +For example, ``pyairtable e`` will be interpreted as ``pyairtable enterprise``, +but ``pyairtable b`` is ambiguous, as it could mean ``base`` or ``bases``. + Command list ------------ .. [[[cog from contextlib import redirect_stdout from io import StringIO - from pyairtable.cli import cli + from pyairtable.cli import cli, CLI_COMMANDS import textwrap - cog.outl() - - indent = " " * 4 - commands = [ - "--help", - "base --help", - "base BASE_ID table --help", - "base BASE_ID table TABLE_NAME records --help", - ] - for cmd in commands: - with redirect_stdout(StringIO()) as f: + for cmd in ["", *CLI_COMMANDS]: + with redirect_stdout(StringIO()) as help_output: cli( - ["-k", "fake", *cmd.split()], + ["-k", "fake", *cmd.split(), "--help"], prog_name="pyairtable", standalone_mode=False ) - - cog.outl(".. code-block:: shell") + if cmd: + heading = " ".join(w for w in cmd.split() if w == w.lower()) + cog.outl() + cog.outl(heading) + cog.outl("~" * len(heading)) cog.outl() - cog.outl(f"{indent}% pyairtable {cmd}") - cog.outl(textwrap.indent(f.getvalue(), indent)) + cog.outl(".. code-block:: text") + cog.outl() + cog.outl(textwrap.indent(help_output.getvalue(), " " * 4)) ]]] -.. code-block:: shell +.. code-block:: text - % pyairtable --help Usage: pyairtable [OPTIONS] COMMAND [ARGS]... Options: -k, --key TEXT Your API key. -kf, --key-file PATH File containing your API key. -ke, --key-env VAR Env var containing your API key. + -v, --verbose Print verbose output. --help Show this message and exit. Commands: - base Print information about a base. - bases List all available bases. - whoami Print information about the current user. + base Print information about a base. + bases List all available bases. + enterprise Print information about a user. + whoami Print information about the current user. -.. code-block:: shell - % pyairtable base --help - Usage: pyairtable base [OPTIONS] BASE_ID COMMAND [ARGS]... +whoami +~~~~~~ - Print information about a base. +.. code-block:: text + + Usage: pyairtable whoami [OPTIONS] + + Print information about the current user. Options: --help Show this message and exit. - Commands: - orm Print a Python module with ORM models. - schema Print the base schema. - table Print information about a table. -.. code-block:: shell +bases +~~~~~ + +.. code-block:: text - % pyairtable base BASE_ID table --help - Usage: pyairtable base BASE_ID table [OPTIONS] ID_OR_NAME COMMAND [ARGS]... + Usage: pyairtable bases [OPTIONS] - Print information about a table. + List all available bases. Options: --help Show this message and exit. - Commands: - records Retrieve records from the table. - schema Print a JSON representation of the table schema. -.. code-block:: shell +base schema +~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable base BASE_ID schema [OPTIONS] + + Print the base schema. + + Options: + --help Show this message and exit. + + +base table records +~~~~~~~~~~~~~~~~~~ + +.. code-block:: text - % pyairtable base BASE_ID table TABLE_NAME records --help Usage: pyairtable base BASE_ID table ID_OR_NAME records [OPTIONS] Retrieve records from the table. @@ -127,4 +144,126 @@ Command list -F, --field TEXT Limit output to certain field(s). --help Show this message and exit. -.. [[[end]]] (checksum: 320534bcf1749b598527336a32ec0c01) + +base table schema +~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable base BASE_ID table ID_OR_NAME schema [OPTIONS] + + Print a JSON representation of the table schema. + + Options: + --help Show this message and exit. + + +base collaborators +~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable base BASE_ID collaborators [OPTIONS] + + Print base collaborators. + + Options: + --help Show this message and exit. + + +base shares +~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable base BASE_ID shares [OPTIONS] + + Print base shares. + + Options: + --help Show this message and exit. + + +base orm +~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable base BASE_ID orm [OPTIONS] + + Print a Python module with ORM models. + + Options: + -t, --table NAME_OR_ID Only generate specific table(s). + --help Show this message and exit. + + +enterprise info +~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable enterprise ENTERPRISE_ID info [OPTIONS] + + Print information about an enterprise. + + Options: + --help Show this message and exit. + + +enterprise user +~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable enterprise ENTERPRISE_ID user [OPTIONS] ID_OR_EMAIL + + Print one user's information. + + Options: + --help Show this message and exit. + + +enterprise users +~~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable enterprise ENTERPRISE_ID users [OPTIONS] ID_OR_EMAIL... + + Print many users' information, keyed by user ID. + + Options: + -c, --collaborations Include collaborations. + -a, --all Retrieve all users. + --help Show this message and exit. + + +enterprise group +~~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable enterprise ENTERPRISE_ID group [OPTIONS] GROUP_ID + + Print a user group's information. + + Options: + --help Show this message and exit. + + +enterprise groups +~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + Usage: pyairtable enterprise ENTERPRISE_ID groups [OPTIONS] GROUP_ID... + + Print many user groups' info, keyed by group ID. + + Options: + -a, --all Retrieve all groups. + -c, --collaborations Include collaborations. + --help Show this message and exit. + +.. [[[end]]] (checksum: 23cfc87fff98fb6b74461c3f1c327547) diff --git a/pyairtable/cli.py b/pyairtable/cli.py index 8581c9f2..d9f4c017 100644 --- a/pyairtable/cli.py +++ b/pyairtable/cli.py @@ -8,16 +8,17 @@ import sys from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Callable, Optional, Sequence +from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Union from typing_extensions import ParamSpec, TypeVar from pyairtable.api.api import Api from pyairtable.api.base import Base +from pyairtable.api.enterprise import Enterprise from pyairtable.api.table import Table from pyairtable.models._base import AirtableModel from pyairtable.orm.generate import ModelFileBuilder -from pyairtable.utils import is_table_id +from pyairtable.utils import chunked, is_table_id try: import click @@ -43,6 +44,7 @@ class CliContext: access_token: str = "" base_id: str = "" table_id_or_name: str = "" + enterprise_id: str = "" click_context: Optional["click.Context"] = None @functools.cached_property @@ -57,6 +59,10 @@ def base(self) -> Base: def table(self) -> Table: return self.base.table(self.table_id_or_name) + @functools.cached_property + def enterprise(self) -> Enterprise: + return self.api.enterprise(self.enterprise_id) + @property def click(self) -> click.Context: assert self.click_context is not None @@ -78,11 +84,29 @@ def _wrapped(click_ctx: click.Context, /, *args: P.args, **kwargs: P.kwargs) -> return _wrapped +class ShortcutGroup(click.Group): + """ + A command group that will accept partial command names and complete them. + """ + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + if exact := super().get_command(ctx, cmd_name): + return exact + # If exactly one subcommand starts with the given name, use that. + existing = [ + subcmd for subcmd in self.list_commands(ctx) if subcmd.startswith(cmd_name) + ] + if len(existing) == 1: + return super().get_command(ctx, existing[0]) + return None + + # fmt: off -@click.group() +@click.group(cls=ShortcutGroup) @click.option("-k", "--key", help="Your API key.") @click.option("-kf", "--key-file", type=click.Path(exists=True), help="File containing your API key.") @click.option("-ke", "--key-env", metavar="VAR", help="Env var containing your API key.") +@click.option("-v", "--verbose", is_flag=True, help="Print verbose output.") @needs_context # fmt: on def cli( @@ -90,6 +114,7 @@ def cli( key: str = "", key_file: str = "", key_env: str = "", + verbose: bool = False, ) -> None: if not any([key, key_file, key_env]): try: @@ -131,7 +156,7 @@ def bases(ctx: CliContext) -> None: _dump(ctx.api._base_info().bases) -@cli.group(invoke_without_command=True) +@cli.group(invoke_without_command=True, cls=ShortcutGroup) @click.argument("base_id") @needs_context def base(ctx: CliContext, base_id: str) -> None: @@ -151,30 +176,7 @@ def base_schema(ctx: CliContext) -> None: _dump(ctx.base.schema()) -@base.command("orm") -@needs_context -@click.option( - "-t", - "--table", - help="Only generate specific table(s).", - metavar="NAME_OR_ID", - multiple=True, -) -def base_orm(ctx: CliContext, table: Sequence[str]) -> None: - """ - Print a Python module with ORM models. - """ - table_ids = [t for t in table if is_table_id(t)] - table_names = [t for t in table if not is_table_id(t)] - generator = ModelFileBuilder(ctx.base, table_ids=table_ids, table_names=table_names) - now = datetime.now(timezone.utc).isoformat() - print("# This file was generated by pyAirtable at", now) - print("# Any modifications to this file will be lost if it is rebuilt.") - print() - print(str(generator)) - - -@base.group("table", invoke_without_command=True) +@base.group("table", invoke_without_command=True, cls=ShortcutGroup) @needs_context @click.argument("id_or_name") def base_table(ctx: CliContext, id_or_name: str) -> None: @@ -227,6 +229,145 @@ def base_table_schema(ctx: CliContext) -> None: _dump(ctx.table.schema()) +@base.command("collaborators") +@needs_context +def base_collaborators(ctx: CliContext) -> None: + """ + Print base collaborators. + """ + _dump(ctx.base.collaborators()) + + +@base.command("shares") +@needs_context +def base_shares(ctx: CliContext) -> None: + """ + Print base shares. + """ + _dump(ctx.base.shares()) + + +@base.command("orm") +@needs_context +@click.option( + "-t", + "--table", + help="Only generate specific table(s).", + metavar="NAME_OR_ID", + multiple=True, +) +def base_orm(ctx: CliContext, table: Sequence[str]) -> None: + """ + Print a Python module with ORM models. + """ + table_ids = [t for t in table if is_table_id(t)] + table_names = [t for t in table if not is_table_id(t)] + generator = ModelFileBuilder(ctx.base, table_ids=table_ids, table_names=table_names) + now = datetime.now(timezone.utc).isoformat() + print("# This file was generated by pyAirtable at", now) + print("# Any modifications to this file will be lost if it is rebuilt.") + print() + print(str(generator)) + + +@cli.group(invoke_without_command=True, cls=ShortcutGroup) +@click.argument("enterprise_id") +@needs_context +def enterprise(ctx: CliContext, enterprise_id: str) -> None: + """ + Print information about a user. + """ + ctx.enterprise_id = enterprise_id + ctx.default_subcommand(enterprise_info) + + +@enterprise.command("info") +@needs_context +def enterprise_info(ctx: CliContext) -> None: + """ + Print information about an enterprise. + """ + _dump(ctx.enterprise.info()) + + +@enterprise.command("user") +@needs_context +@click.argument("id_or_email") +def enterprise_user(ctx: CliContext, id_or_email: str) -> None: + """ + Print one user's information. + """ + _dump(ctx.enterprise.user(id_or_email)) + + +@enterprise.command("users") +@needs_context +@click.argument("ids_or_emails", metavar="ID_OR_EMAIL...", nargs=-1) +@click.option("-c", "--collaborations", is_flag=True, help="Include collaborations.") +@click.option("-a", "--all", "all_users", is_flag=True, help="Retrieve all users.") +def enterprise_users( + ctx: CliContext, + ids_or_emails: Sequence[str], + collaborations: bool = False, + all_users: bool = False, +) -> None: + """ + Print many users' information, keyed by user ID. + """ + if all_users and ids_or_emails: + raise click.UsageError("Cannot combine --all with specific user IDs/emails.") + if all_users: + ids_or_emails = list(ctx.enterprise.info().user_ids) + if not ids_or_emails: + raise click.UsageError("No user IDs or emails provided.") + _dump( + { + user.id: user._raw + for chunk in chunked(ids_or_emails, 100) + for user in ctx.enterprise.users(chunk, collaborations=collaborations) + } + ) + + +@enterprise.command("group") +@needs_context +@click.argument("group_id") +def enterprise_group(ctx: CliContext, group_id: str) -> None: + """ + Print a user group's information. + """ + _dump(ctx.enterprise.group(group_id)) + + +@enterprise.command("groups") +@needs_context +@click.argument("group_ids", metavar="GROUP_ID...", nargs=-1) +@click.option("-a", "--all", "all_groups", is_flag=True, help="Retrieve all groups.") +@click.option("-c", "--collaborations", is_flag=True, help="Include collaborations.") +def enterprise_groups( + ctx: CliContext, + group_ids: Sequence[str], + all_groups: bool = False, + collaborations: bool = False, +) -> None: + """ + Print many user groups' info, keyed by group ID. + """ + if all_groups and group_ids: + raise click.UsageError("Cannot combine --all with specific group IDs.") + if all_groups: + group_ids = list(ctx.enterprise.info().group_ids) + if not group_ids: + raise click.UsageError("No group IDs provided.") + _dump( + { + group.id: group._raw + for group_id in group_ids + if (group := ctx.enterprise.group(group_id, collaborations=collaborations)) + } + ) + + class JSONEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, AirtableModel): @@ -238,5 +379,27 @@ def _dump(obj: Any) -> None: print(json.dumps(obj, cls=JSONEncoder)) +def _gather_commands( + command: Union[click.Command, click.Group], + prefix: str = "", +) -> Iterator[Tuple[str, Union[click.Command, click.Group]]]: + if isinstance(command, click.Group): + if command.name != "cli": + prefix = f"{prefix} {command.name}".strip() + for param in command.params: + if isinstance(param, click.Argument): + metavar = (param.metavar or param.name or "ARG").upper() + prefix = f"{prefix} {metavar}".strip() + for subcommand in command.commands.values(): + yield from _gather_commands(subcommand, prefix=prefix) + return + if isinstance(command, click.Command): + yield (f"{prefix} {command.name}".strip(), command) + + +#: Mapping of command names to their functions. +CLI_COMMANDS = dict(_gather_commands(cli)) + + if __name__ == "__main__": cli() # pragma: no cover diff --git a/tests/conftest.py b/tests/conftest.py index 0d6bb8a6..d229aea8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from requests import HTTPError from pyairtable import Api, Base, Table, Workspace +from pyairtable.api.enterprise import Enterprise @pytest.fixture @@ -195,3 +196,8 @@ def mock_base_metadata(base, sample_json, requests_mock): def mock_workspace_metadata(workspace, sample_json, requests_mock): workspace_json = sample_json("WorkspaceCollaborators") requests_mock.get(workspace.url, json=workspace_json) + + +@pytest.fixture +def enterprise(api): + return Enterprise(api, "entUBq2RGdihxl3vU") diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 27c3a1ac..7f126735 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -3,20 +3,10 @@ import pytest -from pyairtable.api.enterprise import ( - DeleteUsersResponse, - Enterprise, - ManageUsersResponse, -) +from pyairtable.api.enterprise import DeleteUsersResponse, ManageUsersResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.testing import fake_id - -@pytest.fixture -def enterprise(api): - return Enterprise(api, "entUBq2RGdihxl3vU") - - N_AUDIT_PAGES = 15 N_AUDIT_PAGE_SIZE = 10 diff --git a/tests/test_cli.py b/tests/test_cli.py index 32397ab0..f75e2252 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,29 +10,49 @@ @pytest.fixture -def user_id(api, requests_mock): - user_id = fake_id("usr") - requests_mock.get(api.build_url("meta/whoami"), json={"id": user_id}) - return user_id +def user_id(): + return "usrL2PNC5o3H4lBEi" @pytest.fixture(autouse=True) -def mock_metadata(user_id, mock_base_metadata, mock_workspace_metadata): - pass +def mock_metadata( + api, + user_id, + mock_base_metadata, + mock_workspace_metadata, + enterprise, + requests_mock, + sample_json, +): + user_info = sample_json("UserInfo") + user_group = sample_json("UserGroup") + enterprise_info = sample_json("EnterpriseInfo") + requests_mock.get(api.build_url("meta/whoami"), json={"id": user_id}) + requests_mock.get(enterprise.url, json=enterprise_info) + requests_mock.get(f"{enterprise.url}/users/{user_id}", json=user_info) + requests_mock.get(f"{enterprise.url}/users", json={"users": [user_info]}) + for group_id in enterprise_info["groupIds"]: + requests_mock.get( + enterprise.api.build_url(f"meta/groups/{group_id}"), json=user_group + ) @pytest.fixture -def run(mock_base_metadata): +def run(mock_metadata): default_env = {"AIRTABLE_API_KEY": "test"} def _runner(*args: str, env: dict = default_env, fails: bool = False): runner = CliRunner(env=env) result = runner.invoke(pyairtable.cli.cli, args) - print(result.output) # if a test fails, show the command's output - if fails: - assert result.exit_code != 0 - else: - assert result.exit_code == 0 + # if a test fails, show the command's output + print(f"{result.output=}") + if fails and result.exit_code == 0: + raise RuntimeError("expected failure, but command succeeded") + if result.exit_code != 0 and not fails: + print(f"{result.exception=}") + if hasattr(result.exception, "request"): + print(f"{result.exception.request.url=}") + raise RuntimeError(f"command failed: {args}") return result def _runner_with_json(*args, **kwargs): @@ -51,7 +71,7 @@ def test_help(run): for line in result.output.split("Commands:", 1)[1].splitlines() if (words := line.strip().split()) ] - assert commands == ["base", "bases", "whoami"] + assert commands == ["base", "bases", "enterprise", "whoami"] def test_error_without_key(run): @@ -59,6 +79,10 @@ def test_error_without_key(run): assert "--key, --key-file, or --key-env required" in result.output +def test_error_invalid_command(run): + run("asdf", fails=True) + + def test_invalid_key_args(run, tmp_path): keyfile = tmp_path / "keyfile.txt" keyfile.write_text("fakeKey") @@ -72,26 +96,30 @@ def test_invalid_key_args(run, tmp_path): assert "only one of --key, --key-file, --key-env allowed" in result.output -def test_whoami(run, user_id): - result = run.json("whoami") +@pytest.mark.parametrize("cmd", ["whoami", "who", "w"]) # test alias +def test_whoami(run, cmd, user_id): + result = run.json(cmd) assert result == {"id": user_id} -def test_whoami__key(run, user_id): - result = run.json("-k", "key", "whoami", env={}) +@pytest.mark.parametrize("option", ["-k", "--key"]) +def test_whoami__key(run, option, user_id): + result = run.json(option, "key", "whoami", env={}) assert result == {"id": user_id} -def test_whoami__keyenv(run, user_id): +@pytest.mark.parametrize("option", ["-ke", "--key-env"]) +def test_whoami__keyenv(run, option, user_id): env = {"THE_KEY": "fakeKey"} - result = run.json("-ke", "THE_KEY", "whoami", env=env) + result = run.json(option, "THE_KEY", "whoami", env=env) assert result == {"id": user_id} -def test_whoami__keyfile(run, user_id, tmp_path): +@pytest.mark.parametrize("option", ["-kf", "--key-file"]) +def test_whoami__keyfile(run, option, user_id, tmp_path): keyfile = tmp_path / "keyfile.txt" keyfile.write_text("fakeKey") - result = run.json("-kf", keyfile, "whoami", env={}) + result = run.json(option, keyfile, "whoami", env={}) assert result == {"id": user_id} @@ -106,8 +134,9 @@ def test_base(run): assert "Missing argument 'BASE_ID'" in result.output -def test_base_orm(base, run): - result = run("base", base.id, "orm") +@pytest.mark.parametrize("cmd", ["orm", "o"]) # test alias +def test_base_orm(base, run, cmd): + result = run("base", base.id, cmd) expected = str(pyairtable.orm.generate.ModelFileBuilder(base)) assert result.output.rstrip().endswith(expected) @@ -119,6 +148,7 @@ def test_base_schema(run, base, extra_args): assert result["tables"][0]["name"] == "Apartments" +@pytest.mark.parametrize("cmd", ["records", "r"]) # test alias @pytest.mark.parametrize( "extra_args,expected_kwargs", [ @@ -134,7 +164,9 @@ def test_base_schema(run, base, extra_args): ], ) @mock.patch("pyairtable.Table.all") -def test_base_table_records(mock_table_all, run, base, extra_args, expected_kwargs): +def test_base_table_records( + mock_table_all, run, cmd, base, extra_args, expected_kwargs +): defaults = { "formula": None, "view": None, @@ -145,7 +177,7 @@ def test_base_table_records(mock_table_all, run, base, extra_args, expected_kwar expected = {**defaults, **expected_kwargs} fake_ids = [fake_id() for _ in range(3)] mock_table_all.return_value = [{"id": id} for id in fake_ids] - result = run.json("base", base.id, "table", "Apartments", "records", *extra_args) + result = run.json("base", base.id, "table", "Apartments", cmd, *extra_args) mock_table_all.assert_called_once_with(**expected) assert len(result) == 3 assert set(record["id"] for record in result) == set(fake_ids) @@ -155,3 +187,67 @@ def test_base_table_records(mock_table_all, run, base, extra_args, expected_kwar def test_base_table_schema(run, base, extra_args): result = run.json("base", base.id, "table", "Apartments", *extra_args) assert result["fields"][0]["id"] == "fld1VnoyuotSTyxW1" + + +@pytest.mark.parametrize("cmd", ["c", "collaborators"]) +def test_base_collaborators(run, base, cmd): + result = run.json("base", base.id, cmd) + assert result["id"] == base.id + assert result["collaborators"]["baseCollaborators"][0]["email"] == "foo@bam.com" + + +@pytest.mark.parametrize("cmd", ["sh", "shares"]) +def test_base_shares(run, base, cmd): + result = run.json("base", base.id, cmd) + assert result[0]["shareId"] == "shr9SpczJvQpfAzSp" + + +@pytest.mark.parametrize("cmd", ["e", "enterprise"]) +@pytest.mark.parametrize("extra_args", [[], ["info"]]) +def test_enterprise_info(run, enterprise, cmd, extra_args): + result = run.json(cmd, enterprise.id, *extra_args) + assert result["id"] == enterprise.id + + +def test_enterprise_user(run, enterprise, user_id): + result = run.json("enterprise", enterprise.id, "user", user_id) + assert result["id"] == user_id + assert result["email"] == "foo@bar.com" + + +def test_enterprise_users(run, enterprise, user_id): + result = run.json("enterprise", enterprise.id, "users", user_id) + assert list(result) == [user_id] + assert result[user_id]["id"] == user_id + assert result[user_id]["email"] == "foo@bar.com" + + +def test_enterprise_users__all(run, enterprise, user_id): + result = run.json("enterprise", enterprise.id, "users", "--all") + assert list(result) == [user_id] + assert result[user_id]["id"] == user_id + assert result[user_id]["email"] == "foo@bar.com" + + +def test_enterprise_users__invalid(run, enterprise, user_id): + run("enterprise", enterprise.id, "users", fails=True) + run("enterprise", enterprise.id, "users", "--all", user_id, fails=True) + + +def test_enterprise_group(run, enterprise): + result = run.json("enterprise", enterprise.id, "group", "ugp1mKGb3KXUyQfOZ") + assert result["id"] == "ugp1mKGb3KXUyQfOZ" + assert result["name"] == "Group name" + + +@pytest.mark.parametrize("option", ["ugp1mKGb3KXUyQfOZ", "-a", "--all"]) +def test_enterprise_groups(run, enterprise, option): + result = run.json("enterprise", enterprise.id, "groups", option) + assert list(result) == ["ugp1mKGb3KXUyQfOZ"] + assert result["ugp1mKGb3KXUyQfOZ"]["id"] == "ugp1mKGb3KXUyQfOZ" + assert result["ugp1mKGb3KXUyQfOZ"]["name"] == "Group name" + + +def test_enterprise_groups__invalid(run, enterprise): + run("enterprise", enterprise.id, "groups", fails=True) + run("enterprise", enterprise.id, "groups", "--all", "ugp1mKGb3KXUyQfOZ", fails=True) From 0e25b6187935f6fe05c87d3d78e708703c8db1e4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 5 Jun 2024 20:31:05 -0700 Subject: [PATCH 157/272] Add full command list to `--help` --- docs/source/cli.rst | 18 +++++++++++-- pyairtable/cli.py | 62 ++++++++++++++++++++++++++++++--------------- tests/test_cli.py | 13 +++++----- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index b2d8da0e..eef2c515 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -85,9 +85,23 @@ Command list base Print information about a base. bases List all available bases. enterprise Print information about a user. + list Print a list of all available commands. whoami Print information about the current user. +list +~~~~ + +.. code-block:: text + + Usage: pyairtable list [OPTIONS] + + Print a list of all available commands. + + Options: + --help Show this message and exit. + + whoami ~~~~~~ @@ -191,7 +205,7 @@ base orm Usage: pyairtable base BASE_ID orm [OPTIONS] - Print a Python module with ORM models. + Generate a Python module with ORM models. Options: -t, --table NAME_OR_ID Only generate specific table(s). @@ -266,4 +280,4 @@ enterprise groups -c, --collaborations Include collaborations. --help Show this message and exit. -.. [[[end]]] (checksum: 23cfc87fff98fb6b74461c3f1c327547) +.. [[[end]]] (checksum: 1d5e34beb09b9f5b89f772194b28ec09) diff --git a/pyairtable/cli.py b/pyairtable/cli.py index d9f4c017..dbc51639 100644 --- a/pyairtable/cli.py +++ b/pyairtable/cli.py @@ -5,11 +5,13 @@ import functools import json import os +import re import sys from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Callable, Iterator, Optional, Sequence, Tuple, Union +from click import Context, HelpFormatter from typing_extensions import ParamSpec, TypeVar from pyairtable.api.api import Api @@ -93,13 +95,23 @@ def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Comma if exact := super().get_command(ctx, cmd_name): return exact # If exactly one subcommand starts with the given name, use that. - existing = [ - subcmd for subcmd in self.list_commands(ctx) if subcmd.startswith(cmd_name) - ] + existing = [cmd for cmd in self.list_commands(ctx) if cmd.startswith(cmd_name)] if len(existing) == 1: return super().get_command(ctx, existing[0]) return None + def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: + from gettext import gettext as _ + + rows = [ + (name, (command.short_help or command.help or "").strip()) + for name, command in CLI_COMMANDS.items() + ] + col_max = max(len(row[0]) for row in rows) + + with formatter.section(_("Commands")): + formatter.write_dl(rows, col_max=col_max) + # fmt: off @click.group(cls=ShortcutGroup) @@ -142,7 +154,7 @@ def cli( @needs_context def whoami(ctx: CliContext) -> None: """ - Print information about the current user. + Print the current user's information. """ _dump(ctx.api.whoami()) @@ -224,7 +236,7 @@ def base_table_records( @needs_context def base_table_schema(ctx: CliContext) -> None: """ - Print a JSON representation of the table schema. + Print the table's schema as JSON. """ _dump(ctx.table.schema()) @@ -258,7 +270,7 @@ def base_shares(ctx: CliContext) -> None: ) def base_orm(ctx: CliContext, table: Sequence[str]) -> None: """ - Print a Python module with ORM models. + Generate a Python ORM module. """ table_ids = [t for t in table if is_table_id(t)] table_names = [t for t in table if not is_table_id(t)] @@ -312,7 +324,7 @@ def enterprise_users( all_users: bool = False, ) -> None: """ - Print many users' information, keyed by user ID. + Print many users, keyed by user ID. """ if all_users and ids_or_emails: raise click.UsageError("Cannot combine --all with specific user IDs/emails.") @@ -351,7 +363,7 @@ def enterprise_groups( collaborations: bool = False, ) -> None: """ - Print many user groups' info, keyed by group ID. + Print many groups, keyed by group ID. """ if all_groups and group_ids: raise click.UsageError("Cannot combine --all with specific group IDs.") @@ -380,21 +392,31 @@ def _dump(obj: Any) -> None: def _gather_commands( - command: Union[click.Command, click.Group], + command: Union[click.Command, click.Group] = cli, prefix: str = "", ) -> Iterator[Tuple[str, Union[click.Command, click.Group]]]: - if isinstance(command, click.Group): - if command.name != "cli": - prefix = f"{prefix} {command.name}".strip() - for param in command.params: - if isinstance(param, click.Argument): - metavar = (param.metavar or param.name or "ARG").upper() - prefix = f"{prefix} {metavar}".strip() - for subcommand in command.commands.values(): - yield from _gather_commands(subcommand, prefix=prefix) + """ + Enumerate through all commands and groups, yielding a 2-tuple of + a human-readable command line and the associated function. + """ + # placeholders for arguments so we make a valid testable command + if command.name != cli.name: + prefix = f"{prefix} {command.name}".strip() + + for param in command.params: + if not isinstance(param, click.Argument): + continue + if param.required or (param.metavar and param.metavar.endswith("...")): + metavar = (param.metavar or param.name or "ARG").upper() + metavar = re.sub(r"\b[A-Z]+_ID", "ID", metavar) + prefix = f"{prefix} {metavar}".strip() + + if not isinstance(command, click.Group): + yield (prefix, command) return - if isinstance(command, click.Command): - yield (f"{prefix} {command.name}".strip(), command) + + for subcommand in command.commands.values(): + yield from _gather_commands(subcommand, prefix=prefix) #: Mapping of command names to their functions. diff --git a/tests/test_cli.py b/tests/test_cli.py index f75e2252..874f9489 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,13 +65,14 @@ def _runner_with_json(*args, **kwargs): def test_help(run): + """ + Test that the --help message lists the correct top-level commands. + """ result = run("--help") - commands = [ - words[0] - for line in result.output.split("Commands:", 1)[1].splitlines() - if (words := line.strip().split()) - ] - assert commands == ["base", "bases", "enterprise", "whoami"] + lines = result.output.split("Commands:", 1)[1].splitlines() + defined_commands = set(pyairtable.cli.CLI_COMMANDS) + listed_commands = set(line.strip().split(" ")[0] for line in lines) + assert not defined_commands - listed_commands def test_error_without_key(run): From 1811d48feb57bdd6446a62d57349389984d6d2cd Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 31 Jul 2024 21:21:19 -0700 Subject: [PATCH 158/272] Fix mypy-py38 warning --- pyairtable/orm/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index d661d7f4..65430ad7 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -373,7 +373,7 @@ def from_id( memoize: |kwarg_orm_memoize| """ try: - instance = cast(SelfType, cls._memoized[record_id]) + instance = cast(SelfType, cls._memoized[record_id]) # type: ignore[redundant-cast] except KeyError: instance = cls(id=record_id) if fetch and not instance._fetched: @@ -421,7 +421,7 @@ def from_ids( if cls._memoized: for record_id in record_ids: try: - by_id[record_id] = cast(SelfType, cls._memoized[record_id]) + by_id[record_id] = cast(SelfType, cls._memoized[record_id]) # type: ignore[redundant-cast] except KeyError: pass From b41ea4c13bbb881ee8727e0f67b7282d0e6cfa01 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 31 Jul 2024 22:40:27 -0700 Subject: [PATCH 159/272] make docs --- docs/source/cli.rst | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index eef2c515..d542b7e6 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -82,24 +82,19 @@ Command list --help Show this message and exit. Commands: - base Print information about a base. - bases List all available bases. - enterprise Print information about a user. - list Print a list of all available commands. - whoami Print information about the current user. - - -list -~~~~ - -.. code-block:: text - - Usage: pyairtable list [OPTIONS] - - Print a list of all available commands. - - Options: - --help Show this message and exit. + whoami Print the current user's information. + bases List all available bases. + base ID schema Print the base schema. + base ID table ID_OR_NAME records Retrieve records from the table. + base ID table ID_OR_NAME schema Print the table's schema as JSON. + base ID collaborators Print base collaborators. + base ID shares Print base shares. + base ID orm Generate a Python ORM module. + enterprise ID info Print information about an enterprise. + enterprise ID user ID_OR_EMAIL Print one user's information. + enterprise ID users ID_OR_EMAIL... Print many users, keyed by user ID. + enterprise ID group ID Print a user group's information. + enterprise ID groups ID... Print many groups, keyed by group ID. whoami @@ -109,7 +104,7 @@ whoami Usage: pyairtable whoami [OPTIONS] - Print information about the current user. + Print the current user's information. Options: --help Show this message and exit. @@ -166,7 +161,7 @@ base table schema Usage: pyairtable base BASE_ID table ID_OR_NAME schema [OPTIONS] - Print a JSON representation of the table schema. + Print the table's schema as JSON. Options: --help Show this message and exit. @@ -205,7 +200,7 @@ base orm Usage: pyairtable base BASE_ID orm [OPTIONS] - Generate a Python module with ORM models. + Generate a Python ORM module. Options: -t, --table NAME_OR_ID Only generate specific table(s). @@ -245,7 +240,7 @@ enterprise users Usage: pyairtable enterprise ENTERPRISE_ID users [OPTIONS] ID_OR_EMAIL... - Print many users' information, keyed by user ID. + Print many users, keyed by user ID. Options: -c, --collaborations Include collaborations. @@ -273,11 +268,11 @@ enterprise groups Usage: pyairtable enterprise ENTERPRISE_ID groups [OPTIONS] GROUP_ID... - Print many user groups' info, keyed by group ID. + Print many groups, keyed by group ID. Options: -a, --all Retrieve all groups. -c, --collaborations Include collaborations. --help Show this message and exit. -.. [[[end]]] (checksum: 1d5e34beb09b9f5b89f772194b28ec09) +.. [[[end]]] (checksum: 9181d3a8abea1b24cb46cb6e997b08f0) From 7bc92a39f149f4998c47f64840d0b63e280d902e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 13 Aug 2024 12:01:22 -0700 Subject: [PATCH 160/272] Add support for undocumented "manualSort" field schema --- pyairtable/models/schema.py | 18 +++++++++++++++++- pyairtable/orm/fields.py | 1 + .../field_schema/ManualSortFieldSchema.json | 6 ++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/sample_data/field_schema/ManualSortFieldSchema.json diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 4d02651d..d549955d 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -810,6 +810,14 @@ class LastModifiedTimeFieldOptions(AirtableModel): result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] +class ManualSortFieldConfig(AirtableModel): + """ + Field configuration for ``manualSort`` field type (not documented). + """ + + type: Literal["manualSort"] + + class MultilineTextFieldConfig(AirtableModel): """ Field configuration for `Long text `__. @@ -1074,6 +1082,7 @@ class _FieldSchemaBase( FormulaFieldConfig, LastModifiedByFieldConfig, LastModifiedTimeFieldConfig, + ManualSortFieldConfig, MultilineTextFieldConfig, MultipleAttachmentsFieldConfig, MultipleCollaboratorsFieldConfig, @@ -1196,6 +1205,12 @@ class LastModifiedTimeFieldSchema(_FieldSchemaBase, LastModifiedTimeFieldConfig) """ +class ManualSortFieldSchema(_FieldSchemaBase, ManualSortFieldConfig): + """ + Field schema for ``manualSort`` field type (not documented). + """ + + class MultilineTextFieldSchema(_FieldSchemaBase, MultilineTextFieldConfig): """ Field schema for `Long text `__. @@ -1317,6 +1332,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): FormulaFieldSchema, LastModifiedByFieldSchema, LastModifiedTimeFieldSchema, + ManualSortFieldSchema, MultilineTextFieldSchema, MultipleAttachmentsFieldSchema, MultipleCollaboratorsFieldSchema, @@ -1335,7 +1351,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): UrlFieldSchema, UnknownFieldSchema, ] -# [[[end]]] (checksum: afb669896323650954a082cb4b079c16) +# [[[end]]] (checksum: ca159bc8c76b1d15a2a57f0e76fb8911) # fmt: on diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index e7ca4456..f1f58ea6 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -1414,6 +1414,7 @@ class CreatedTimeField(RequiredDatetimeField): "lastModifiedBy": LastModifiedByField, "lastModifiedTime": LastModifiedTimeField, "lookup": LookupField, + "manualSort": ManualSortField, "multilineText": TextField, "multipleAttachments": AttachmentsField, "multipleCollaborators": MultipleCollaboratorsField, diff --git a/tests/sample_data/field_schema/ManualSortFieldSchema.json b/tests/sample_data/field_schema/ManualSortFieldSchema.json new file mode 100644 index 00000000..d99cfa36 --- /dev/null +++ b/tests/sample_data/field_schema/ManualSortFieldSchema.json @@ -0,0 +1,6 @@ +{ + "type": "manualSort", + "options": null, + "id": "fldqCjrs1UhXgHUIc", + "name": "Record Sort" +} From aeee55c413bfb966fddc3906fe57d30b58af5e04 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 13 Aug 2024 12:13:23 -0700 Subject: [PATCH 161/272] Add support for "manualSort" field type in ORM --- docs/source/orm.rst | 6 ++++-- pyairtable/orm/fields.py | 12 +++++++++++- pyairtable/orm/model.py | 4 ++-- tests/test_orm_fields.py | 2 ++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 3a37cc47..40473ccc 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -118,7 +118,7 @@ read `Field types and cell values `", cls.__doc__ or "") ro = ' ๐Ÿ”’' if cls.readonly else '' cog.outl(f" * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}") - cog.outl(f" - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}") + cog.outl(f" - {', '.join(f'{link}__' for link in links) if links else '(undocumented)'}") classes = sorted(fields.ALL_FIELDS, key=attrgetter("__name__")) optional = [cls for cls in classes if not cls.__name__.startswith("Required")] @@ -185,6 +185,8 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.LookupField` ๐Ÿ”’ - `Lookup `__ + * - :class:`~pyairtable.orm.fields.ManualSortField` ๐Ÿ”’ + - (undocumented) * - :class:`~pyairtable.orm.fields.MultipleCollaboratorsField` - `Multiple Collaborators `__ * - :class:`~pyairtable.orm.fields.MultipleSelectField` @@ -257,7 +259,7 @@ See :ref:`Required Values` for more details. - `Single line text `__, `Long text `__ * - :class:`~pyairtable.orm.fields.RequiredUrlField` - `Url `__ -.. [[[end]]] (checksum: 131138e1071ba71d4f46f05da4d57570) +.. [[[end]]] (checksum: 43c56200ca513d3a0603bb5a6ddbb1ef) Formula, Rollup, and Lookup Fields diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index f1f58ea6..a18cfa26 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -962,6 +962,14 @@ class LookupField(Generic[T], _ListField[T, T]): readonly = True +class ManualSortField(TextField): + """ + Field configuration for ``manualSort`` field type (not documented). + """ + + readonly = True + + class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]): """ Accepts a list of dicts in the format detailed in @@ -1072,6 +1080,7 @@ class UrlField(TextField): "LastModifiedByField", "LastModifiedTimeField", "ExternalSyncSourceField", + "ManualSortField", }: continue @@ -1487,6 +1496,7 @@ class CreatedTimeField(RequiredDatetimeField): "LastModifiedTimeField", "LinkField", "LookupField", + "ManualSortField", "MultipleCollaboratorsField", "MultipleSelectField", "NumberField", @@ -1523,7 +1533,7 @@ class CreatedTimeField(RequiredDatetimeField): "FIELD_CLASSES_TO_TYPES", "LinkSelf", ] -# [[[end]]] (checksum: 21316c688401f32f59d597c496d48bf3) +# [[[end]]] (checksum: 3ea404fb3814f0d053b93d3479ab5e13) # Delayed import to avoid circular dependency diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index d661d7f4..65430ad7 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -373,7 +373,7 @@ def from_id( memoize: |kwarg_orm_memoize| """ try: - instance = cast(SelfType, cls._memoized[record_id]) + instance = cast(SelfType, cls._memoized[record_id]) # type: ignore[redundant-cast] except KeyError: instance = cls(id=record_id) if fetch and not instance._fetched: @@ -421,7 +421,7 @@ def from_ids( if cls._memoized: for record_id in record_ids: try: - by_id[record_id] = cast(SelfType, cls._memoized[record_id]) + by_id[record_id] = cast(SelfType, cls._memoized[record_id]) # type: ignore[redundant-cast] except KeyError: pass diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 3b68aff2..7ce54e33 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -271,6 +271,7 @@ class Container(Model): (f.LookupField, ["any", "values"]), (f.CreatedByField, fake_user()), (f.LastModifiedByField, fake_user()), + (f.ManualSortField, "fcca"), # 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), @@ -402,6 +403,7 @@ class T(Model): f.LastModifiedByField, f.LastModifiedTimeField, f.LookupField, + f.ManualSortField, f.MultipleCollaboratorsField, f.MultipleSelectField, f.NumberField, From 782e8225980074a0c9c7f3a3f509d940301fe91d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 15 Aug 2024 22:19:52 -0700 Subject: [PATCH 162/272] Track changed field values, only save changed fields --- pyairtable/orm/fields.py | 6 ++- pyairtable/orm/model.py | 81 +++++++++++++++++++++++++--------------- tests/test_orm.py | 74 +++++++++++++++++++++++++----------- tests/test_orm_fields.py | 6 +-- 4 files changed, 110 insertions(+), 57 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index a18cfa26..e8faadc1 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -176,11 +176,13 @@ def __get__( def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: self._raise_if_readonly() - if not hasattr(instance, "_fields"): - instance._fields = {} if self.validate_type and value is not None: self.valid_or_raise(value) + if not hasattr(instance, "_fields"): + instance._fields = {} instance._fields[self.field_name] = value + if hasattr(instance, "_changed"): + instance._changed[self.field_name] = True def __delete__(self, instance: "Model") -> None: raise AttributeError(f"cannot delete {self._description}") diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 65430ad7..88313c16 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -125,6 +125,7 @@ def api_key(): _deleted: bool = False _fetched: bool = False _fields: Dict[FieldName, Any] + _changed: Dict[FieldName, bool] _memoized: ClassVar[Dict[RecordId, SelfType]] def __init_subclass__(cls, **kwargs: Any): @@ -133,10 +134,21 @@ def __init_subclass__(cls, **kwargs: Any): cls._validate_class() super().__init_subclass__(**kwargs) - def __repr__(self) -> str: - if not self.id: - return f"" - return f"<{self.__class__.__name__} id={self.id!r}>" + @classmethod + def _validate_class(cls) -> None: + # Verify required Meta attributes were set (but don't call any callables) + assert cls.meta.get("api_key", required=True, call=False) + assert cls.meta.get("base_id", required=True, call=False) + assert cls.meta.get("table_name", required=True, call=False) + + model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")] + overridden = set(model_attributes).intersection(Model.__dict__.keys()) + if overridden: + raise ValueError( + "Class {cls} fields clash with existing method: {name}".format( + cls=cls.__name__, name=overridden + ) + ) @classmethod def _attribute_descriptor_map(cls) -> Dict[str, AnyField]: @@ -199,21 +211,13 @@ def __init__(self, **fields: Any): raise AttributeError(key) setattr(self, key, value) - @classmethod - def _validate_class(cls) -> None: - # Verify required Meta attributes were set (but don't call any callables) - assert cls.meta.get("api_key", required=True, call=False) - assert cls.meta.get("base_id", required=True, call=False) - assert cls.meta.get("table_name", required=True, call=False) + # Only start tracking changes after the object is created + self._changed = {} - model_attributes = [a for a in cls.__dict__.keys() if not a.startswith("__")] - overridden = set(model_attributes).intersection(Model.__dict__.keys()) - if overridden: - raise ValueError( - "Class {cls} fields clash with existing method: {name}".format( - cls=cls.__name__, name=overridden - ) - ) + def __repr__(self) -> str: + if not self.id: + return f"" + return f"<{self.__class__.__name__} id={self.id!r}>" def exists(self) -> bool: """ @@ -221,31 +225,45 @@ def exists(self) -> bool: """ return bool(self.id) - def save(self) -> bool: + def save(self, *, force: bool = False) -> bool: """ Save the model to the API. If the instance does not exist already, it will be created; - otherwise, the existing record will be updated. + otherwise, the existing record will be updated, using only the + fields which have been modified since it was retrieved. + + Args: + force: If ``True``, all fields will be saved, even if they have not changed. Returns: - ``True`` if a record was created, ``False`` if it was updated. + ``True`` if a record was created; + ``False`` if it was updated, or if the model had no changes. """ if self._deleted: raise RuntimeError(f"{self.id} was deleted") - table = self.meta.table - fields = self.to_record(only_writable=True)["fields"] + + field_values = self.to_record(only_writable=True)["fields"] if not self.id: - record = table.create(fields, typecast=self.meta.typecast) - did_create = True - else: - record = table.update(self.id, fields, typecast=self.meta.typecast) - did_create = False + record = self.meta.table.create(field_values, typecast=self.meta.typecast) + self.id = record["id"] + self.created_time = datetime_from_iso_str(record["createdTime"]) + self._changed.clear() + return True + + if not force: + if not self._changed: + return False + field_values = { + field_name: value + for field_name, value in field_values.items() + if self._changed.get(field_name) + } - self.id = record["id"] - self.created_time = datetime_from_iso_str(record["createdTime"]) - return did_create + self.meta.table.update(self.id, field_values, typecast=self.meta.typecast) + self._changed.clear() + return False def delete(self) -> bool: """ @@ -391,6 +409,7 @@ def fetch(self) -> None: record = self.meta.table.get(self.id) unused = self.from_record(record, memoize=False) self._fields = unused._fields + self._changed.clear() self._fetched = True self.created_time = unused.created_time diff --git a/tests/test_orm.py b/tests/test_orm.py index 784abd9e..70ff53e3 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -1,5 +1,6 @@ import re from datetime import datetime, timezone +from functools import partial from operator import itemgetter from unittest import mock @@ -26,12 +27,25 @@ class Contact(Model): first_name = f.TextField("First Name") last_name = f.TextField("Last Name") email = f.EmailField("Email") - is_registered = f.CheckboxField("Registered") + is_registered = f.CheckboxField("Registered?") address = f.LinkField("Link", Address, lazy=False) birthday = f.DateField("Birthday") created_at = f.CreatedTimeField("Created At") +@pytest.fixture +def contact_record(): + return fake_record( + { + "First Name": "John", + "Last Name": "Doe", + "Email": "john@example.com", + "Registered?": True, + "Birthday": "1970-01-01", + } + ) + + def test_model_basics(): contact = Contact( first_name="Gui", @@ -138,31 +152,49 @@ def test_from_record(): assert not contact.first_name == "X" -def test_readonly_field_not_saved(): +def test_unmodified_field_not_saved(contact_record): """ - Test that we do not attempt to save readonly fields to the API, - but we can retrieve readonly fields and set them on instantiation. + Test that we do not attempt to save fields to the API if they are unchanged. """ + contact = Contact.from_record(contact_record) + mock_update_contact = partial( + mock.patch.object, Table, "update", return_value=contact_record + ) - record = { - "id": "recwnBLPIeQJoYVt4", - "createdTime": datetime.now(timezone.utc).isoformat(), - "fields": { - "Birthday": "1970-01-01", - "Age": 57, - }, - } + # Do not call update() if the record is unchanged + with mock_update_contact() as m_update: + contact.save() + m_update.assert_not_called() - contact = Contact.from_record(record) - with mock.patch.object(Table, "update") as m_update: - m_update.return_value = record - contact.birthday = datetime(2000, 1, 1) + # By default, only pass fields which were changed to the API + with mock_update_contact() as m_update: + contact.email = "john.doe@example.com" contact.save() + m_update.assert_called_once_with( + contact.id, + {"Email": "john.doe@example.com"}, + typecast=True, + ) + + # Once saved, the field is no longer marked as changed + with mock_update_contact() as m_update: + contact.save() + m_update.assert_not_called() - # We should not pass 'Age' to the API - m_update.assert_called_once_with( - contact.id, {"Birthday": "2000-01-01"}, typecast=True - ) + # We can explicitly pass all fields to the API + with mock_update_contact() as m_update: + contact.save(force=True) + m_update.assert_called_once_with( + contact.id, + { + "First Name": "John", + "Last Name": "Doe", + "Email": "john.doe@example.com", + "Registered?": True, + "Birthday": "1970-01-01", + }, + typecast=True, + ) def test_linked_record(): @@ -209,7 +241,7 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records): if access_linked_records: assert contact.address[0].id == address_id - contact.save() + contact.save(force=True) assert mock_save.last_request.json() == { "fields": { "Email": "alice@example.com", diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 7ce54e33..5ec395d7 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -833,7 +833,7 @@ class Book(Model): # if book.author.__set__ not called, the entire list will be sent back to the API with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: - book.save() + book.save(force=True) m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True) # if we modify the field value, it will drop items 2-N @@ -974,7 +974,7 @@ def patch_callback(request, context): # Test that we parse the "Z" into UTC correctly assert obj.dt.date() == datetime.date(2024, 2, 29) assert obj.dt.tzinfo is datetime.timezone.utc - obj.save() + obj.save(force=True) assert m.last_request.json()["fields"]["dt"] == "2024-02-29T12:34:56.000Z" # Test that we can set a UTC timezone and it will be saved as-is. @@ -1018,5 +1018,5 @@ class T(Model): assert obj.the_field == expected with mock.patch("pyairtable.Table.update", return_value=obj.to_record()) as m: - obj.save() + obj.save(force=True) m.assert_called_once_with(obj.id, fields, typecast=True) From 84255064b358b239082dfeae58b825854f692ea1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 16 Aug 2024 00:44:22 -0700 Subject: [PATCH 163/272] Track changes to record link lists --- pyairtable/orm/changes.py | 48 +++++++++++++++++++++++++++++++++++++ pyairtable/orm/fields.py | 22 ++++++++++++----- tests/test_orm_fields.py | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 pyairtable/orm/changes.py diff --git a/pyairtable/orm/changes.py b/pyairtable/orm/changes.py new file mode 100644 index 00000000..021b8d23 --- /dev/null +++ b/pyairtable/orm/changes.py @@ -0,0 +1,48 @@ +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 e8faadc1..32c0f537 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -29,6 +29,7 @@ import importlib from datetime import date, datetime, timedelta from enum import Enum +from functools import partial from typing import ( TYPE_CHECKING, Any, @@ -58,6 +59,7 @@ RecordId, ) from pyairtable.exceptions import MissingValueError, MultipleValuesError +from pyairtable.orm.changes import ChangeNotifyingList if TYPE_CHECKING: from pyairtable.orm import Model # noqa @@ -488,15 +490,23 @@ def __get__( return self._get_list_value(instance) def _get_list_value(self, instance: "Model") -> List[T_ORM]: - value = cast(List[T_ORM], instance._fields.get(self.field_name)) + value = instance._fields.get(self.field_name) # If Airtable returns no value, substitute an empty list. if value is None: value = [] - # For implementers to be able to modify this list in place - # and persist it later when they call .save(), we need to - # set this empty list as the field's value. - if not self.readonly: - instance._fields[self.field_name] = 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) + + # 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 diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 5ec395d7..63c42697 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -756,6 +756,56 @@ def test_link_field__load_many(requests_mock): assert mock_list.call_count == 2 +@pytest.mark.parametrize( + "mutation", + ( + "author.books = [book]", + "author.books.append(book)", + "author.books[0] = book", + "author.books.insert(0, book)", + "author.books[0:1] = []", + "author.books.pop(0)", + "del author.books[0]", + "author.books.remove(author.books[0])", + "author.books.clear()", + "author.books.extend([book])", + ), +) +def test_link_field__save(requests_mock, mutation): + """ + Test that we correctly detect changes to linked fields and save them. + """ + + class Book(Model): + Meta = fake_meta() + + class Author(Model): + Meta = fake_meta() + books = f.LinkField("Books", model=Book) + + b1 = Book.from_record(fake_record()) + b2 = Book.from_record(fake_record()) + author = Author.from_record(fake_record({"Books": [b1.id]})) + + def _cb(request, context): + return { + "id": author.id, + "createdTime": datetime_to_iso_str(author.created_time), + "fields": request.json()["fields"], + } + + requests_mock.get( + Book.meta.table.url, + json={"records": [b1.to_record(), b2.to_record()]}, + ) + m = requests_mock.patch(Author.meta.table.record_url(author.id), json=_cb) + exec(mutation, {}, {"author": author, "book": b2}) + assert author._changed["Books"] + author.save() + assert m.call_count == 1 + assert "Books" in m.last_request.json()["fields"] + + def test_single_link_field(): class Author(Model): Meta = fake_meta() From c23e6326acb28c89265b41b357e6e378a04fb131 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 20 Aug 2024 20:10:29 -0700 Subject: [PATCH 164/272] Fix test failure when AIRTABLE_API_KEY env is set externally --- tests/test_cli.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 874f9489..aa0940ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,10 +38,14 @@ def mock_metadata( @pytest.fixture -def run(mock_metadata): +def run(mock_metadata, monkeypatch): default_env = {"AIRTABLE_API_KEY": "test"} def _runner(*args: str, env: dict = default_env, fails: bool = False): + # make sure we're starting from a blank environment + monkeypatch.delenv("AIRTABLE_API_KEY", raising=False) + monkeypatch.delenv("AIRTABLE_API_KEY_FILE", raising=False) + # run the command runner = CliRunner(env=env) result = runner.invoke(pyairtable.cli.cli, args) # if a test fails, show the command's output @@ -57,6 +61,7 @@ def _runner(*args: str, env: dict = default_env, fails: bool = False): def _runner_with_json(*args, **kwargs): result = _runner(*args, **kwargs) + assert result.stdout, "command did not produce any output" return json.loads(result.stdout) _runner.json = _runner_with_json From 8a30d10b9ffd2b6ff259d80877a3c4f2c190d310 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 20 Aug 2024 20:18:24 -0700 Subject: [PATCH 165/272] Fix failing integration test for manualSort field --- tests/integration/test_integration_orm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 223c6722..e859ef9e 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -201,6 +201,7 @@ def test_every_field(Everything): f.ExternalSyncSourceField, f.AITextField, f.RequiredAITextField, + f.ManualSortField, }: continue assert field_class in classes_used From d71f7e4c776d71c69baffcbb95a416efb055976f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 19 Aug 2024 20:37:44 -0700 Subject: [PATCH 166/272] Deprecate escape_quotes --- docs/source/migrations.rst | 2 ++ pyairtable/formulas.py | 16 +++++++++++++--- tests/test_formulas.py | 7 ++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 4cbae28e..f2ada1f2 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -53,6 +53,8 @@ The full list of breaking changes is below: * - :func:`~pyairtable.formulas.IF`, :func:`~pyairtable.formulas.FIND`, :func:`~pyairtable.formulas.LOWER` - These no longer return ``str``, and instead return instances of :class:`~pyairtable.formulas.FunctionCall`. + * - :func:`~pyairtable.formulas.escape_quotes` + - Deprecated. Use :func:`~pyairtable.formulas.quoted` instead. Changes to retrieving ORM model configuration --------------------------------------------- diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index dac7ad54..6eb71df3 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -7,6 +7,7 @@ import datetime import re +import warnings from decimal import Decimal from fractions import Fraction from typing import Any, ClassVar, Iterable, List, Optional, Set, Union @@ -461,13 +462,17 @@ def quoted(value: str) -> str: >>> quoted("Guest's Name") "'Guest\\'s Name'" """ - return "'{}'".format(escape_quotes(str(value))) + value = value.replace("\\", r"\\").replace("'", r"\'") + return "'{}'".format(value) def escape_quotes(value: str) -> str: r""" Ensure any quotes are escaped. Already escaped quotes are ignored. + This function has been deprecated. + Use :func:`~pyairtable.formulas.quoted` instead. + Args: value: text to be escaped @@ -477,6 +482,11 @@ def escape_quotes(value: str) -> str: >>> escape_quotes(r"Player\'s Name") "Player\\'s Name" """ + warnings.warn( + "escape_quotes is deprecated; use quoted() instead.", + category=DeprecationWarning, + stacklevel=2, + ) escaped_value = re.sub("(? str: >>> field_name("First Name") '{First Name}' >>> field_name("Guest's Name") - "{Guest\\'s Name}" + "{Guest's Name}" """ # This will not actually work with field names that contain more # than one closing curly brace; that's a limitation of Airtable. # Our library will escape all closing braces, but the API will fail. - return "{%s}" % escape_quotes(name.replace("}", r"\}")) + return "{%s}" % name.replace("}", r"\}") class FunctionCall(Formula): diff --git a/tests/test_formulas.py b/tests/test_formulas.py index 41396dac..6514cb73 100644 --- a/tests/test_formulas.py +++ b/tests/test_formulas.py @@ -334,7 +334,7 @@ def test_function_call_equivalence(): "input,expected", [ ("First Name", "{First Name}"), - ("Guest's Name", r"{Guest\'s Name}"), + ("Guest's Name", r"{Guest's Name}"), ("With {Curly Braces}", r"{With {Curly Braces\}}"), ], ) @@ -343,8 +343,9 @@ def test_field_name(input, expected): def test_quoted(): - assert F.quoted("John") == "'John'" - assert F.quoted("Guest's Name") == "'Guest\\'s Name'" + assert F.quoted("Guest") == "'Guest'" + assert F.quoted("Guest's Name") == r"'Guest\'s Name'" + assert F.quoted(F.quoted("Guest's Name")) == r"'\'Guest\\\'s Name\''" class FakeModel(orm.Model): From d76534b12727cace5c8bde66c9c1b714b5615738 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 17 Aug 2024 23:29:54 -0700 Subject: [PATCH 167/272] Update docs for 3.0 release --- docs/source/changelog.rst | 25 +++++++++++++++---------- docs/source/migrations.rst | 11 ++++++++++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 9b7036ae..3b83cf2c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -6,30 +6,35 @@ Changelog ------------------------ * Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. - - `PR #329 `_. + - `PR #329 `_ * :class:`~pyairtable.orm.fields.TextField` and :class:`~pyairtable.orm.fields.CheckboxField` return ``""`` or ``False`` instead of ``None``. - - `PR #347 `_. + - `PR #347 `_ * Changed the type of :data:`~pyairtable.orm.Model.created_time` from ``str`` to ``datetime``, along with all other timestamp fields used in :ref:`API: pyairtable.models`. - - `PR #352 `_. + - `PR #352 `_ * Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField` for record links that should only contain one record. - - `PR #354 `_. + - `PR #354 `_ * Support ``use_field_ids`` in the :ref:`ORM`. - - `PR #355 `_. + - `PR #355 `_ * Removed the ``pyairtable.metadata`` module. - - `PR #360 `_. + - `PR #360 `_ * Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. - - `PR #362 `_. + - `PR #362 `_ * Added ORM fields that :ref:`require a non-null value `. - - `PR #363 `_. + - `PR #363 `_ * Refactored methods for accessing ORM model configuration. - - `PR #366 `_. + - `PR #366 `_ * Added support for :ref:`memoization of ORM models `. - - `PR #369 `_. + - `PR #369 `_ +* Added `Enterprise.grant_access ` + and `Enterprise.revoke_access `. + - `PR #373 `_ +* Added command line utility and ORM module generator. See :doc:`cli`. + - `PR #376 `_ 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index f2ada1f2..9575125f 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -11,6 +11,12 @@ Migrating from 2.x to 3.0 In this release we've made a number of breaking changes, summarized below. +Deprecated metadata module removed +--------------------------------------------- + +The 3.0 release removed the ``pyairtable.metadata`` module. For supported alternatives, +see :doc:`metadata`. + Changes to the formulas module --------------------------------------------- @@ -56,9 +62,12 @@ The full list of breaking changes is below: * - :func:`~pyairtable.formulas.escape_quotes` - Deprecated. Use :func:`~pyairtable.formulas.quoted` instead. -Changes to retrieving ORM model configuration +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`. + The 3.0 release has changed the API for retrieving ORM model configuration: .. list-table:: From f74c7a6afc98069c15c7341b96946d1cbc363bee Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 21 Aug 2024 22:13:43 -0700 Subject: [PATCH 168/272] 3.0.0a3 --- pyairtable/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index fed194b0..d57c2249 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0a2" +__version__ = "3.0.0a3" from .api import Api, Base, Table from .api.enterprise import Enterprise From 84c4cde9da626177de28037d99eaf34666988e6e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 24 Aug 2024 22:46:44 -0700 Subject: [PATCH 169/272] Add `use_field_ids=` param to Api --- docs/source/_substitutions.rst | 3 +- pyairtable/api/api.py | 8 ++ pyairtable/api/table.py | 23 ++++-- pyairtable/formulas.py | 2 +- tests/test_api_table.py | 143 +++++++++++++++++++++++++++++---- 5 files changed, 155 insertions(+), 24 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index b5e230ca..290da7d7 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -58,7 +58,8 @@ bet set to null. If ``False``, only provided fields are updated. .. |kwarg_use_field_ids| replace:: An optional boolean value that lets you return field objects where the - key is the field id. This defaults to `false`, which returns field objects where the key is the field name. + key is the field id. This defaults to ``False``, which returns field objects where the key is the field name. + This behavior can be overridden by passing ``use_field_ids=True`` to :class:`~pyairtable.Api`. .. |kwarg_force_metadata| replace:: By default, this method will only fetch information from the API if it has not been cached. diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 3f552812..a6fa6ea1 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -41,6 +41,10 @@ class Api: # Cached metadata to reduce API calls _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None + endpoint_url: str + session: Session + use_field_ids: bool + def __init__( self, api_key: str, @@ -48,6 +52,7 @@ def __init__( timeout: Optional[TimeoutTuple] = None, retry_strategy: Optional[Union[bool, retrying.Retry]] = True, endpoint_url: str = "https://api.airtable.com", + use_field_ids: bool = False, ): """ Args: @@ -63,6 +68,8 @@ def __init__( (see :func:`~pyairtable.retry_strategy` for details). endpoint_url: The API endpoint to use. Override this if you are using a debugging or caching proxy. + use_field_ids: If ``True``, all API requests will return responses + with field IDs instead of field names. """ if retry_strategy is True: retry_strategy = retrying.retry_strategy() @@ -74,6 +81,7 @@ def __init__( self.endpoint_url = endpoint_url self.timeout = timeout self.api_key = api_key + self.use_field_ids = use_field_ids @property def api_key(self) -> str: diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index d8851564..47a10519 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -230,6 +230,8 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: """ if isinstance(formula := options.get("formula"), Formula): options["formula"] = to_formula_str(formula) + if self.api.use_field_ids: + options.setdefault("use_field_ids", self.api.use_field_ids) for page in self.api.iterate_requests( method="get", url=self.url, @@ -290,7 +292,7 @@ def create( self, fields: WritableFields, typecast: bool = False, - use_field_ids: bool = False, + use_field_ids: Optional[bool] = None, ) -> RecordDict: """ Create a new record @@ -304,6 +306,8 @@ def create( typecast: |kwarg_typecast| use_field_ids: |kwarg_use_field_ids| """ + if use_field_ids is None: + use_field_ids = self.api.use_field_ids created = self.api.post( url=self.url, json={ @@ -318,7 +322,7 @@ def batch_create( self, records: Iterable[WritableFields], typecast: bool = False, - use_field_ids: bool = False, + use_field_ids: Optional[bool] = None, ) -> List[RecordDict]: """ Create a number of new records in batches. @@ -343,6 +347,8 @@ def batch_create( use_field_ids: |kwarg_use_field_ids| """ inserted_records = [] + if use_field_ids is None: + use_field_ids = self.api.use_field_ids # If we got an iterator, exhaust it and collect it into a list. records = list(records) @@ -367,7 +373,7 @@ def update( fields: WritableFields, replace: bool = False, typecast: bool = False, - use_field_ids: bool = False, + use_field_ids: Optional[bool] = None, ) -> RecordDict: """ Update a particular record ID with the given fields. @@ -384,6 +390,8 @@ def update( typecast: |kwarg_typecast| use_field_ids: |kwarg_use_field_ids| """ + if use_field_ids is None: + use_field_ids = self.api.use_field_ids method = "put" if replace else "patch" updated = self.api.request( method=method, @@ -401,7 +409,7 @@ def batch_update( records: Iterable[UpdateRecordDict], replace: bool = False, typecast: bool = False, - use_field_ids: bool = False, + use_field_ids: Optional[bool] = None, ) -> List[RecordDict]: """ Update several records in batches. @@ -417,6 +425,8 @@ def batch_update( """ updated_records = [] method = "put" if replace else "patch" + if use_field_ids is None: + use_field_ids = self.api.use_field_ids # If we got an iterator, exhaust it and collect it into a list. records = list(records) @@ -442,7 +452,7 @@ def batch_upsert( key_fields: List[FieldName], replace: bool = False, typecast: bool = False, - use_field_ids: bool = False, + use_field_ids: Optional[bool] = None, ) -> UpsertResultDict: """ Update or create records in batches, either using ``id`` (if given) or using a set of @@ -462,6 +472,9 @@ def batch_upsert( Returns: Lists of created/updated record IDs, along with the list of all records affected. """ + if use_field_ids is None: + use_field_ids = self.api.use_field_ids + # If we got an iterator, exhaust it and collect it into a list. records = list(records) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 6eb71df3..5a7865d9 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -466,7 +466,7 @@ def quoted(value: str) -> str: return "'{}'".format(value) -def escape_quotes(value: str) -> str: +def escape_quotes(value: str) -> str: # pragma: no cover r""" Ensure any quotes are escaped. Already escaped quotes are ignored. diff --git a/tests/test_api_table.py b/tests/test_api_table.py index d432676a..452b90b7 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from posixpath import join as urljoin from unittest import mock @@ -11,6 +12,8 @@ from pyairtable.testing import fake_id, fake_record from pyairtable.utils import chunked +NOW = datetime.now(timezone.utc).isoformat() + @pytest.fixture() def table_schema(sample_json, api, base) -> TableSchema: @@ -206,31 +209,62 @@ def test_first_none(table: Table, mock_response_single): assert rv is None -def test_all(table: Table, mock_response_list, mock_records): - with Mocker() as mock: - mock.get( - table.url, +def test_all(table, requests_mock, mock_response_list, mock_records): + requests_mock.get( + table.url, + status_code=200, + json=mock_response_list[0], + complete_qs=True, + ) + for n, resp in enumerate(mock_response_list, 1): + offset = resp.get("offset", None) + if not offset: + continue + offset_url = table.url + "?offset={}".format(offset) + requests_mock.get( + offset_url, status_code=200, - json=mock_response_list[0], + json=mock_response_list[1], complete_qs=True, ) - for n, resp in enumerate(mock_response_list, 1): - offset = resp.get("offset", None) - if not offset: - continue - offset_url = table.url + "?offset={}".format(offset) - mock.get( - offset_url, - status_code=200, - json=mock_response_list[1], - complete_qs=True, - ) - response = table.all() + response = table.all() for n, resp in enumerate(response): assert dict_equals(resp, mock_records[n]) +@pytest.mark.parametrize( + "kwargs,expected", + [ + ({"view": "Grid view"}, {"view": ["Grid view"]}), + ({"page_size": 10}, {"pageSize": ["10"]}), + ({"max_records": 10}, {"maxRecords": ["10"]}), + ({"fields": ["Name", "Email"]}, {"fields[]": ["Name", "Email"]}), + ({"formula": "{Status}='Active'"}, {"filterByFormula": ["{Status}='Active'"]}), + ({"cell_format": "json"}, {"cellFormat": ["json"]}), + ({"user_locale": "en_US"}, {"userLocale": ["en_US"]}), + ({"time_zone": "America/New_York"}, {"timeZone": ["America/New_York"]}), + ({"use_field_ids": True}, {"returnFieldsByFieldId": ["1"]}), + ( + {"sort": ["Name", "-Email"]}, + { + "sort[0][direction]": ["asc"], + "sort[0][field]": ["Name"], + "sort[1][direction]": ["desc"], + "sort[1][field]": ["Email"], + }, + ), + ], +) +def test_all__params(table, requests_mock, kwargs, expected): + """ + Test that parameters to all() get translated to query string correctly. + """ + m = requests_mock.get(table.url, status_code=200, json={"records": []}) + table.all(**kwargs) + assert m.last_request.qs == expected + + def test_iterate(table: Table, mock_response_list, mock_records): with Mocker() as mock: mock.get( @@ -460,6 +494,81 @@ def test_delete_view(table, mock_schema, requests_mock): assert m.call_count == 1 +fake_upsert = {"updatedRecords": [], "createdRecords": [], "records": []} + + +@pytest.mark.parametrize("method_name", ("all", "first")) +def test_use_field_ids__get(table, monkeypatch, requests_mock, method_name): + """ + Test that setting api.use_field_ids=True will change the default behavior + (but not the explicit behavior) of the API methods on Table. + """ + m = requests_mock.register_uri("GET", table.url, json={"records": []}) + + # by default, we don't pass the param at all + method = getattr(table, method_name) + method() + assert m.called + assert "returnFieldsByFieldId" not in m.last_request.qs + + # if use_field_ids=True, we should pass the param... + monkeypatch.setattr(table.api, "use_field_ids", True) + m.reset() + method() + assert m.called + assert m.last_request.qs["returnFieldsByFieldId"] == ["1"] + + # ...but we can override it + m.reset() + method(use_field_ids=False) + assert m.called + assert m.last_request.qs["returnFieldsByFieldId"] == ["0"] + + +@pytest.mark.parametrize( + "method_name,method_args,http_method,suffix,response", + [ + ("create", ({"fields": {}}), "POST", "", fake_record()), + ("update", ("rec123", {}), "PATCH", "rec123", fake_record()), + ("batch_create", ([fake_record()],), "POST", "", {"records": []}), + ("batch_update", ([fake_record()],), "PATCH", "", {"records": []}), + ("batch_upsert", ([fake_record()], ["Key"]), "PATCH", "", fake_upsert), + ], +) +def test_use_field_ids__post( + table, + monkeypatch, + requests_mock, + method_name, + method_args, + http_method, + suffix, + response, +): + url = f"{table.url}/{suffix}".rstrip("/") + print(f"{url=}") + m = requests_mock.register_uri(http_method, url, json=response) + + # by default, the param is False + method = getattr(table, method_name) + method(*method_args) + assert m.call_count == 1 + assert m.last_request.json()["returnFieldsByFieldId"] is False + + # if use_field_ids=True, we should pass the param... + monkeypatch.setattr(table.api, "use_field_ids", True) + m.reset() + method(*method_args) + assert m.call_count == 1 + assert m.last_request.json()["returnFieldsByFieldId"] is True + + # ...but we can override it + m.reset() + method(*method_args, use_field_ids=False) + assert m.call_count == 1 + assert m.last_request.json()["returnFieldsByFieldId"] is False + + # Helpers From b4429d45e05679aef37ee6523b71cf6f5b0c4af5 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 26 Aug 2024 10:03:40 -0700 Subject: [PATCH 170/272] Use api.use_field_ids in Table.get --- pyairtable/api/table.py | 2 ++ tests/test_api_table.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 47a10519..a9d42943 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -197,6 +197,8 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict: user_locale: |kwarg_user_locale| use_field_ids: |kwarg_use_field_ids| """ + if self.api.use_field_ids: + options.setdefault("use_field_ids", self.api.use_field_ids) record = self.api.get(self.record_url(record_id), options=options) return assert_typed_dict(RecordDict, record) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 452b90b7..34beedc2 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -497,11 +497,39 @@ def test_delete_view(table, mock_schema, requests_mock): fake_upsert = {"updatedRecords": [], "createdRecords": [], "records": []} +def test_use_field_ids__get_record(table, monkeypatch, requests_mock): + """ + Test that setting api.use_field_ids=True will change the default behavior + (but not the explicit behavior) of Table.get() + """ + record = fake_record() + url = table.record_url(record_id := record["id"]) + m = requests_mock.register_uri("GET", url, json=record) + + # by default, we don't pass the param at all + table.get(record_id) + assert m.called + assert "returnFieldsByFieldId" not in m.last_request.qs + + # if use_field_ids=True, we should pass the param... + monkeypatch.setattr(table.api, "use_field_ids", True) + m.reset() + table.get(record_id) + assert m.called + assert m.last_request.qs["returnFieldsByFieldId"] == ["1"] + + # ...but we can override it + m.reset() + table.get(record_id, use_field_ids=False) + assert m.called + assert m.last_request.qs["returnFieldsByFieldId"] == ["0"] + + @pytest.mark.parametrize("method_name", ("all", "first")) -def test_use_field_ids__get(table, monkeypatch, requests_mock, method_name): +def test_use_field_ids__get_records(table, monkeypatch, requests_mock, method_name): """ Test that setting api.use_field_ids=True will change the default behavior - (but not the explicit behavior) of the API methods on Table. + (but not the explicit behavior) of Table.all() and Table.first() """ m = requests_mock.register_uri("GET", table.url, json={"records": []}) @@ -545,6 +573,10 @@ def test_use_field_ids__post( suffix, response, ): + """ + Test that setting api.use_field_ids=True will change the default behavior + (but not the explicit behavior) of the create/update API methods on Table. + """ url = f"{table.url}/{suffix}".rstrip("/") print(f"{url=}") m = requests_mock.register_uri(http_method, url, json=response) From 4fc0dceb4532ecefbfc2fe2ddd77849a069028a3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 17 Aug 2024 23:29:42 -0700 Subject: [PATCH 171/272] Model.save() returns SaveResult instead of bool --- docs/source/api.rst | 3 ++ docs/source/changelog.rst | 8 +++ docs/source/migrations.rst | 4 ++ pyairtable/orm/__init__.py | 3 +- pyairtable/orm/model.py | 79 ++++++++++++++++++++++++--- tests/test_orm.py | 5 +- tests/test_orm_model.py | 107 ++++++++++++++++++++++++++++++++----- 7 files changed, 186 insertions(+), 23 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index fcfa1aa0..18cfe4e2 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -95,6 +95,9 @@ API: pyairtable.orm .. autoclass:: pyairtable.orm.Model :members: +.. autoclass:: pyairtable.orm.SaveResult + :members: + API: pyairtable.orm.fields ******************************* diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 3b83cf2c..c0e4041c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -35,6 +35,14 @@ Changelog - `PR #373 `_ * Added command line utility and ORM module generator. See :doc:`cli`. - `PR #376 `_ +* Changed the behavior of :meth:`Model.save ` + to no longer send unmodified field values to the API. + - `PR #381 `_ +* Added ``use_field_ids=`` parameter to :class:`~pyairtable.Api`. + - `PR #386 `_ +* Changed the return type of :meth:`Model.save ` + from ``bool`` to :class:`~pyairtable.orm.SaveResult`. + - `PR #387 `_ 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 9575125f..97093213 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -68,6 +68,10 @@ 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. It also now returns an instance of +:class:`~pyairtable.orm.SaveResult` instead of ``bool``. This behavior can be overridden. + The 3.0 release has changed the API for retrieving ORM model configuration: .. list-table:: diff --git a/pyairtable/orm/__init__.py b/pyairtable/orm/__init__.py index b85a24fc..ab7ad1d5 100644 --- a/pyairtable/orm/__init__.py +++ b/pyairtable/orm/__init__.py @@ -1,7 +1,8 @@ from . import fields -from .model import Model +from .model import Model, SaveResult __all__ = [ "Model", + "SaveResult", "fields", ] diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 88313c16..fbe9c516 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -1,4 +1,6 @@ +import dataclasses import datetime +import warnings from dataclasses import dataclass from functools import cached_property from typing import ( @@ -10,6 +12,7 @@ List, Mapping, Optional, + Set, Type, Union, cast, @@ -225,7 +228,7 @@ def exists(self) -> bool: """ return bool(self.id) - def save(self, *, force: bool = False) -> bool: + def save(self, *, force: bool = False) -> "SaveResult": """ Save the model to the API. @@ -235,10 +238,6 @@ def save(self, *, force: bool = False) -> bool: Args: force: If ``True``, all fields will be saved, even if they have not changed. - - Returns: - ``True`` if a record was created; - ``False`` if it was updated, or if the model had no changes. """ if self._deleted: raise RuntimeError(f"{self.id} was deleted") @@ -250,11 +249,11 @@ def save(self, *, force: bool = False) -> bool: self.id = record["id"] self.created_time = datetime_from_iso_str(record["createdTime"]) self._changed.clear() - return True + return SaveResult(self.id, created=True, field_names=set(field_values)) if not force: if not self._changed: - return False + return SaveResult(self.id) field_values = { field_name: value for field_name, value in field_values.items() @@ -263,7 +262,9 @@ def save(self, *, force: bool = False) -> bool: self.meta.table.update(self.id, field_values, typecast=self.meta.typecast) self._changed.clear() - return False + return SaveResult( + self.id, forced=force, updated=True, field_names=set(field_values) + ) def delete(self) -> bool: """ @@ -632,3 +633,65 @@ def request_kwargs(self) -> Dict[str, Any]: "time_zone": None, "use_field_ids": self.use_field_ids, } + + +@dataclass +class SaveResult: + """ + Represents the result of saving a record to the API. The result's + attributes contain more granular information about the save operation: + + >>> result = model.save() + >>> result.record_id + 'recWPqD9izdsNvlE' + >>> result.created + False + >>> result.updated + True + >>> result.forced + False + >>> result.field_names + {'Name', 'Email'} + + If none of the model's fields have changed, calling :meth:`~pyairtable.orm.Model.save` + will not perform any API requests and will return a SaveResult with no changes. + + >>> model = YourModel() + >>> result = model.save() + >>> result.saved + True + >>> second_result = model.save() + >>> second_result.saved + False + + For backwards compatibility, instances of SaveResult will evaluate as truthy + if the record was created, and falsy if the record was not created. + """ + + record_id: RecordId + created: bool = False + updated: bool = False + forced: bool = False + field_names: Set[FieldName] = dataclasses.field(default_factory=set) + + def __bool__(self) -> bool: + """ + Returns ``True`` if the record was created. This is for backwards compatibility + with the behavior of :meth:`~pyairtable.orm.Model.save` prior to the 3.0 release, + which returned a boolean indicating whether a record was created. + """ + warnings.warn( + "Model.save() now returns SaveResult instead of bool; switch" + " to checking Model.save().created instead before the 4.0 release.", + DeprecationWarning, + ) + return self.created + + @property + def saved(self) -> bool: + """ + Whether the record was saved to the API. If ``False``, this indicates there + were no changes to the model and the :meth:`~pyairtable.orm.Model.save` + operation was not forced. + """ + return self.created or self.updated diff --git a/tests/test_orm.py b/tests/test_orm.py index 70ff53e3..4f39b347 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -62,7 +62,7 @@ def test_model_basics(): # save with mock.patch.object(Table, "create") as m_save: m_save.return_value = {"id": "id", "createdTime": NOW} - contact.save() + assert contact.save().created assert m_save.called assert contact.id == "id" @@ -163,7 +163,8 @@ def test_unmodified_field_not_saved(contact_record): # Do not call update() if the record is unchanged with mock_update_contact() as m_update: - contact.save() + result = contact.save() + assert not (result.created or result.updated) m_update.assert_not_called() # By default, only pass fields which were changed to the API diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 6b25bd5c..32d5eee6 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from functools import partial from unittest import mock @@ -6,9 +7,22 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f +from pyairtable.orm.model import SaveResult from pyairtable.testing import fake_id, fake_meta, fake_record +class FakeModel(Model): + Meta = fake_meta() + one = f.TextField("one") + two = f.TextField("two") + + +class FakeModelByIds(Model): + Meta = fake_meta(use_field_ids=True, table_name="Apartments") + Name = f.TextField("fld1VnoyuotSTyxW1") + Age = f.NumberField("fld2VnoyuotSTy4g6") + + @pytest.fixture(autouse=True) def no_requests(requests_mock): """ @@ -126,18 +140,6 @@ def test_model_overlapping(name): ) -class FakeModel(Model): - Meta = fake_meta() - one = f.TextField("one") - two = f.TextField("two") - - -class FakeModelByIds(Model): - Meta = fake_meta(use_field_ids=True, table_name="Apartments") - Name = f.TextField("fld1VnoyuotSTyxW1") - Age = f.NumberField("fld2VnoyuotSTy4g6") - - def test_repr(): record = fake_record() assert repr(FakeModel.from_record(record)) == f"" @@ -375,3 +377,84 @@ class Meta: assert f.meta.base_id == data["base_id"] assert f.meta.table_name == data["table_name"] Fake.Meta.table_name.assert_called_once() + + +@mock.patch("pyairtable.Table.create") +def test_save__create(mock_create): + """ + Test that we can save a model instance we've created. + """ + mock_create.return_value = { + "id": fake_id, + "createdTime": datetime.now(timezone.utc).isoformat(), + "fields": {"one": "ONE", "two": "TWO"}, + } + obj = FakeModel(one="ONE", two="TWO") + result = obj.save() + assert result.saved + assert result.created + assert result.field_names == {"one", "two"} + assert not result.updated + assert not result.forced + mock_create.assert_called_once_with({"one": "ONE", "two": "TWO"}, typecast=True) + + +@mock.patch("pyairtable.Table.update") +def test_save__update(mock_update): + """ + Test that we can save a model instance that already exists. + """ + obj = FakeModel.from_record(fake_record(one="ONE", two="TWO")) + obj.one = "new value" + result = obj.save() + assert result.saved + assert not result.created + assert result.updated + assert result.field_names == {"one"} + assert not result.forced + mock_update.assert_called_once_with(obj.id, {"one": "new value"}, typecast=True) + + +@mock.patch("pyairtable.Table.update") +def test_save__update_force(mock_update): + """ + Test that we can save a model instance that already exists, + and we can force saving all values to the API. + """ + obj = FakeModel.from_record(fake_record(one="ONE", two="TWO")) + obj.one = "new value" + result = obj.save(force=True) + assert result.saved + assert not result.created + assert result.updated + assert result.forced + assert result.field_names == {"one", "two"} + mock_update.assert_called_once_with( + obj.id, {"one": "new value", "two": "TWO"}, typecast=True + ) + + +@mock.patch("pyairtable.Table.update") +def test_save__noop(mock_update): + """ + Test that if a model is unchanged, we don't try to save it to the API. + """ + obj = FakeModel.from_record(fake_record(one="ONE", two="TWO")) + result = obj.save() + assert not result.saved + assert not result.created + assert not result.updated + assert not result.field_names + assert not result.forced + mock_update.assert_not_called() + + +def test_save_bool_deprecated(): + """ + Test that SaveResult instances can be used as booleans, but emit a deprecation warning. + """ + with pytest.deprecated_call(): + assert bool(SaveResult(fake_id(), created=False)) is False + + with pytest.deprecated_call(): + assert bool(SaveResult(fake_id(), created=True)) is True From 30ea6367565b0697b11f5177f13ae21eef6cc929 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 14:30:52 -0700 Subject: [PATCH 172/272] Fix minor docs typo --- docs/source/migrations.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 97093213..fa6e9d94 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -69,8 +69,8 @@ Changes to the ORM in 3.0 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. It also now returns an instance of -:class:`~pyairtable.orm.SaveResult` instead of ``bool``. This behavior can be overridden. +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: From cacd0a52a5c8fe7c35f996d316a628bc33454aa4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 14:53:10 -0700 Subject: [PATCH 173/272] SaveResult should be frozen --- pyairtable/orm/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index fbe9c516..12ceaec3 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -635,7 +635,7 @@ def request_kwargs(self) -> Dict[str, Any]: } -@dataclass +@dataclass(frozen=True) class SaveResult: """ Represents the result of saving a record to the API. The result's From 3dd937d4033a70a2a323b80b521258426fe24966 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 14:59:31 -0700 Subject: [PATCH 174/272] Export orm.fields.AnyField --- pyairtable/orm/fields.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 33577ea5..3eeaae6f 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -1474,20 +1474,27 @@ class CreatedTimeField(RequiredDatetimeField): # with open(cog.inFile) as fp: # src = fp.read() # -# classes = re.findall(r"class ((?:[A-Z]\w+)?Field)", src) +# classes = re.findall(r"^class ((?:[A-Z]\w+)?Field)\b", src, re.MULTILINE) # constants = re.findall(r"^(?!T_)([A-Z][A-Z_]+)(?:: [^=]+)? = ", src, re.MULTILINE) +# aliases = re.findall(r"^(\w+): TypeAlias\b", src, re.MULTILINE) # extras = ["LinkSelf"] -# names = sorted(classes) + constants + extras +# names = constants + sorted(classes + aliases + extras) # # cog.outl("\n\n__all__ = [") # for name in names: -# cog.outl(f' "{name}",') +# if not name.startswith("_"): +# cog.outl(f' "{name}",') # cog.outl("]") # [[[out]]] __all__ = [ + "ALL_FIELDS", + "READONLY_FIELDS", + "FIELD_TYPES_TO_CLASSES", + "FIELD_CLASSES_TO_TYPES", "AITextField", + "AnyField", "AttachmentsField", "AutoNumberField", "BarcodeField", @@ -1509,6 +1516,7 @@ class CreatedTimeField(RequiredDatetimeField): "LastModifiedByField", "LastModifiedTimeField", "LinkField", + "LinkSelf", "LookupField", "ManualSortField", "MultipleCollaboratorsField", @@ -1541,13 +1549,8 @@ class CreatedTimeField(RequiredDatetimeField): "SingleLinkField", "TextField", "UrlField", - "ALL_FIELDS", - "READONLY_FIELDS", - "FIELD_TYPES_TO_CLASSES", - "FIELD_CLASSES_TO_TYPES", - "LinkSelf", ] -# [[[end]]] (checksum: 3c6f5447f45e74c170ec3378272c6dd3) +# [[[end]]] (checksum: 87b0a100c9e30523d9aab8cc935c7960) # Delayed import to avoid circular dependency From 454740bad2113865d27b30bd4982236d3f94eeb5 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 5 Sep 2024 22:37:24 -0700 Subject: [PATCH 175/272] Context manager for mocking API interactions in tests --- docs/source/changelog.rst | 1 + pyairtable/api/api.py | 8 +- pyairtable/api/types.py | 3 + pyairtable/testing.py | 361 ++++++++++++++++++++++++++- pyairtable/utils.py | 58 ++++- tests/test_testing.py | 19 ++ tests/test_testing__mock_airtable.py | 207 +++++++++++++++ tests/test_utils.py | 51 ++++ 8 files changed, 695 insertions(+), 13 deletions(-) create mode 100644 tests/test_testing__mock_airtable.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c0e4041c..dbea73e3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -43,6 +43,7 @@ Changelog * Changed the return type of :meth:`Model.save ` from ``bool`` to :class:`~pyairtable.orm.SaveResult`. - `PR #387 `_ +* Added :class:`pyairtable.testing.MockAirtable` for easier testing. 2.3.3 (2024-03-22) ------------------------ diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index a6fa6ea1..65c91d36 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union import requests +from requests import PreparedRequest from requests.sessions import Session from typing_extensions import TypeAlias @@ -273,14 +274,17 @@ def request( json=json, ) - response = self.session.send(prepared, timeout=self.timeout) - return self._process_response(response) + return self._perform_request(prepared) get = partialmethod(request, "GET") post = partialmethod(request, "POST") patch = partialmethod(request, "PATCH") delete = partialmethod(request, "DELETE") + def _perform_request(self, prepared: PreparedRequest) -> Any: + response = self.session.send(prepared, timeout=self.timeout) + return self._process_response(response) + def _process_response(self, response: requests.Response) -> Any: try: response.raise_for_status() diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 5ab90ca6..ecea194d 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -303,6 +303,9 @@ class UpdateRecordDict(TypedDict): fields: WritableFields +AnyRecordDict: TypeAlias = Union[RecordDict, CreateRecordDict, UpdateRecordDict] + + class RecordDeletedDict(TypedDict): """ A ``dict`` representing the payload returned by the Airtable API to confirm a deletion. diff --git a/pyairtable/testing.py b/pyairtable/testing.py index ccbdd7a3..3f00eed8 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -3,14 +3,49 @@ """ import datetime +import inspect import random import string -from typing import Any, Optional, Union +from collections import defaultdict +from contextlib import ExitStack +from typing import ( + Any, + Dict, + Iterable, + List, + Optional, + Sequence, + Tuple, + Union, + cast, + overload, +) +from unittest import mock + +from typing_extensions import Self, TypeAlias from pyairtable.api import retrying -from pyairtable.api.api import TimeoutTuple -from pyairtable.api.types import AttachmentDict, CollaboratorDict, Fields, RecordDict -from pyairtable.utils import is_airtable_id +from pyairtable.api.api import Api, TimeoutTuple +from pyairtable.api.table import Table +from pyairtable.api.types import ( + AnyRecordDict, + AttachmentDict, + CollaboratorDict, + CreateRecordDict, + FieldName, + Fields, + RecordDeletedDict, + RecordDict, + RecordId, + UpdateRecordDict, + UpsertResultDict, + WritableFields, +) +from pyairtable.utils import fieldgetter, is_airtable_id + + +def _now() -> str: + return datetime.datetime.now().isoformat() + "Z" def fake_id(type: str = "rec", value: Any = None) -> str: @@ -53,8 +88,6 @@ def fake_meta( "timeout": timeout, "retry": retry, "typecast": typecast, - "timeout": timeout, - "retry": retry, "use_field_ids": use_field_ids, "memoize": memoize, } @@ -83,7 +116,7 @@ def fake_record( """ return { "id": str(id) if is_airtable_id(id, "rec") else fake_id(value=id), - "createdTime": datetime.datetime.now().isoformat() + "Z", + "createdTime": _now(), "fields": {**(fields or {}), **other_fields}, } @@ -114,3 +147,317 @@ def fake_attachment() -> AttachmentDict: "size": 100, "type": "text/plain", } + + +BaseAndTableId: TypeAlias = Tuple[str, str] + + +class MockAirtable: + """ + This class acts as a context manager which mocks several pyAirtable APIs, + so that your tests can operate against tables without making network requests. + + .. code-block:: python + + from pyairtable import Api + from pyairtable.testing import MockAirtable + + with MockAirtable() as m: + m.add_record("baseId", "tableName", {"Name": "Alice"}) + records = t.all() + assert len(t.all()) == 1 + + If you use pytest, you might want to include this as a fixture. + + .. code-block:: python + + import pytest + from pyairtable.testing import MockAirtable + + @pytest.fixture(autouse=True) + def mock_airtable(): + with MockAirtable() as m: + yield m + + def test_your_function(): + ... + + Not all API methods are supported; if your test calls a method that would + make a network request, a RuntimeError will be raised instead. + + >>> with MockAirtable() as m: + ... table.schema() + ... + Traceback (most recent call last): ... + RuntimeError: unhandled call to Api.request + + This behavior can be overridden by setting the ``passthrough`` argument to True, + either on the constructor or temporarily on the MockAirtable instance. This is + useful when using another library, like `requests-mock `_, + to prepare responses for complex cases (like code that retrieves the schema). + """ + + # The list of APIs that are mocked by this class. + mocked = [ + "Api._perform_request", + "Table.iterate", + "Table.get", + "Table.create", + "Table.update", + "Table.delete", + "Table.batch_create", + "Table.batch_update", + "Table.batch_delete", + "Table.batch_upsert", + ] + + # 2-layer mapping of (base, table) IDs --> record IDs --> record dicts. + records: Dict[BaseAndTableId, Dict[RecordId, RecordDict]] + + _stack: Optional[ExitStack] + _mocks: Dict[str, Any] + + def __init__(self, passthrough: bool = False) -> None: + """ + Args: + passthrough: if True, unmocked methods will still be allowed to + perform real network requests. If False, they will raise an error. + """ + self.passthrough = passthrough + self._reset() + + def _reset(self) -> None: + self._stack = None + self._mocks = {} + self.records = defaultdict(dict) + + def __enter__(self) -> Self: + if self._stack: + raise RuntimeError("MockAirtable is not reentrant") + if hasattr(Api._perform_request, "mock"): + raise RuntimeError("MockAirtable cannot be nested") + self._reset() + self._stack = ExitStack() + + for name in self.mocked: + side_effect_name = name.replace(".", "_").lower() + side_effect = getattr(self, f"_{side_effect_name}", None) + mocked_method = self._mocks[name] = mock.patch( + f"pyairtable.{name}", + side_effect=side_effect, + autospec=True, + ) + self._stack.enter_context(mocked_method) + + return self + + def __exit__(self, *exc_info: Any) -> None: + if self._stack: + self._stack.__exit__(*exc_info) + + @overload + def add_records( + self, + base_id: str, + table_name: str, + /, + records: Iterable[Dict[str, Any]], + ) -> None: ... + + @overload + def add_records( + self, + table: Table, + /, + records: Iterable[Dict[str, Any]], + ) -> None: ... + + def add_records(self, *args: Any, **kwargs: Any) -> None: + """ + Add a list of records to the mock Airtable instance. + + Can be called with either a base ID and table name, or an instance of :class:`~pyairtable.Table`. + + .. code-block:: + + with MockAirtable() as m: + m.add_records("baseId", "tableName", [{"Name": "Alice"}]) + + with MockAirtable() as m: + m.add_records(table, records=[{"id": "recFake", {"Name": "Alice"}}]) + """ + base_id, table_name, records = _extract_args(args, kwargs, ["records"]) + self.records[(base_id, table_name)].update( + { + coerced["id"]: coerced + for record in records + if (coerced := coerce_fake_record(record)) + } + ) + + def clear(self) -> None: + """ + Clear all records from the mock Airtable instance. + """ + self.records.clear() + + # side effects + + def _api__perform_request(self, method: str, url: str, **kwargs: Any) -> Any: + if not self.passthrough: + raise RuntimeError("unhandled call to Api.request") + mocked = self._mocks["Api._perform_request"] + return mocked.temp_original(method, url, **kwargs) + + def _table_iterate(self, table: Table, **options: Any) -> List[List[RecordDict]]: + return [list(self.records[(table.base.id, table.name)].values())] + + def _table_get(self, table: Table, record_id: str, **options: Any) -> RecordDict: + return self.records[(table.base.id, table.name)][record_id] + + def _table_create( + self, + table: Table, + record: CreateRecordDict, + **kwargs: Any, + ) -> RecordDict: + records = self.records[(table.base.id, table.name)] + record = coerce_fake_record(record) + while record["id"] in records: + record["id"] = fake_id() # pragma: no cover + records[record["id"]] = record + return record + + def _table_update( + self, + table: Table, + record_id: RecordId, + fields: WritableFields, + **kwargs: Any, + ) -> RecordDict: + exists = self.records[(table.base.id, table.name)][record_id] + exists["fields"].update(fields) + return exists + + def _table_delete(self, table: Table, record_id: RecordId) -> RecordDeletedDict: + self.records[(table.base.id, table.name)].pop(record_id) + return {"id": record_id, "deleted": True} + + def _table_batch_create( + self, + table: Table, + records: Iterable[CreateRecordDict], + **kwargs: Any, + ) -> List[RecordDict]: + return [self._table_create(table, record) for record in records] + + def _table_batch_update( + self, + table: Table, + records: Iterable[UpdateRecordDict], + **kwargs: Any, + ) -> List[RecordDict]: + return [ + self._table_update(table, record["id"], record["fields"]) + for record in records + ] + + def _table_batch_delete( + self, + table: Table, + record_ids: Iterable[RecordId], + ) -> List[RecordDeletedDict]: + return [self._table_delete(table, record_id) for record_id in record_ids] + + def _table_batch_upsert( + self, + table: Table, + records: Iterable[AnyRecordDict], + key_fields: Iterable[FieldName], + **kwargs: Any, + ) -> UpsertResultDict: + """ + Perform a batch upsert operation on the mocked records for the table. + """ + key = fieldgetter(*key_fields) + existing_by_id = self.records[(table.base.id, table.name)] + existing_by_key = {key(r): r for r in existing_by_id.values()} + result: UpsertResultDict = { + "updatedRecords": [], + "createdRecords": [], + "records": [], + } + + for record in records: + existing_record: Optional[RecordDict] + if "id" in record: + record_id = str(record.get("id")) + existing_record = existing_by_id[record_id] + existing_record["fields"].update(record["fields"]) + result["updatedRecords"].append(record_id) + result["records"].append(existing_record) + elif existing_record := existing_by_key.get(key(record)): + existing_record["fields"].update(record["fields"]) + result["updatedRecords"].append(existing_record["id"]) + result["records"].append(existing_record) + else: + created_record = self._table_create(table, record) + result["createdRecords"].append(created_record["id"]) + result["records"].append(created_record) + + return result + + +def coerce_fake_record(record: Union[AnyRecordDict, Fields]) -> RecordDict: + """ + Coerce a record dict or field mapping to the expected format for + an Airtable record, creating a fake ID and createdTime if necessary. + + >>> coerce_fake_record({"Name": "Alice"}) + {'id': 'rec000...', 'createdTime': '...', 'fields': {'Name': 'Alice'}} + """ + if "fields" not in record: + record = {"fields": cast(Fields, record)} + return { + "id": str(record.get("id") or fake_id()), + "createdTime": str(record.get("createdTime") or _now()), + "fields": record["fields"], + } + + +def _extract_args( + args: Sequence[Any], + kwargs: Dict[str, Any], + extract: Optional[Sequence[str]] = None, +) -> Tuple[Any, ...]: + """ + Convenience function for functions/methods which accept either + a Table or a (base_id, table_name) as their first posargs. + """ + extract = extract or [] + extracted = set() + caller = inspect.stack()[1].function + + if type(args[0]) is Table: + args = (args[0].base.id, args[0].name, *args[1:]) + + argtypes = tuple(type(arg) for arg in args) + if argtypes[:2] != (str, str): + raise TypeError( + f"{caller} expected (str, str, ...), got ({', '.join(t.__name__ for t in argtypes)})" + ) + + for extract_name in extract: + if extract_name in kwargs: + extracted.add(extract_name) + args = (*args, kwargs.pop(extract_name)) + + if kwargs: + raise TypeError( + f"{caller} got unexpected keyword arguments: {', '.join(kwargs)}" + ) + if len(args) < len(extract) + 2 and len(extracted) < len(extract): + missing = set(extract) - extracted + raise TypeError(f"{caller} missing keyword arguments: {', '.join(missing)}") + + return tuple(args) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index c4d44205..d2f9bc1b 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -20,7 +20,7 @@ import requests from typing_extensions import ParamSpec, Protocol -from pyairtable.api.types import CreateAttachmentDict +from pyairtable.api.types import AnyRecordDict, CreateAttachmentDict, FieldValue P = ParamSpec("P") R = TypeVar("R", covariant=True) @@ -200,13 +200,13 @@ def _wrapper(func: F) -> F: return _wrapper -class FetchMethod(Protocol, Generic[C, R]): +class _FetchMethod(Protocol, Generic[C, R]): def __get__(self, instance: C, owner: Any) -> Callable[..., R]: ... def __call__(self_, self: C, *, force: bool = False) -> R: ... -def cache_unless_forced(func: Callable[[C], R]) -> FetchMethod[C, R]: +def cache_unless_forced(func: Callable[[C], R]) -> _FetchMethod[C, R]: """ Wrap a method (e.g. ``Base.shares()``) in a decorator that will save a memoized version of the return value for future reuse, but will also @@ -226,7 +226,7 @@ def _inner(self: C, *, force: bool = False) -> R: _inner.__annotations__["force"] = bool _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|") - return cast(FetchMethod[C, R], _inner) + return cast(_FetchMethod[C, R], _inner) def coerce_iso_str(value: Any) -> Optional[str]: @@ -253,3 +253,53 @@ def coerce_list_str(value: Optional[Union[str, Iterable[str]]]) -> List[str]: if isinstance(value, str): return [value] return list(value) + + +def fieldgetter( + *fields: str, + required: Union[bool, Iterable[str]] = False, +) -> Callable[[AnyRecordDict], Any]: + """ + Create a function that extracts ID, created time, or field values from a record. + Intended to be used in similar situations as + `operator.itemgetter `_. + + >>> record = {"id": "rec001", "fields": {"Name": "Alice"}} + >>> fieldgetter("Name")(record) + 'Alice' + >>> fieldgetter("id")(record) + 'rec001' + >>> fieldgetter("id", "Name", "Missing")(record) + ('rec001', 'Alice', None) + + Args: + fields: The field names to extract from the record. The values + ``"id"`` and ``"createdTime"`` are special cased; all other + values are interpreted as field names. + required: If True, will raise KeyError if a value is missing. + If False, missing values will return as None. + If a sequence of field names is provided, only those names + will be required. + """ + if isinstance(required, str): + required = {required} + elif required is True: + required = set(fields) + elif required is False: + required = [] + else: + required = set(required) + + def _get_field(record: AnyRecordDict, field: str) -> FieldValue: + src = record if field in ("id", "createdTime") else record["fields"] + if field in required and field not in src: + raise KeyError(field) + return src.get(field) + + if len(fields) == 1: + return partial(_get_field, field=fields[0]) + + def _getter(record: AnyRecordDict) -> Any: + return tuple(_get_field(record, field) for field in fields) + + return _getter diff --git a/tests/test_testing.py b/tests/test_testing.py index 98b3d301..475770b3 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -48,3 +48,22 @@ def test_fake_function(funcname, sig, expected): func = getattr(T, funcname) assert func(*sig.args, **sig.kwargs) == expected + + +def test_coerce_fake_record(): + assert T.coerce_fake_record({"Name": "Alice"}) == { + "id": ANY, + "createdTime": ANY, + "fields": {"Name": "Alice"}, + } + assert T.coerce_fake_record({"fields": {"Name": "Alice"}}) == { + "id": ANY, + "createdTime": ANY, + "fields": {"Name": "Alice"}, + } + assert T.coerce_fake_record({"id": "rec123", "fields": {"Name": "Alice"}}) == { + "id": "rec123", + "createdTime": ANY, + "fields": {"Name": "Alice"}, + } + assert T.coerce_fake_record(fake := T.fake_record()) == fake diff --git a/tests/test_testing__mock_airtable.py b/tests/test_testing__mock_airtable.py new file mode 100644 index 00000000..0ee0b132 --- /dev/null +++ b/tests/test_testing__mock_airtable.py @@ -0,0 +1,207 @@ +from unittest.mock import ANY + +import pytest + +from pyairtable import testing as T + + +@pytest.fixture +def mock_airtable(requests_mock): + with T.MockAirtable() as m: + yield m + + +@pytest.fixture +def mock_records(mock_airtable, table): + mock_records = [T.fake_record() for _ in range(5)] + mock_airtable.add_records(table, mock_records) + return mock_records + + +@pytest.fixture +def mock_record(mock_records): + return mock_records[0] + + +def test_not_reentrant(): + """ + Test that nested MockAirtable contexts raise an error. + """ + mocked = T.MockAirtable() + with mocked: + with pytest.raises(RuntimeError): + with mocked: + pass + + +def test_multiple_nested_contexts(): + """ + Test that nested MockAirtable contexts raise an error. + """ + with T.MockAirtable(): + with pytest.raises(RuntimeError): + with T.MockAirtable(): + pass + + +def test_add_records__ids(mock_airtable, mock_records, table): + mock_airtable.add_records(table.base.id, table.name, mock_records) + assert table.all() == mock_records + + +def test_add_records__ids_kwarg(mock_airtable, mock_records, table): + mock_airtable.add_records(table.base.id, table.name, records=mock_records) + assert table.all() == mock_records + + +def test_add_records__kwarg(mock_airtable, mock_records, table): + mock_airtable.add_records(table, records=mock_records) + assert table.all() == mock_records + + +def test_add_records__missing_kwarg(mock_airtable, table): + with pytest.raises(TypeError, match="add_records missing keyword"): + mock_airtable.add_records(table) + with pytest.raises(TypeError, match="add_records missing keyword"): + mock_airtable.add_records("base", "table") + + +def test_add_records__invalid_types(mock_airtable): + with pytest.raises( + TypeError, + match=r"add_records expected \(str, str, \.\.\.\), got \(int, float\)", + ): + mock_airtable.add_records(1, 2.0, records=[]) + + +def test_add_records__invalid_kwarg(mock_airtable, table): + with pytest.raises( + TypeError, + match="add_records got unexpected keyword arguments: asdf", + ): + mock_airtable.add_records(table, records=[], asdf=1) + + +@pytest.mark.parametrize( + "funcname,expected", + [ + ("all", "mock_records"), + ("iterate", "[mock_records]"), + ("first", "mock_records[0]"), + ], +) +def test_table_iterate(mock_records, table, funcname, expected): + expected = eval(expected, {}, {"mock_records": mock_records}) + assert getattr(table, funcname)() == expected + + +def test_table_get(mock_record, table): + assert table.get(mock_record["id"]) == mock_record + + +def test_table_create(mock_airtable, table): + record = table.create(T.fake_record()["fields"]) + assert record in table.all() + + +def test_table_update(mock_record, table): + table.update(mock_record["id"], {"Name": "Bob"}) + assert table.get(mock_record["id"])["fields"]["Name"] == "Bob" + + +def test_table_delete(mock_record, table): + table.delete(mock_record["id"]) + assert mock_record not in table.all() + + +def test_table_batch_create(mock_airtable, mock_records, table): + mock_airtable.clear() + table.batch_create(mock_records) + assert all(r in table.all() for r in mock_records) + + +def test_table_batch_update(mock_records, table): + table.batch_update( + [{"id": record["id"], "fields": {"Name": "Bob"}} for record in mock_records] + ) + assert all(r["fields"]["Name"] == "Bob" for r in table.all()) + + +def test_table_batch_delete(mock_records, table): + table.batch_delete([r["id"] for r in mock_records]) + assert table.all() == [] + + +def test_table_batch_upsert(mock_airtable, table): + # this one is complicated because we actually do the upsert logic + mock_airtable.clear() + mock_airtable.add_records( + table, + [ + {"id": "rec001", "fields": {"Name": "Alice"}}, + {"id": "rec002", "fields": {"Name": "Bob"}}, + {"id": "rec003", "fields": {"Name": "Carol"}}, + ], + ) + table.batch_upsert( + records=[ + {"fields": {"Name": "Alice", "Email": "alice@example.com"}}, + {"fields": {"Name": "Bob", "Email": "bob@example.com"}}, + {"id": "rec003", "fields": {"Email": "carol@example.com"}}, + {"fields": {"Name": "Dave", "Email": "dave@example.com"}}, + ], + key_fields=["Name"], + ) + assert table.all() == [ + { + "id": "rec001", + "createdTime": ANY, + "fields": {"Name": "Alice", "Email": "alice@example.com"}, + }, + { + "id": "rec002", + "createdTime": ANY, + "fields": {"Name": "Bob", "Email": "bob@example.com"}, + }, + { + "id": "rec003", + "createdTime": ANY, + "fields": {"Name": "Carol", "Email": "carol@example.com"}, + }, + { + "id": ANY, + "createdTime": ANY, + "fields": {"Name": "Dave", "Email": "dave@example.com"}, + }, + ] + + +@pytest.mark.parametrize( + "expr", + [ + "base.collaborators()", + "base.create_table('Name', fields=[])", + "base.delete()", + "base.shares()", + "base.webhooks()", + "table.add_comment('recordId', 'value')", + "table.comments('recordId')", + "table.create_field('name', 'type')", + "table.schema()", + ], +) +def test_unhandled_methods(mock_airtable, expr, api, base, table): + """ + Test that unhandled methods raise an error. + """ + with pytest.raises(RuntimeError): + eval(expr, {}, {"api": api, "base": base, "table": table}) + + +def test_passthrough(mock_airtable, requests_mock, base, monkeypatch): + """ + Test that we can temporarily pass through unhandled methods to the requests library. + """ + requests_mock.get(base.meta_url("tables"), json={"tables": []}) + monkeypatch.setattr(mock_airtable, "passthrough", True) + assert base.schema().tables == [] # no RuntimeError diff --git a/tests/test_utils.py b/tests/test_utils.py index f1d8f42f..e22e4c82 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,6 +4,7 @@ import pytest from pyairtable import utils +from pyairtable.testing import fake_record utc_tz = partial(datetime, tzinfo=timezone.utc) @@ -94,3 +95,53 @@ def test_converter(func, input, expected): return assert func(input) == expected + + +def test_fieldgetter(): + get_a = utils.fieldgetter("A") + get_abc = utils.fieldgetter("A", "B", "C") + + assert get_a(fake_record(A=1)) == 1 + assert get_a({"fields": {"A": 1}}) == 1 + assert get_abc(fake_record(A=1, C=3)) == (1, None, 3) + assert get_abc({"fields": {"A": 1, "C": 3}}) == (1, None, 3) + + record = fake_record(A="one", B="two") + assert get_a(record) == "one" + assert get_abc(record) == ("one", "two", None) + assert utils.fieldgetter("id")(record) == record["id"] + assert utils.fieldgetter("createdTime")(record) == record["createdTime"] + + +def test_fieldgetter__required(): + """ + Test that required=True means all fields are required. + """ + require_ab = utils.fieldgetter("A", "B", required=True) + record = fake_record(A="one", B="two") + assert require_ab(record) == ("one", "two") + with pytest.raises(KeyError): + require_ab(fake_record(A="one")) + + +def test_fieldgetter__required_list(): + """ + Test that required=["A", "B"] means only A and B are required. + """ + get_abc_require_ab = utils.fieldgetter("A", "B", "C", required=["A", "B"]) + record = fake_record(A="one", B="two") + assert get_abc_require_ab(record) == ("one", "two", None) + with pytest.raises(KeyError): + get_abc_require_ab(fake_record(A="one", C="three")) + + +def test_fieldgetter__required_str(): + """ + Test that required="Bravo" means only Bravo is required, + rather than ["B", "r", "a", "v", "o"]. + """ + get_abc_require_b = utils.fieldgetter("Alpha", "Bravo", required="Bravo") + record = fake_record(Alpha="one", Bravo="two") + assert get_abc_require_b(record) == ("one", "two") + with pytest.raises(KeyError): + get_abc_require_b(fake_record(Alpha="one")) From 521538d20c352b6f5bf62b537aa7dd7f36843375 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Sep 2024 10:01:51 -0700 Subject: [PATCH 176/272] Table.upload_attachment --- pyairtable/api/table.py | 49 ++++++++++++++++++++++- pyairtable/api/types.py | 6 +++ tests/integration/conftest.py | 1 + tests/integration/test_integration_api.py | 46 +++++++++++++++++++-- tests/test_api_table.py | 48 +++++++++++++++++++++- tests/test_orm_model.py | 23 ++++++++++- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index a9d42943..ceba27b6 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -1,7 +1,21 @@ +import base64 +import mimetypes +import os import posixpath import urllib.parse import warnings -from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload +from pathlib import Path +from typing import ( + Any, + BinaryIO, + Dict, + Iterable, + Iterator, + List, + Optional, + Union, + overload, +) import pyairtable.models from pyairtable.api.retrying import Retry @@ -11,6 +25,7 @@ RecordDict, RecordId, UpdateRecordDict, + UploadAttachmentResultDict, UpsertResultDict, WritableFields, assert_typed_dict, @@ -691,6 +706,38 @@ 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[bytes] = None, + content_type: Optional[str] = None, + ) -> UploadAttachmentResultDict: + """ """ + 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" + 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 5ab90ca6..ee41cf23 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -350,6 +350,12 @@ class UserAndScopesDict(TypedDict, total=False): scopes: List[str] +class UploadAttachmentResultDict(TypedDict): + id: RecordId + createdTime: str + fields: Dict[str, List[AttachmentDict]] + + @lru_cache def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]: """ 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..5a254a56 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -1,7 +1,9 @@ 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 @@ -286,14 +288,50 @@ 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"), + attachment(valid_img_url, filename="a.png"), + attachment(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: [attachment(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/test_api_table.py b/tests/test_api_table.py index 34beedc2..ef9f5dca 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,52 @@ 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()]}, + }, + ) + + +def test_upload_attachment(mock_upload_attachment, table): + """ + Test that we can upload an attachment to a record. + """ + table.upload_attachment(RECORD_ID, FIELD_ID, "sample.txt", b"Hello, World!") + 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(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_orm_model.py b/tests/test_orm_model.py index 32d5eee6..d7f41a75 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -8,7 +8,7 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f from pyairtable.orm.model import SaveResult -from pyairtable.testing import fake_id, fake_meta, fake_record +from pyairtable.testing import fake_attachment, fake_id, fake_meta, fake_record class FakeModel(Model): @@ -458,3 +458,24 @@ def test_save_bool_deprecated(): with pytest.deprecated_call(): assert bool(SaveResult(fake_id(), created=True)) is True + + +@pytest.mark.skip +def test_add_attachment(): + """ + Test that we can add an attachment to a record. + """ + + class Fake(Model): + Meta = fake_meta() + attachments = f.AttachmentsField("Files") + + record = fake_record(Files=fake_attachment()) + + record = fake_record() + with mock.patch("pyairtable.Table.add_attachment") as mock_add_attachment: + FakeModel.add_attachment(record["id"], "file.txt", b"Hello, World!") + + mock_add_attachment.assert_called_once_with( + record["id"], "file.txt", b"Hello, World!" + ) From f3d41803b6092bae866a8f48298883855068583a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Sep 2024 13:14:50 -0700 Subject: [PATCH 177/272] Model.attachments.upload() Along the way I moved orm.changes -> orm.lists and renamed ChangeNotifyingList -> ChangeTrackingList --- docs/source/changelog.rst | 1 + docs/source/orm.rst | 37 +++++++++ pyairtable/api/table.py | 12 +-- pyairtable/orm/changes.py | 48 ------------ pyairtable/orm/fields.py | 52 ++++++++++-- pyairtable/orm/lists.py | 96 +++++++++++++++++++++++ tests/integration/test_integration_orm.py | 32 ++++++++ tests/test_orm_model.py | 34 ++++++-- tests/test_typing.py | 3 +- 9 files changed, 242 insertions(+), 73 deletions(-) delete mode 100644 pyairtable/orm/changes.py create mode 100644 pyairtable/orm/lists.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c0e4041c..7834362a 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -43,6 +43,7 @@ Changelog * Changed the return type of :meth:`Model.save ` from ``bool`` to :class:`~pyairtable.orm.SaveResult`. - `PR #387 `_ +* Added support for `Upload attachment `_. 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 40473ccc..f91886d2 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -583,6 +583,43 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi >>> comment.delete() +Attachments +------------------ + +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 conform +to :class:`~pyairtable.api.types.CreateAttachmentDict`, 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.fields.AttachmentList.upload` to +directly upload content to Airtable. You do not need to call +:meth:`~pyairtable.orm.Model.save`; the change will be saved immediately. + + >>> model.attachments.upload("example.jpg", b"...", "image/jpeg") + >>> model.attachments[-1]["filename"] + 'example.jpg' + >>> model.attachments[-1]["url"] + 'https://v5.airtableusercontent.com/...' + + ORM Limitations ------------------ diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index ceba27b6..60576a4a 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -5,17 +5,7 @@ import urllib.parse import warnings from pathlib import Path -from typing import ( - Any, - BinaryIO, - Dict, - Iterable, - Iterator, - List, - Optional, - Union, - overload, -) +from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload import pyairtable.models from pyairtable.api.retrying import Retry 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..9a1d4c0b 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -29,7 +29,7 @@ import importlib from datetime import date, datetime, timedelta from enum import Enum -from functools import partial +from pathlib import Path from typing import ( TYPE_CHECKING, Any, @@ -61,7 +61,7 @@ RecordId, ) from pyairtable.exceptions import MissingValueError, MultipleValuesError -from pyairtable.orm.changes import ChangeNotifyingList +from pyairtable.orm.lists import ChangeTrackingList if TYPE_CHECKING: from pyairtable.orm import Model # noqa @@ -474,6 +474,7 @@ class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM], List[T_O """ valid_types = list + list_class: Type[ChangeTrackingList[T_ORM]] = ChangeTrackingList # List fields will always return a list, never ``None``, so we # have to overload the type annotations for __get__ @@ -501,9 +502,8 @@ def _get_list_value(self, instance: "Model") -> List[T_ORM]: # 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): + 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 @@ -873,6 +873,32 @@ class AITextField(_DictField[AITextDict]): readonly = True +class AttachmentsList(ChangeTrackingList[AttachmentDict]): + def upload( + self, + filename: Union[str, Path], + content: Optional[bytes] = None, + content_type: Optional[str] = None, + ) -> None: + """ + Upload an attachment to the Airtable API. This will replace the current + list with the response from the server, which will contain a full list of + :class:`~pyairtable.api.types.AttachmentDict`. + """ + if not self._model.id: + raise ValueError("cannot upload attachments to an unsaved record") + response = self._model.meta.table.upload_attachment( + self._model.id, + self._field.field_name, + filename=filename, + content=content, + content_type=content_type, + ) + with self.disable_tracking(): + self.clear() + self.extend(*response["fields"].values()) + + class AttachmentsField(_ValidatingListField[AttachmentDict]): """ Accepts a list of dicts in the format detailed in @@ -880,6 +906,22 @@ class AttachmentsField(_ValidatingListField[AttachmentDict]): """ contains_type = cast(Type[AttachmentDict], dict) + list_class = AttachmentsList + + # TODO: this is a bit of a hack to make AttachmentsField return AttachmentsList. + # It makes assumptions about parent class, and ought to be refactored. + @overload + def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... + + @overload + def __get__(self, instance: "Model", owner: Type[Any]) -> AttachmentsList: ... + + def __get__( + self, instance: Optional["Model"], owner: Type[Any] + ) -> Union[SelfType, AttachmentsList]: + if not instance: + return self + return cast(AttachmentsList, super().__get__(instance, owner)) class BarcodeField(_DictField[BarcodeDict]): diff --git a/pyairtable/orm/lists.py b/pyairtable/orm/lists.py new file mode 100644 index 00000000..4b026390 --- /dev/null +++ b/pyairtable/orm/lists.py @@ -0,0 +1,96 @@ +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterable, Iterator, List, Optional, SupportsIndex, Union + +from typing_extensions import Self, TypeVar + +from pyairtable.api.types import AttachmentDict + +T = TypeVar("T") + + +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: Any, model: Any) -> 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 + + 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) + + +class AttachmentsList(ChangeTrackingList[AttachmentDict]): + def upload( + self, + filename: Union[str, Path], + content: Optional[bytes] = None, + content_type: Optional[str] = None, + ) -> None: + """ + Upload an attachment to the Airtable API. This will replace the current + list with the response from the server, which will contain a full list of + :class:`~pyairtable.api.types.AttachmentDict`. + """ + if not self._model.id: + raise ValueError("cannot upload attachments to an unsaved record") + response = self._model.meta.table.upload_attachment( + self._model.id, + self._field.field_name, + filename=filename, + content=content, + content_type=content_type, + ) + with self.disable_tracking(): + self.clear() + self.extend(*response["fields"].values()) diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index e859ef9e..3917f390 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -251,3 +251,35 @@ 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]["filename"] == "logo.png" + assert record.attachments[0]["type"] == "image/png" + assert record.attachments[0]["url"] != valid_img_url # overwritten by Airtable + + # 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/test_orm_model.py b/tests/test_orm_model.py index d7f41a75..201054ad 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -8,7 +8,9 @@ from pyairtable.orm import Model from pyairtable.orm import fields as f from pyairtable.orm.model import SaveResult -from pyairtable.testing import fake_attachment, fake_id, fake_meta, fake_record +from pyairtable.testing import fake_id, fake_meta, fake_record + +NOW = datetime.now(timezone.utc).isoformat() class FakeModel(Model): @@ -461,7 +463,7 @@ def test_save_bool_deprecated(): @pytest.mark.skip -def test_add_attachment(): +def test_attachment_upload(tmp_path): """ Test that we can add an attachment to a record. """ @@ -470,12 +472,28 @@ class Fake(Model): Meta = fake_meta() attachments = f.AttachmentsField("Files") - record = fake_record(Files=fake_attachment()) - record = fake_record() - with mock.patch("pyairtable.Table.add_attachment") as mock_add_attachment: - FakeModel.add_attachment(record["id"], "file.txt", b"Hello, World!") + instance = Fake.from_record(record) + response = { + "id": record["id"], + "createdTime": NOW, + "fields": { + fake_id("fld"): [ + { + "id": fake_id("att"), + "url": "https://example.com/a.png", + "filename": "a.txt", + "type": "text/plain", + }, + ] + }, + } + tmp_file = tmp_path / "a.txt" + tmp_file.write_text("Hello, world!") + + with mock.patch("pyairtable.Table.upload_attachment", return_value=response) as m: + instance.attachments.upload(tmp_file) - mock_add_attachment.assert_called_once_with( - record["id"], "file.txt", b"Hello, World!" + m.assert_called_once_with( + record["id"], "Files", filename=tmp_file, content=None, content_type=None ) diff --git a/tests/test_typing.py b/tests/test_typing.py index 2078d5f3..7309d739 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -10,6 +10,7 @@ import pyairtable import pyairtable.api.types as T from pyairtable import orm +from pyairtable.orm.fields import AttachmentsList if TYPE_CHECKING: # This section does not actually get executed; it is only parsed by mypy. @@ -139,7 +140,7 @@ class EveryField(orm.Model): record = EveryField() assert_type(record.aitext, Optional[T.AITextDict]) - assert_type(record.attachments, List[T.AttachmentDict]) + assert_type(record.attachments, AttachmentsList) assert_type(record.autonumber, int) assert_type(record.barcode, Optional[T.BarcodeDict]) assert_type(record.button, T.ButtonDict) From 7b4ce8f9fa239c768b24fb1bda072512cbe88236 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 12:17:37 -0700 Subject: [PATCH 178/272] Clean up typing of ListField, AttachmentsField --- pyairtable/exceptions.py | 12 ++++ pyairtable/orm/fields.py | 129 ++++++++++++++++--------------------- pyairtable/orm/generate.py | 4 +- pyairtable/orm/lists.py | 39 +++++++++-- tests/test_orm_generate.py | 2 +- tests/test_orm_lists.py | 71 ++++++++++++++++++++ tests/test_orm_model.py | 37 ----------- tests/test_typing.py | 19 ++++-- 8 files changed, 187 insertions(+), 126 deletions(-) create mode 100644 tests/test_orm_lists.py 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/orm/fields.py b/pyairtable/orm/fields.py index 9a1d4c0b..63ac35bd 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 pathlib import Path from typing import ( TYPE_CHECKING, Any, @@ -60,8 +59,12 @@ CollaboratorDict, RecordId, ) -from pyairtable.exceptions import MissingValueError, MultipleValuesError -from pyairtable.orm.lists import ChangeTrackingList +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 +74,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,15 +470,23 @@ 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[ChangeTrackingList[T_ORM]] = ChangeTrackingList + list_class: Type[T_ORM_List] + contains_type: Optional[Type[T_ORM]] = None # List fields will always return a list, never ``None``, so we # have to overload the type annotations for __get__ @@ -483,43 +495,53 @@ class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM], List[T_O 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, self.list_class): + if not isinstance(self.list_class, type): + raise RuntimeError(f"expected a type, got {self.list_class}") + if not issubclass(self.list_class, ChangeTrackingList): + raise RuntimeError( + f"expected Type[ChangeTrackingList], got {self.list_class}" + ) 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). + """ + + list_class = ChangeTrackingList class _LinkFieldOptions(Enum): @@ -530,7 +552,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. @@ -540,6 +565,8 @@ class LinkField(_ListField[RecordId, T_Linked]): See `Link to another record `__. """ + list_class = ChangeTrackingList + _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]] _max_retrieve: Optional[int] = None @@ -680,7 +707,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 +736,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,56 +900,10 @@ class AITextField(_DictField[AITextDict]): readonly = True -class AttachmentsList(ChangeTrackingList[AttachmentDict]): - def upload( - self, - filename: Union[str, Path], - content: Optional[bytes] = None, - content_type: Optional[str] = None, - ) -> None: - """ - Upload an attachment to the Airtable API. This will replace the current - list with the response from the server, which will contain a full list of - :class:`~pyairtable.api.types.AttachmentDict`. - """ - if not self._model.id: - raise ValueError("cannot upload attachments to an unsaved record") - response = self._model.meta.table.upload_attachment( - self._model.id, - self._field.field_name, - filename=filename, - content=content, - content_type=content_type, - ) - with self.disable_tracking(): - self.clear() - self.extend(*response["fields"].values()) - - -class AttachmentsField(_ValidatingListField[AttachmentDict]): - """ - Accepts a list of dicts in the format detailed in - `Attachments `_. - """ - +class AttachmentsField(_ListFieldBase[AttachmentDict, AttachmentDict, AttachmentsList]): contains_type = cast(Type[AttachmentDict], dict) list_class = AttachmentsList - # TODO: this is a bit of a hack to make AttachmentsField return AttachmentsList. - # It makes assumptions about parent class, and ought to be refactored. - @overload - def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ... - - @overload - def __get__(self, instance: "Model", owner: Type[Any]) -> AttachmentsList: ... - - def __get__( - self, instance: Optional["Model"], owner: Type[Any] - ) -> Union[SelfType, AttachmentsList]: - if not instance: - return self - return cast(AttachmentsList, super().__get__(instance, owner)) - class BarcodeField(_DictField[BarcodeDict]): """ @@ -996,7 +977,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. @@ -1026,7 +1007,7 @@ class ManualSortField(TextField): readonly = True -class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]): +class MultipleCollaboratorsField(_ListField[CollaboratorDict]): """ Accepts a list of dicts in the format detailed in `Multiple Collaborators `_. @@ -1035,7 +1016,7 @@ class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]): contains_type = cast(Type[CollaboratorDict], dict) -class MultipleSelectField(_ValidatingListField[str]): +class MultipleSelectField(_ListField[str]): """ Accepts a list of ``str``. 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 index 4b026390..97623b39 100644 --- a/pyairtable/orm/lists.py +++ b/pyairtable/orm/lists.py @@ -1,21 +1,37 @@ from contextlib import contextmanager from pathlib import Path -from typing import Any, Iterable, Iterator, List, Optional, SupportsIndex, Union +from typing import ( + TYPE_CHECKING, + Iterable, + Iterator, + List, + Optional, + SupportsIndex, + Union, + overload, +) from typing_extensions import Self, TypeVar from pyairtable.api.types import AttachmentDict +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: Any, model: Any) -> None: + def __init__(self, *args: Iterable[T], field: "AnyField", model: "Model") -> None: super().__init__(*args) self._field = field self._model = model @@ -37,9 +53,20 @@ def _on_change(self) -> None: if self._tracking_enabled: self._model._changed[self._field.field_name] = True - def __setitem__(self, index: SupportsIndex, value: T) -> None: # type: ignore[override] + @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) + return super().__setitem__(index, value) # type: ignore def __delitem__(self, key: Union[SupportsIndex, slice]) -> None: self._on_change() @@ -83,7 +110,9 @@ def upload( :class:`~pyairtable.api.types.AttachmentDict`. """ if not self._model.id: - raise ValueError("cannot upload attachments to an unsaved record") + 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, 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..244df3bc --- /dev/null +++ b/tests/test_orm_lists.py @@ -0,0 +1,71 @@ +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.png", + "filename": "a.txt", + "type": "text/plain", + }, + ] + }, + } + with mock.patch("pyairtable.Table.upload_attachment", return_value=response) as m: + yield m + + +def test_attachment_upload(mock_upload, tmp_path): + """ + Test that we can add an attachment to a record. + """ + tmp_file = tmp_path / "a.txt" + tmp_file.write_text("Hello, world!") + + record = fake_record() + instance = Fake.from_record(record) + instance.attachments.upload(tmp_file) + + mock_upload.assert_called_once_with( + record["id"], + "Files", + filename=tmp_file, + content=None, + content_type=None, + ) + + +def test_attachment_upload__readonly(mock_upload): + 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(mock_upload): + instance = Fake() + with pytest.raises(UnsavedRecordError): + instance.attachments.upload("a.txt", content=b"Hello, world!") + mock_upload.assert_not_called() diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 201054ad..2e1559b5 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -460,40 +460,3 @@ def test_save_bool_deprecated(): with pytest.deprecated_call(): assert bool(SaveResult(fake_id(), created=True)) is True - - -@pytest.mark.skip -def test_attachment_upload(tmp_path): - """ - Test that we can add an attachment to a record. - """ - - class Fake(Model): - Meta = fake_meta() - attachments = f.AttachmentsField("Files") - - record = fake_record() - instance = Fake.from_record(record) - response = { - "id": record["id"], - "createdTime": NOW, - "fields": { - fake_id("fld"): [ - { - "id": fake_id("att"), - "url": "https://example.com/a.png", - "filename": "a.txt", - "type": "text/plain", - }, - ] - }, - } - tmp_file = tmp_path / "a.txt" - tmp_file.write_text("Hello, world!") - - with mock.patch("pyairtable.Table.upload_attachment", return_value=response) as m: - instance.attachments.upload(tmp_file) - - m.assert_called_once_with( - record["id"], "Files", filename=tmp_file, content=None, content_type=None - ) diff --git a/tests/test_typing.py b/tests/test_typing.py index 7309d739..8578602e 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -9,8 +9,8 @@ import pyairtable import pyairtable.api.types as T +import pyairtable.orm.lists as L from pyairtable import orm -from pyairtable.orm.fields import AttachmentsList if TYPE_CHECKING: # This section does not actually get executed; it is only parsed by mypy. @@ -73,7 +73,7 @@ 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[T.CollaboratorDict]) class Movie(orm.Model): name = orm.fields.TextField("Name") @@ -85,9 +85,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): @@ -140,7 +141,9 @@ class EveryField(orm.Model): record = EveryField() assert_type(record.aitext, Optional[T.AITextDict]) - assert_type(record.attachments, AttachmentsList) + assert_type(record.attachments, L.AttachmentsList) + assert_type(record.attachments[0], T.AttachmentDict) + 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) @@ -158,8 +161,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[T.CollaboratorDict]) + assert_type(record.multi_user[0], T.CollaboratorDict) + 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) From fe998817999a5a1b408cf24422b1c44597b3a324 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 12:40:58 -0700 Subject: [PATCH 179/272] move ListField params into __init_subclass__ --- pyairtable/orm/fields.py | 47 +++++++++++++++++++++++----------------- tests/test_orm_fields.py | 19 ++++++++++++++++ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 63ac35bd..2cf7fbe4 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -486,11 +486,26 @@ class _ListFieldBase( valid_types = list list_class: Type[T_ORM_List] - contains_type: Optional[Type[T_ORM]] = None + 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: ... @@ -513,12 +528,9 @@ def _get_list_value(self, instance: "Model") -> T_ORM_List: # 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, self.list_class): - if not isinstance(self.list_class, type): - raise RuntimeError(f"expected a type, got {self.list_class}") - if not issubclass(self.list_class, ChangeTrackingList): - raise RuntimeError( - f"expected Type[ChangeTrackingList], got {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 @@ -541,8 +553,6 @@ class _ListField(Generic[T], _ListFieldBase[T, T, ChangeTrackingList[T]]): Not for direct use; should be subclassed by concrete field types (below). """ - list_class = ChangeTrackingList - class _LinkFieldOptions(Enum): LinkSelf = object() @@ -565,8 +575,6 @@ class LinkField( See `Link to another record `__. """ - list_class = ChangeTrackingList - _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]] _max_retrieve: Optional[int] = None @@ -900,9 +908,12 @@ class AITextField(_DictField[AITextDict]): readonly = True -class AttachmentsField(_ListFieldBase[AttachmentDict, AttachmentDict, AttachmentsList]): - contains_type = cast(Type[AttachmentDict], dict) - list_class = AttachmentsList +class AttachmentsField( + _ListFieldBase[AttachmentDict, AttachmentDict, AttachmentsList], + list_class=AttachmentsList, + contains_type=dict, +): + pass class BarcodeField(_DictField[BarcodeDict]): @@ -1007,24 +1018,20 @@ class ManualSortField(TextField): readonly = True -class MultipleCollaboratorsField(_ListField[CollaboratorDict]): +class MultipleCollaboratorsField(_ListField[CollaboratorDict], contains_type=dict): """ Accepts a list of dicts in the format detailed in `Multiple Collaborators `_. """ - contains_type = cast(Type[CollaboratorDict], dict) - -class MultipleSelectField(_ListField[str]): +class MultipleSelectField(_ListField[str], contains_type=str): """ Accepts a list of ``str``. See `Multiple select `__. """ - contains_type = str - class PercentField(NumberField): """ diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 63c42697..6ded1e7b 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -1070,3 +1070,22 @@ 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 From a903bf1465e562dfd81806809460b987c6441060 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 12:22:24 -0700 Subject: [PATCH 180/272] Support content=str in upload_attachment() --- pyairtable/api/table.py | 3 ++- pyairtable/orm/lists.py | 2 +- tests/test_api_table.py | 7 ++++--- tests/test_orm_lists.py | 12 +++++++----- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 60576a4a..e9faafcf 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -701,7 +701,7 @@ def upload_attachment( record_id: RecordId, field: str, filename: Union[str, Path], - content: Optional[bytes] = None, + content: Optional[Union[str, bytes]] = None, content_type: Optional[str] = None, ) -> UploadAttachmentResultDict: """ """ @@ -720,6 +720,7 @@ def upload_attachment( # 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, diff --git a/pyairtable/orm/lists.py b/pyairtable/orm/lists.py index 97623b39..701da08a 100644 --- a/pyairtable/orm/lists.py +++ b/pyairtable/orm/lists.py @@ -101,7 +101,7 @@ class AttachmentsList(ChangeTrackingList[AttachmentDict]): def upload( self, filename: Union[str, Path], - content: Optional[bytes] = None, + content: Optional[Union[str, bytes]] = None, content_type: Optional[str] = None, ) -> None: """ diff --git a/tests/test_api_table.py b/tests/test_api_table.py index ef9f5dca..4a4f9d4f 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -618,11 +618,12 @@ def mock_upload_attachment(requests_mock, table): ) -def test_upload_attachment(mock_upload_attachment, table): +@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", b"Hello, World!") + 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!" @@ -630,7 +631,7 @@ def test_upload_attachment(mock_upload_attachment, table): } -def test_upload_attachment__no_content(mock_upload_attachment, table, tmp_path): +def test_upload_attachment__no_content_type(mock_upload_attachment, table, tmp_path): """ Test that we can upload an attachment to a record. """ diff --git a/tests/test_orm_lists.py b/tests/test_orm_lists.py index 244df3bc..8d867761 100644 --- a/tests/test_orm_lists.py +++ b/tests/test_orm_lists.py @@ -37,21 +37,23 @@ def mock_upload(): yield m -def test_attachment_upload(mock_upload, tmp_path): +@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. """ - tmp_file = tmp_path / "a.txt" - tmp_file.write_text("Hello, world!") + 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(tmp_file) + instance.attachments.upload(fp) mock_upload.assert_called_once_with( record["id"], "Files", - filename=tmp_file, + filename=fp, content=None, content_type=None, ) From 7ef611482344e280e0d428a89f062ea21d41224e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 13:47:43 -0700 Subject: [PATCH 181/272] Additional tests for attachment edge cases --- docs/source/orm.rst | 1 + tests/test_orm_fields.py | 46 ++++++++++++++++++++++++++++++++++++++++ tests/test_orm_lists.py | 28 +++++++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index f91886d2..0859ff43 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -612,6 +612,7 @@ back to the API. You can also use :meth:`~pyairtable.orm.fields.AttachmentList.upload` to directly upload content to Airtable. You do not need to call :meth:`~pyairtable.orm.Model.save`; the change will be saved immediately. +Note that this means any other unsaved changes to this field will be lost. >>> model.attachments.upload("example.jpg", b"...", "image/jpeg") >>> model.attachments[-1]["filename"] diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 6ded1e7b..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, @@ -1089,3 +1090,48 @@ def test_invalid_list_class_params(class_kwargs): 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_lists.py b/tests/test_orm_lists.py index 8d867761..bc0545ff 100644 --- a/tests/test_orm_lists.py +++ b/tests/test_orm_lists.py @@ -60,14 +60,40 @@ def test_attachment_upload(mock_upload, tmp_path, content): 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(mock_upload): +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 From 88db5c9a08a7f52ad222e15b55e1bcff7377c76d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 18:36:32 -0700 Subject: [PATCH 182/272] Fix type bugs with ORM fields that store lists of dicts --- pyairtable/api/types.py | 18 ++++++++++- pyairtable/orm/fields.py | 26 ++++++++------- pyairtable/orm/lists.py | 9 ++++-- pyairtable/utils.py | 39 +++++++++++++++++++++-- tests/integration/test_integration_orm.py | 4 +-- tests/test_api_types.py | 7 ++-- tests/test_orm_lists.py | 22 +++++++++++-- tests/test_typing.py | 35 ++++++++++++++++---- 8 files changed, 128 insertions(+), 32 deletions(-) diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index ee41cf23..af2a0089 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. diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 2cf7fbe4..db919819 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -57,6 +57,8 @@ BarcodeDict, ButtonDict, CollaboratorDict, + CollaboratorEmailDict, + CreateAttachmentDict, RecordId, ) from pyairtable.exceptions import ( @@ -909,7 +911,11 @@ class AITextField(_DictField[AITextDict]): class AttachmentsField( - _ListFieldBase[AttachmentDict, AttachmentDict, AttachmentsList], + _ListFieldBase[ + AttachmentDict, + Union[AttachmentDict, CreateAttachmentDict], + AttachmentsList, + ], list_class=AttachmentsList, contains_type=dict, ): @@ -924,7 +930,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 `_ @@ -968,10 +974,8 @@ class ExternalSyncSourceField(TextField): readonly = True -class LastModifiedByField(CollaboratorField): +class LastModifiedByField(_DictField[CollaboratorDict]): """ - Equivalent to :class:`CollaboratorField(readonly=True) `. - See `Last modified by `__. """ @@ -1018,7 +1022,9 @@ class ManualSortField(TextField): readonly = True -class MultipleCollaboratorsField(_ListField[CollaboratorDict], contains_type=dict): +class MultipleCollaboratorsField( + _ListField[Union[CollaboratorDict, CollaboratorEmailDict]], contains_type=dict +): """ Accepts a list of dicts in the format detailed in `Multiple Collaborators `_. @@ -1177,7 +1183,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 `_ @@ -1367,7 +1373,7 @@ class RequiredUrlField(UrlField, _BasicFieldWithRequiredValue[str]): """ -# [[[end]]] (checksum: 84b5c48286d992737e12318a72e4e123) +# [[[end]]] (checksum: 5078434bb8fd65fa8f0be48de6915c2d) # fmt: on @@ -1395,10 +1401,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/lists.py b/pyairtable/orm/lists.py index 701da08a..f32f5470 100644 --- a/pyairtable/orm/lists.py +++ b/pyairtable/orm/lists.py @@ -13,7 +13,7 @@ from typing_extensions import Self, TypeVar -from pyairtable.api.types import AttachmentDict +from pyairtable.api.types import AttachmentDict, CreateAttachmentDict from pyairtable.exceptions import ReadonlyFieldError, UnsavedRecordError T = TypeVar("T") @@ -97,7 +97,7 @@ def pop(self, index: SupportsIndex = -1) -> T: return super().pop(index) -class AttachmentsList(ChangeTrackingList[AttachmentDict]): +class AttachmentsList(ChangeTrackingList[Union[AttachmentDict, CreateAttachmentDict]]): def upload( self, filename: Union[str, Path], @@ -120,6 +120,9 @@ def upload( content=content, content_type=content_type, ) + attachments = list(response["fields"].values()).pop(0) with self.disable_tracking(): self.clear() - self.extend(*response["fields"].values()) + # 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/utils.py b/pyairtable/utils.py index c4d44205..c19a01c1 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -20,7 +20,7 @@ import requests from typing_extensions import ParamSpec, Protocol -from pyairtable.api.types import CreateAttachmentDict +from pyairtable.api.types import CreateAttachmentByUrl P = ParamSpec("P") R = TypeVar("R", covariant=True) @@ -72,7 +72,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 +83,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: @@ -253,3 +253,36 @@ def coerce_list_str(value: Optional[Union[str, Iterable[str]]]) -> List[str]: if isinstance(value, str): return [value] return list(value) + + +# [[[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", + "is_airtable_id", + "is_base_id", + "is_field_id", + "is_record_id", + "is_table_id", + "is_user_id", +] +# [[[end]]] (checksum: 2e24ae7bd070c354cece2852ade7cdf9) diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 3917f390..65577683 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -261,11 +261,9 @@ def test_attachments_upload(Everything, valid_img_url, tmp_path): 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" - assert record.attachments[0]["type"] == "image/png" - assert record.attachments[0]["url"] != valid_img_url # overwritten by Airtable # add an attachment by uploading content tmp_file = tmp_path / "sample.txt" diff --git a/tests/test_api_types.py b/tests/test_api_types.py index e124510d..df840f56 100644 --- a/tests/test_api_types.py +++ b/tests/test_api_types.py @@ -14,8 +14,8 @@ (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.CreateAttachmentDict, {"url": "http://example.com"}), + (T.CreateAttachmentById, {"id": "att123"}), + (T.CreateAttachmentByUrl, {"url": "http://example.com"}), (T.CreateRecordDict, {"fields": {}}), (T.RecordDeletedDict, {"deleted": True, "id": fake_id()}), (T.RecordDict, fake_record()), @@ -42,7 +42,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_orm_lists.py b/tests/test_orm_lists.py index bc0545ff..90d11b34 100644 --- a/tests/test_orm_lists.py +++ b/tests/test_orm_lists.py @@ -26,11 +26,21 @@ def mock_upload(): fake_id("fld"): [ { "id": fake_id("att"), - "url": "https://example.com/a.png", + "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: @@ -49,6 +59,14 @@ def test_attachment_upload(mock_upload, tmp_path, 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"], diff --git a/tests/test_typing.py b/tests/test_typing.py index 8578602e..412542e0 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -73,7 +73,14 @@ class Actor(orm.Model): logins = orm.fields.MultipleCollaboratorsField("Logins") assert_type(Actor().name, str) - assert_type(Actor().logins, L.ChangeTrackingList[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") @@ -139,16 +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, L.AttachmentsList) - assert_type(record.attachments[0], T.AttachmentDict) + 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) @@ -161,8 +169,8 @@ 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, L.ChangeTrackingList[T.CollaboratorDict]) - assert_type(record.multi_user[0], T.CollaboratorDict) + 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]]) @@ -174,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) @@ -190,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"}) From 813b5fee6c2d51f9d33b276d7b71caa6b8eecf64 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 19:41:40 -0700 Subject: [PATCH 183/272] Deprecate utils.attachment() --- pyairtable/utils.py | 5 +++++ tests/integration/test_integration_api.py | 10 +++++----- tests/test_utils.py | 13 ++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index c19a01c1..56f605c7 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 ( @@ -106,6 +107,10 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentByUrl: """ + warnings.warn( + "attachment(url, filename) is deprecated; use {'url': url, 'filename': filename} instead.", + DeprecationWarning, + ) return {"url": url} if not filename else {"url": url, "filename": filename} diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 5a254a56..c55c03fc 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -7,7 +7,7 @@ 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] @@ -279,7 +279,7 @@ 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") @@ -288,8 +288,8 @@ def test_integration_attachment_multiple(table, cols, valid_img_url): rec = table.create( { cols.ATTACHMENT: [ - attachment(valid_img_url, filename="a.png"), - attachment(valid_img_url, filename="b.png"), + {"url": valid_img_url, "filename": "a.png"}, + {"url": valid_img_url, "filename": "b.png"}, ] } ) @@ -299,7 +299,7 @@ def test_integration_attachment_multiple(table, cols, valid_img_url): def test_integration_upload_attachment(table, cols, valid_img_url, tmp_path): - rec = table.create({cols.ATTACHMENT: [attachment(valid_img_url, filename="a.png")]}) + 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 == { diff --git a/tests/test_utils.py b/tests/test_utils.py index f1d8f42f..b18e28d8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -39,11 +39,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( From 869408c0c2601c221ec4bd454e2c9493f01311b2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 21:46:17 -0700 Subject: [PATCH 184/272] Fix documentation for 'Upload Attachment' --- docs/source/changelog.rst | 4 +- docs/source/migrations.rst | 54 +++++++++++-------- docs/source/orm.rst | 21 +++----- pyairtable/api/table.py | 50 +++++++++++++++-- pyairtable/api/types.py | 21 ++++++++ pyairtable/orm/fields.py | 5 +- pyairtable/orm/lists.py | 19 +++++-- .../test_integration_enterprise.py | 2 +- 8 files changed, 132 insertions(+), 44 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 7834362a..8e9f285e 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -43,7 +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 `_. +* Added support for `Upload attachment `_ + via :meth:`Table.upload_attachment ` + or :meth:`AttachmentsList.upload `. 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index fa6e9d94..9e541108 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -65,28 +65,40 @@ 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`. +* :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)`` -: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: - -.. 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)`` Miscellaneous name changes --------------------------------------------- diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 0859ff43..63ac1b31 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -583,8 +583,8 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi >>> comment.delete() -Attachments ------------------- +Attachments in the ORM +---------------------- When retrieving attachments from the API, pyAirtable will return a list of :class:`~pyairtable.api.types.AttachmentDict`. @@ -602,23 +602,16 @@ When retrieving attachments from the API, pyAirtable will return a list of ... ] -You can append your own values to this list, and as long as they conform -to :class:`~pyairtable.api.types.CreateAttachmentDict`, they will be saved -back to the API. +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.fields.AttachmentList.upload` to -directly upload content to Airtable. You do not need to call -:meth:`~pyairtable.orm.Model.save`; the change will be saved immediately. -Note that this means any other unsaved changes to this field will be lost. +You can also use :meth:`~pyairtable.orm.lists.AttachmentsList.upload` to +directly upload content to Airtable: - >>> model.attachments.upload("example.jpg", b"...", "image/jpeg") - >>> model.attachments[-1]["filename"] - 'example.jpg' - >>> model.attachments[-1]["url"] - 'https://v5.airtableusercontent.com/...' +.. automethod:: pyairtable.orm.lists.AttachmentsList.upload ORM Limitations diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index e9faafcf..687958cd 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -662,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 `__. @@ -676,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: @@ -704,7 +714,41 @@ def upload_attachment( 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() diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index af2a0089..dcbd89c6 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -367,6 +367,27 @@ class UserAndScopesDict(TypedDict, total=False): 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]] diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index db919819..27e00d9e 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -919,7 +919,10 @@ class AttachmentsField( list_class=AttachmentsList, contains_type=dict, ): - pass + """ + Accepts a list of dicts in the format detailed in + `Attachments `_. + """ class BarcodeField(_DictField[BarcodeDict]): diff --git a/pyairtable/orm/lists.py b/pyairtable/orm/lists.py index f32f5470..f0360632 100644 --- a/pyairtable/orm/lists.py +++ b/pyairtable/orm/lists.py @@ -105,9 +105,22 @@ def upload( content_type: Optional[str] = None, ) -> None: """ - Upload an attachment to the Airtable API. This will replace the current - list with the response from the server, which will contain a full list of - :class:`~pyairtable.api.types.AttachmentDict`. + 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") 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"}, From 7bf14ca72ed9e085f9f5de803e60f017f34ea29f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 8 Sep 2024 22:36:47 -0700 Subject: [PATCH 185/272] Improve documentation of MockAirtable.passthrough --- pyairtable/testing.py | 63 ++++++++++++++++++++++++++-- tests/test_testing__mock_airtable.py | 21 ++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 3f00eed8..41ee6912 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -7,11 +7,13 @@ import random import string from collections import defaultdict -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager +from functools import partialmethod from typing import ( Any, Dict, Iterable, + Iterator, List, Optional, Sequence, @@ -162,10 +164,12 @@ class MockAirtable: from pyairtable import Api from pyairtable.testing import MockAirtable + table = Api.base("baseId").table("tableName") + with MockAirtable() as m: m.add_record("baseId", "tableName", {"Name": "Alice"}) - records = t.all() - assert len(t.all()) == 1 + records = table.all() + assert len(table.all()) == 1 If you use pytest, you might want to include this as a fixture. @@ -191,10 +195,27 @@ def test_your_function(): Traceback (most recent call last): ... RuntimeError: unhandled call to Api.request - This behavior can be overridden by setting the ``passthrough`` argument to True, + You can allow unhandled requests by setting the ``passthrough`` argument to True, either on the constructor or temporarily on the MockAirtable instance. This is useful when using another library, like `requests-mock `_, to prepare responses for complex cases (like code that retrieves the schema). + + .. code-block:: python + + def test_your_function(requests_mock, mock_airtable, monkeypatch): + base = Api.base("baseId") + + # load and cache our mock schema + requests_mock.get( + base.meta_url("tables"), + json={"tables": [...]} + ) + with mock_airtable.enable_passthrough(): + base.schema() + + # code below will fail if any more unhandled requests are made + ... + """ # The list of APIs that are mocked by this class. @@ -255,6 +276,40 @@ def __exit__(self, *exc_info: Any) -> None: if self._stack: self._stack.__exit__(*exc_info) + @contextmanager + def set_passthrough(self, allowed: bool) -> Iterator[Self]: + """ + Context manager that temporarily changes whether unmocked methods + are allowed to perform real network requests. For convenience, there are + also shortcuts ``enable_passthrough()`` and ``disable_passthrough()``. + + Usage: + + .. code-block:: python + + with MockAirtable() as m: + with m.enable_passthrough(): + schema = base.schema() + hooks = table.webhooks() + + # no more network requests allowed + ... + + Args: + allowed: If ``True``, unmocked methods will be allowed to perform real + network requests within this context manager. If ``False``, + they will not be allowed. + """ + original = self.passthrough + self.passthrough = allowed + try: + yield self + finally: + self.passthrough = original + + enable_passthrough = partialmethod(set_passthrough, True) + disable_passthrough = partialmethod(set_passthrough, False) + @overload def add_records( self, diff --git a/tests/test_testing__mock_airtable.py b/tests/test_testing__mock_airtable.py index 0ee0b132..767a4081 100644 --- a/tests/test_testing__mock_airtable.py +++ b/tests/test_testing__mock_airtable.py @@ -190,7 +190,7 @@ def test_table_batch_upsert(mock_airtable, table): "table.schema()", ], ) -def test_unhandled_methods(mock_airtable, expr, api, base, table): +def test_unhandled_methods(mock_airtable, monkeypatch, expr, api, base, table): """ Test that unhandled methods raise an error. """ @@ -203,5 +203,20 @@ def test_passthrough(mock_airtable, requests_mock, base, monkeypatch): Test that we can temporarily pass through unhandled methods to the requests library. """ requests_mock.get(base.meta_url("tables"), json={"tables": []}) - monkeypatch.setattr(mock_airtable, "passthrough", True) - assert base.schema().tables == [] # no RuntimeError + + with monkeypatch.context() as mctx: + mctx.setattr(mock_airtable, "passthrough", True) + assert base.schema(force=True).tables == [] # no RuntimeError + + with mock_airtable.enable_passthrough(): + assert base.schema(force=True).tables == [] # no RuntimeError + with mock_airtable.disable_passthrough(): + with pytest.raises(RuntimeError): + base.schema(force=True) + + with mock_airtable.set_passthrough(True): + assert base.schema(force=True).tables == [] # no RuntimeError + + with mock_airtable.set_passthrough(False): + with pytest.raises(RuntimeError): + base.schema(force=True) From 9d42d08cdb5ca4b5ca8653b5c9a4a1c804ea99d0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 9 Sep 2024 22:50:25 -0700 Subject: [PATCH 186/272] Improve documentation of MockAirtable.add_records --- docs/source/_substitutions.rst | 3 +++ pyairtable/api/base.py | 3 +-- pyairtable/testing.py | 30 +++++++++++++++++++++--------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index 290da7d7..17d99735 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -2,6 +2,9 @@ .. |arg_record_id| replace:: An Airtable record ID. +.. |arg_table_id_or_name| replace:: An Airtable table ID or name. + Table name should be unencoded, as shown on browser. + .. |kwarg_view| replace:: The name or ID of a view. If set, only the records in that view will be returned. The records will be sorted according to the order of the view. diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index e850cc3d..67bee396 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -107,8 +107,7 @@ def table( Build a new :class:`Table` instance using this instance of :class:`Base`. Args: - id_or_name: An Airtable table ID or name. Table name should be unencoded, - as shown on browser. + id_or_name: |arg_table_id_or_name| validate: |kwarg_validate_metadata| force: |kwarg_force_metadata| diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 41ee6912..60cf7f95 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -314,10 +314,10 @@ def set_passthrough(self, allowed: bool) -> Iterator[Self]: def add_records( self, base_id: str, - table_name: str, + table_id_or_name: str, /, records: Iterable[Dict[str, Any]], - ) -> None: ... + ) -> List[RecordDict]: ... @overload def add_records( @@ -325,9 +325,9 @@ def add_records( table: Table, /, records: Iterable[Dict[str, Any]], - ) -> None: ... + ) -> List[RecordDict]: ... - def add_records(self, *args: Any, **kwargs: Any) -> None: + def add_records(self, *args: Any, **kwargs: Any) -> List[RecordDict]: """ Add a list of records to the mock Airtable instance. @@ -340,15 +340,27 @@ def add_records(self, *args: Any, **kwargs: Any) -> None: with MockAirtable() as m: m.add_records(table, records=[{"id": "recFake", {"Name": "Alice"}}]) + + Args: + base_id: |arg_base_id| + *This must be the first positional argument.* + table_id_or_name: |arg_table_id_or_name| + This should be the same ID or name used in the code under test. + *This must be the second positional argument.* + table: An instance of :class:`~pyairtable.Table`. + *This is an alternative to providing base and table IDs, + and must be the first positional argument.* + records: A sequence of :class:`~pyairtable.api.types.RecordDict`, + :class:`~pyairtable.api.types.UpdateRecordDict`, + :class:`~pyairtable.api.types.CreateRecordDict`, + or :class:`~pyairtable.api.types.Fields`. """ base_id, table_name, records = _extract_args(args, kwargs, ["records"]) + coerced = [coerce_fake_record(record) for record in records] self.records[(base_id, table_name)].update( - { - coerced["id"]: coerced - for record in records - if (coerced := coerce_fake_record(record)) - } + {record["id"]: record for record in coerced} ) + return coerced def clear(self) -> None: """ From 57abe20b605919ced39fc73d13c3de3573b9fef6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 13 Sep 2024 18:30:37 -0700 Subject: [PATCH 187/272] A couple fields missed in #352 --- pyairtable/models/comment.py | 2 +- pyairtable/models/webhook.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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: From 356ba1431b4c55ec0531086df4230f982ca3dfc2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 14 Sep 2024 16:15:13 -0700 Subject: [PATCH 188/272] Fail `tox -e coverage` if under 100% --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From cc10c08122e2fa79c00132bee95fb37ce8bfe0ed Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 14 Sep 2024 21:34:02 -0700 Subject: [PATCH 189/272] Test coverage for model str->datetime conversion --- pyairtable/models/schema.py | 1 + tests/conftest.py | 11 +++++-- tests/sample_data/AuditLogResponse.json | 39 +++++++++++++++++++++++++ tests/sample_data/Comment.json | 18 ++++++++++++ tests/sample_data/Webhook.json | 2 +- tests/test_models.py | 33 +++++++++++++++++++++ tests/test_models_comment.py | 24 ++++----------- 7 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 tests/sample_data/AuditLogResponse.json create mode 100644 tests/sample_data/Comment.json 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/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/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_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): From 6a4060366a32aed6cc7c78002521ad5efc9748e0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 15 Sep 2024 10:27:52 -0700 Subject: [PATCH 190/272] Update docs for MockAirtable --- docs/source/tables.rst | 11 ++++++ pyairtable/testing.py | 81 +++++++++++++++++++++++++++++++----------- tests/test_testing.py | 6 ++-- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/docs/source/tables.rst b/docs/source/tables.rst index 84ec8cd5..670120a0 100644 --- a/docs/source/tables.rst +++ b/docs/source/tables.rst @@ -299,3 +299,14 @@ and :meth:`~pyairtable.Table.add_comment` methods will return instances of >>> table.comments("recMNxslc6jG0XedV")[0].text 'Never mind!' >>> comment.delete() + +Testing Your Code +----------------- + +pyAirtable provides a :class:`~pyairtable.testing.MockAirtable` class that can be used to +test your code without making real requests to Airtable. + +.. autoclass:: pyairtable.testing.MockAirtable + :noindex: + +For more information, see :mod:`pyairtable.testing`. diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 60cf7f95..5f53214a 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -1,5 +1,8 @@ """ -Helper functions for writing tests that use the pyairtable library. +pyAirtable provides a number of helper functions for testing code that uses +the Airtable API. These functions are designed to be used with the standard +Python :mod:`unittest.mock` library, and can be used to create fake records, +users, and attachments, as well as to mock the Airtable API itself. """ import datetime @@ -105,16 +108,25 @@ def fake_record( Generate a fake record dict with the given field values. >>> fake_record({"Name": "Alice"}) - {'id': '...', 'createdTime': '...', 'fields': {'Name': 'Alice'}} - - >>> fake_record(name="Alice", address="123 Fake St") - {'id': '...', 'createdTime': '...', 'fields': {'name': 'Alice', 'address': '123 Fake St'}} + { + 'id': '...', + 'createdTime': '...', + 'fields': {'name': 'Alice'} + } >>> fake_record(name="Alice", id="123") - {'id': 'rec00000000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}} + { + 'id': 'rec00000000000123', + 'createdTime': '...', + 'fields': {'name': 'Alice'} + } >>> fake_record(name="Alice", id="recABC00000000123") - {'id': 'recABC00000000123', 'createdTime': '...', 'fields': {'name': 'Alice'}} + { + 'id': 'recABC00000000123', + 'createdTime': '...', + 'fields': {'name': 'Alice'} + } """ return { "id": str(id) if is_airtable_id(id, "rec") else fake_id(value=id), @@ -127,25 +139,47 @@ def fake_user(value: Any = None) -> CollaboratorDict: """ Generate a fake user dict with the given value for an email prefix. - >>> fake_user("alice") - {'id': 'usr000000000Alice', 'email': 'alice@example.com', 'name': 'Fake User'} + >>> fake_user("Alice") + { + 'id': 'usr000000000Alice', + 'email': 'alice@example.com' + 'name': 'Alice' + } """ id = fake_id("usr", value) return { "id": id, - "email": f"{value or id}@example.com", - "name": "Fake User", + "email": f"{str(value or id).lower()}@example.com", + "name": str(value or "Fake User"), } -def fake_attachment() -> AttachmentDict: +def fake_attachment(url: str = "", filename: str = "") -> AttachmentDict: """ Generate a fake attachment dict. + + >>> fake_attachment() + { + 'id': 'att...', + 'url': 'https://example.com/', + 'filename': 'foo.txt', + 'size': 100, + 'type': 'text/plain', + } + + >>> fake_attachment('https://example.com/image.png', 'foo.png') + { + 'id': 'att...', + 'url': 'https://example.com/image.png', + 'filename': 'foo.png', + 'size': 100, + 'type': 'text/plain', + } """ return { "id": fake_id("att"), - "url": "https://example.com/", - "filename": "foo.txt", + "url": url or "https://example.com/", + "filename": filename or "foo.txt", "size": 100, "type": "text/plain", } @@ -329,17 +363,24 @@ def add_records( def add_records(self, *args: Any, **kwargs: Any) -> List[RecordDict]: """ - Add a list of records to the mock Airtable instance. + Add a list of records to the mock Airtable instance. These will be returned + from methods like :meth:`~pyairtable.Table.all` and :meth:`~pyairtable.Table.get`. - Can be called with either a base ID and table name, or an instance of :class:`~pyairtable.Table`. + Can be called with either a base ID and table name, + or an instance of :class:`~pyairtable.Table`: .. code-block:: - with MockAirtable() as m: - m.add_records("baseId", "tableName", [{"Name": "Alice"}]) + m = MockAirtable() + m.add_records("baseId", "tableName", [{"Name": "Alice"}]) + m.add_records(table, records=[{"id": "recFake", {"Name": "Alice"}}]) - with MockAirtable() as m: - m.add_records(table, records=[{"id": "recFake", {"Name": "Alice"}}]) + .. note:: + + The parameters to :meth:`~pyairtable.Table.all` are not supported by MockAirtable, + and constraints like ``formula=`` and ``limit=`` will be ignored. It is assumed + that you are adding records to specifically test a particular use case. + MockAirtable is not a full in-memory replacement for the Airtable API. Args: base_id: |arg_base_id| diff --git a/tests/test_testing.py b/tests/test_testing.py index 475770b3..81aa2b0d 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -36,11 +36,11 @@ ), ( "fake_user", - call("alice"), + call("Alice"), { - "id": "usr000000000alice", + "id": "usr000000000Alice", "email": "alice@example.com", - "name": "Fake User", + "name": "Alice", }, ), ], From 958f7da655c261b2dffe2d8d8da0e0efe20f269d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 16 Sep 2024 08:23:01 -0700 Subject: [PATCH 191/272] Fix lingering docstring typo --- pyairtable/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index d2f9bc1b..32684fe0 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -88,7 +88,7 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict: Usage: >>> table = Table(...) - >>> profile_url = "https://myprofile.com/id/profile.jpg + >>> profile_url = "https://example.com/profile.jpg" >>> rec = table.create({"Profile Photo": [attachment(profile_url)]}) { 'id': 'recZXOZ5gT9vVGHfL', @@ -96,8 +96,8 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict: 'attachment': [ { 'id': 'attu6kbaST3wUuNTA', - 'url': 'https://aws1.discourse-cdn.com/airtable/original/2X/4/411e4fac00df06a5e316a0585a831549e11d0705.png', - 'filename': '411e4fac00df06a5e316a0585a831549e11d0705.png' + 'url': 'https://content.airtable.com/...', + 'filename': 'profile.jpg' } ] }, From d0000c525e13fc7c191053ccc42df06d814fe460 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 16 Sep 2024 08:42:34 -0700 Subject: [PATCH 192/272] Note the breaking change to CreateAttachmentDict --- docs/source/migrations.rst | 7 ++++++- pyairtable/api/types.py | 12 ++++++++++++ tests/test_api_types.py | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 9e541108..79444bd4 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -99,8 +99,13 @@ Changes to the ORM in 3.0 * - ``Model._get_meta(name)`` - ``Model.meta.get(name)`` +Breaking type changes +--------------------------------------------- + +* ``pyairtable.api.types.CreateAttachmentDict`` is now a ``Union`` instead of a ``TypedDict``, + which may change some type checking behavior in code that uses it. -Miscellaneous name changes +Breaking name changes --------------------------------------------- * - | ``pyairtable.api.enterprise.ClaimUsersResponse`` diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index dcbd89c6..2753a603 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -440,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) diff --git a/tests/test_api_types.py b/tests/test_api_types.py index df840f56..ede5a0d4 100644 --- a/tests/test_api_types.py +++ b/tests/test_api_types.py @@ -16,6 +16,8 @@ (T.CollaboratorDict, fake_user()), (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()}), (T.RecordDict, fake_record()), @@ -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", [ From 440491a00ff7bb7b58a9b5bcea8b5533f1da3d2f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 16 Sep 2024 17:03:20 -0700 Subject: [PATCH 193/272] Fix flaky integration tests --- pyairtable/orm/model.py | 1 + pyairtable/utils.py | 1 + tests/integration/test_integration_api.py | 9 ++++++++- tests/integration/test_integration_orm.py | 4 ++-- 4 files changed, 12 insertions(+), 3 deletions(-) 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 56f605c7..510630fc 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -110,6 +110,7 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentByUrl: 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} diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index c55c03fc..66656f46 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -281,7 +281,14 @@ def test_integration_formula_composition(table: Table, cols): def test_integration_attachment(table, cols, 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): diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 65577683..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() From 753c8e9fb861f941eb00c3526125c42aca6639b9 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 17 Sep 2024 07:05:57 -0700 Subject: [PATCH 194/272] Clean up pyairtable.testing tests --- pyairtable/testing.py | 9 +++- tests/test_testing.py | 113 +++++++++++++++++++++++++----------------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 5f53214a..227c44bd 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -7,6 +7,7 @@ import datetime import inspect +import mimetypes import random import string from collections import defaultdict @@ -27,6 +28,7 @@ ) from unittest import mock +import urllib3 from typing_extensions import Self, TypeAlias from pyairtable.api import retrying @@ -176,12 +178,15 @@ def fake_attachment(url: str = "", filename: str = "") -> AttachmentDict: 'type': 'text/plain', } """ + if not filename: + filename = (urllib3.util.parse_url(url).path or "").split("/")[-1] + filename = filename or "foo.txt" return { "id": fake_id("att"), "url": url or "https://example.com/", - "filename": filename or "foo.txt", + "filename": filename, "size": 100, - "type": "text/plain", + "type": mimetypes.guess_type(filename)[0] or "text/plain", } diff --git a/tests/test_testing.py b/tests/test_testing.py index 81aa2b0d..7400b0a0 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,53 +1,74 @@ -from unittest.mock import ANY, call - -import pytest +import re +from unittest.mock import ANY from pyairtable import testing as T -@pytest.mark.parametrize( - "funcname,sig,expected", - [ - ("fake_id", call(value=123), "rec00000000000123"), - ("fake_id", call("tbl", "x"), "tbl0000000000000x"), - ( - "fake_record", - call(id=123), - {"id": "rec00000000000123", "createdTime": ANY, "fields": {}}, - ), - ( - "fake_record", - call(id="recABC00000000123"), - {"id": "recABC00000000123", "createdTime": ANY, "fields": {}}, - ), - ( - "fake_record", - call({"A": 1}, 123), - {"id": "rec00000000000123", "createdTime": ANY, "fields": {"A": 1}}, - ), - ( - "fake_record", - call(one=1, two=2), - { - "id": ANY, - "createdTime": ANY, - "fields": {"one": 1, "two": 2}, - }, - ), - ( - "fake_user", - call("Alice"), - { - "id": "usr000000000Alice", - "email": "alice@example.com", - "name": "Alice", - }, - ), - ], -) -def test_fake_function(funcname, sig, expected): - func = getattr(T, funcname) - assert func(*sig.args, **sig.kwargs) == expected +def test_fake_id(): + assert re.match(r"rec[a-zA-Z0-9]{14}", T.fake_id()) + assert T.fake_id(value=123) == "rec00000000000123" + assert T.fake_id("tbl", "x") == "tbl0000000000000x" + + +def test_fake_record(): + assert T.fake_record(id=123) == { + "id": "rec00000000000123", + "createdTime": ANY, + "fields": {}, + } + assert T.fake_record(id="recABC00000000123") == { + "id": "recABC00000000123", + "createdTime": ANY, + "fields": {}, + } + assert T.fake_record({"A": 1}, 123) == { + "id": "rec00000000000123", + "createdTime": ANY, + "fields": {"A": 1}, + } + assert T.fake_record(one=1, two=2) == { + "id": ANY, + "createdTime": ANY, + "fields": {"one": 1, "two": 2}, + } + + +def test_fake_user(): + user = T.fake_user() + assert user == { + "id": ANY, + "email": f"{user['id'].lower()}@example.com", + "name": "Fake User", + } + assert T.fake_user("Alice") == { + "id": "usr000000000Alice", + "email": "alice@example.com", + "name": "Alice", + } + + +def test_fake_attachment(): + assert T.fake_attachment() == { + "id": ANY, + "url": "https://example.com/", + "filename": "foo.txt", + "size": 100, + "type": "text/plain", + } + assert T.fake_attachment(url="https://example.com/image.png") == { + "id": ANY, + "url": "https://example.com/image.png", + "filename": "image.png", + "size": 100, + "type": "image/png", + } + assert T.fake_attachment(url="https://example.com", filename="image.png") == { + "id": ANY, + "url": "https://example.com", + "filename": "image.png", + "size": 100, + "type": "image/png", + } def test_coerce_fake_record(): From ba3a1241682284fa1b5205ead7f77a32f2cb6dc2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 17 Sep 2024 07:06:34 -0700 Subject: [PATCH 195/272] MockAirtable.set_records --- pyairtable/testing.py | 40 +++++++++++++ tests/test_testing__mock_airtable.py | 88 +++++++++++++++++++++------- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 227c44bd..86529eaf 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -408,6 +408,46 @@ def add_records(self, *args: Any, **kwargs: Any) -> List[RecordDict]: ) return coerced + @overload + def set_records( + self, + base_id: str, + table_id_or_name: str, + /, + records: Iterable[Dict[str, Any]], + ) -> None: ... + + @overload + def set_records( + self, + table: Table, + /, + records: Iterable[Dict[str, Any]], + ) -> None: ... + + def set_records(self, *args: Any, **kwargs: Any) -> None: + """ + Set the mock records for a particular base and table, replacing any existing records. + See :meth:`~MockAirtable.add_records` for more information. + + Args: + base_id: |arg_base_id| + *This must be the first positional argument.* + table_id_or_name: |arg_table_id_or_name| + This should be the same ID or name used in the code under test. + *This must be the second positional argument.* + table: An instance of :class:`~pyairtable.Table`. + *This is an alternative to providing base and table IDs, + and must be the first positional argument.* + records: A sequence of :class:`~pyairtable.api.types.RecordDict`, + :class:`~pyairtable.api.types.UpdateRecordDict`, + :class:`~pyairtable.api.types.CreateRecordDict`, + or :class:`~pyairtable.api.types.Fields`. + """ + base_id, table_name, records = _extract_args(args, kwargs, ["records"]) + self.records[(base_id, table_name)].clear() + self.add_records(base_id, table_name, records=records) + def clear(self) -> None: """ Clear all records from the mock Airtable instance. diff --git a/tests/test_testing__mock_airtable.py b/tests/test_testing__mock_airtable.py index 767a4081..60168198 100644 --- a/tests/test_testing__mock_airtable.py +++ b/tests/test_testing__mock_airtable.py @@ -11,18 +11,6 @@ def mock_airtable(requests_mock): yield m -@pytest.fixture -def mock_records(mock_airtable, table): - mock_records = [T.fake_record() for _ in range(5)] - mock_airtable.add_records(table, mock_records) - return mock_records - - -@pytest.fixture -def mock_record(mock_records): - return mock_records[0] - - def test_not_reentrant(): """ Test that nested MockAirtable contexts raise an error. @@ -44,19 +32,22 @@ def test_multiple_nested_contexts(): pass -def test_add_records__ids(mock_airtable, mock_records, table): - mock_airtable.add_records(table.base.id, table.name, mock_records) - assert table.all() == mock_records +def test_add_records__ids(mock_airtable, table): + fake_records = [T.fake_record() for _ in range(3)] + mock_airtable.add_records(table.base.id, table.name, fake_records) + assert table.all() == fake_records -def test_add_records__ids_kwarg(mock_airtable, mock_records, table): - mock_airtable.add_records(table.base.id, table.name, records=mock_records) - assert table.all() == mock_records +def test_add_records__ids_kwarg(mock_airtable, table): + fake_records = [T.fake_record() for _ in range(3)] + mock_airtable.add_records(table.base.id, table.name, records=fake_records) + assert table.all() == fake_records -def test_add_records__kwarg(mock_airtable, mock_records, table): - mock_airtable.add_records(table, records=mock_records) - assert table.all() == mock_records +def test_add_records__kwarg(mock_airtable, table): + fake_records = [T.fake_record() for _ in range(3)] + mock_airtable.add_records(table, records=fake_records) + assert table.all() == fake_records def test_add_records__missing_kwarg(mock_airtable, table): @@ -82,6 +73,42 @@ def test_add_records__invalid_kwarg(mock_airtable, table): mock_airtable.add_records(table, records=[], asdf=1) +@pytest.fixture +def mock_records(mock_airtable, table): + mock_records = [T.fake_record() for _ in range(5)] + mock_airtable.add_records(table, mock_records) + return mock_records + + +@pytest.fixture +def mock_record(mock_records): + return mock_records[0] + + +def test_set_records(mock_airtable, mock_records, table): + replace = [T.fake_record()] + mock_airtable.set_records(table, replace) + assert table.all() == replace + + +def test_set_records__ids(mock_airtable, mock_records, table): + replace = [T.fake_record()] + mock_airtable.set_records(table.base.id, table.name, replace) + assert table.all() == replace + + +def test_set_records__ids_kwarg(mock_airtable, mock_records, table): + replace = [T.fake_record()] + mock_airtable.set_records(table.base.id, table.name, records=replace) + assert table.all() == replace + + +def test_set_records__kwarg(mock_airtable, mock_records, table): + replace = [T.fake_record()] + mock_airtable.set_records(table, records=replace) + assert table.all() == replace + + @pytest.mark.parametrize( "funcname,expected", [ @@ -133,7 +160,9 @@ def test_table_batch_delete(mock_records, table): def test_table_batch_upsert(mock_airtable, table): - # this one is complicated because we actually do the upsert logic + """ + Test that MockAirtable actually performs upsert logic correctly. + """ mock_airtable.clear() mock_airtable.add_records( table, @@ -145,9 +174,13 @@ def test_table_batch_upsert(mock_airtable, table): ) table.batch_upsert( records=[ + # matches by Name to rec001 {"fields": {"Name": "Alice", "Email": "alice@example.com"}}, + # matches by Name to rec002 {"fields": {"Name": "Bob", "Email": "bob@example.com"}}, + # matches by id to rec003 {"id": "rec003", "fields": {"Email": "carol@example.com"}}, + # no match; will create the record {"fields": {"Name": "Dave", "Email": "dave@example.com"}}, ], key_fields=["Name"], @@ -176,6 +209,17 @@ def test_table_batch_upsert(mock_airtable, table): ] +def test_table_batch_upsert__invalid_id(mock_airtable, table): + with pytest.raises(KeyError): + table.batch_upsert( + records=[ + # record does not exist + {"id": "rec999", "fields": {"Name": "Alice"}} + ], + key_fields=["Name"], + ) + + @pytest.mark.parametrize( "expr", [ From 2e1a6fca843d6366bc9c3ad01f0057d3b2175cb4 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 13 Oct 2024 19:47:20 -0400 Subject: [PATCH 196/272] Fix docs typo --- pyairtable/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/testing.py b/pyairtable/testing.py index 86529eaf..b3b0da4b 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -206,7 +206,7 @@ class MockAirtable: table = Api.base("baseId").table("tableName") with MockAirtable() as m: - m.add_record("baseId", "tableName", {"Name": "Alice"}) + m.add_records(table, [{"Name": "Alice"}]) records = table.all() assert len(table.all()) == 1 From 4a0a98db29953e1147c01e2286a07f9e6eb5d029 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 21 Oct 2024 13:00:03 -0700 Subject: [PATCH 197/272] Add Python 3.13 to test suite --- .github/workflows/test_lint_deploy.yml | 2 +- tox.ini | 34 ++++++++++++++------------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml index cc89d891..d5b53ee2 100644 --- a/.github/workflows/test_lint_deploy.yml +++ b/.github/workflows/test_lint_deploy.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 diff --git a/tox.ini b/tox.ini index f42c1575..31ed16f4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = pre-commit - mypy-py3{8,9,10,11,12} - py3{8,9,10,11,12}{,-pydantic1,-requestsmin} + mypy-py3{8,9,10,11,12,13} + py3{8,9,10,11,12,13}{,-pydantic1,-requestsmin} integration coverage @@ -13,20 +13,7 @@ python = 3.10: py310, mypy-py310 3.11: py311, mypy-py311 3.12: coverage, mypy-py312 - -[testenv:pre-commit] -deps = pre-commit -commands = pre-commit run --all-files - -[testenv:mypy,mypy-py3{8,9,10,11,12}] -basepython = - py38: python3.8 - py39: python3.9 - py310: python3.10 - py311: python3.11 - py312: python3.12 -deps = -r requirements-dev.txt -commands = mypy --strict pyairtable tests/test_typing.py + 3.13: py313, mypy-py313 [testenv] passenv = @@ -41,6 +28,21 @@ deps = requestsmin: requests==2.22.0 # Keep in sync with setup.cfg pydantic1: pydantic<2 # Lots of projects still use 1.x +[testenv:pre-commit] +deps = pre-commit +commands = pre-commit run --all-files + +[testenv:mypy,mypy-py3{8,9,10,11,12,13}] +basepython = + py38: python3.8 + py39: python3.9 + py310: python3.10 + py311: python3.11 + py312: python3.12 + py313: python3.13 +deps = -r requirements-dev.txt +commands = mypy --strict pyairtable tests/test_typing.py + [testenv:integration] commands = python -m pytest -m integration From 7ddbfe3762a1eece2c38d36ec12086243cadc939 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 21 Oct 2024 14:01:41 -0700 Subject: [PATCH 198/272] =?UTF-8?q?Remove=20underscore=5Fattrs=5Fare=5Fpri?= =?UTF-8?q?vate,=20it=20breaks=203.13=20=C2=AF\=5F(=E3=83=84)=5F/=C2=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyairtable/models/_base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 64b1f105..6428e21c 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -29,9 +29,6 @@ class Config: # Allow both base_invite_links= and baseInviteLinks= in constructor allow_population_by_field_name = True - # We'll assume this in a couple different places - underscore_attrs_are_private = True - _raw: Any = pydantic.PrivateAttr() def __init__(self, **data: Any) -> None: @@ -138,7 +135,7 @@ class RestfulModel(AirtableModel): _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() _url: str = pydantic.PrivateAttr(default="") - _url_context: Any = None + _url_context: Any = pydantic.PrivateAttr(default=None) def __init_subclass__(cls, **kwargs: Any) -> None: cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) From f0a7127d4daf14732d8eeaeefc26b684a8caf565 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 21 Oct 2024 15:39:30 -0700 Subject: [PATCH 199/272] Add changelog for 2.3.4 --- docs/source/changelog.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e70024b0..947ff422 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -43,10 +43,19 @@ Changelog * Changed the return type of :meth:`Model.save ` from ``bool`` to :class:`~pyairtable.orm.SaveResult`. - `PR #387 `_ +* Added :class:`pyairtable.testing.MockAirtable` for easier testing. + - `PR #388 `_ * Added support for `Upload attachment `_ via :meth:`Table.upload_attachment ` or :meth:`AttachmentsList.upload `. -* Added :class:`pyairtable.testing.MockAirtable` for easier testing. + - `PR #389 `_ + +2.3.4 (2024-10-21) +------------------------ + +* Fixed a crash at import time under Python 3.13. + - `PR #395 `_, + `PR #396 `_. 2.3.3 (2024-03-22) ------------------------ From 526c5295227deeb483b7820a3aa3cc47b29f7bb0 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 21 Oct 2024 16:25:28 -0700 Subject: [PATCH 200/272] Drop 3.8 support --- .github/workflows/test_lint_deploy.yml | 6 +- .readthedocs.yaml | 2 +- docs/source/changelog.rst | 83 +++++++++++++------------- pyproject.toml | 2 +- requirements-dev.txt | 5 ++ setup.cfg | 2 +- tox.ini | 10 ++-- 7 files changed, 57 insertions(+), 53 deletions(-) diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml index d5b53ee2..33170263 100644 --- a/.github/workflows/test_lint_deploy.yml +++ b/.github/workflows/test_lint_deploy.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 @@ -56,10 +56,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - name: Install pypa/build run: >- diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5101a10e..f1a8858f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,7 +4,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/source/conf.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 947ff422..57498878 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -49,35 +49,36 @@ Changelog via :meth:`Table.upload_attachment ` or :meth:`AttachmentsList.upload `. - `PR #389 `_ +* Dropped support for Python 3.8. + - `PR #395 `_ 2.3.4 (2024-10-21) ------------------------ * Fixed a crash at import time under Python 3.13. - - `PR #395 `_, - `PR #396 `_. + `PR #396 `_ 2.3.3 (2024-03-22) ------------------------ * Fixed a bug affecting ORM Meta values which are computed at runtime. - - `PR #357 `_. + - `PR #357 `_ * Fixed documentation for the ORM module. - - `PR #356 `_. + - `PR #356 `_ 2.3.2 (2024-03-18) ------------------------ * Fixed a bug affecting :func:`pyairtable.metadata.get_table_schema`. - - `PR #349 `_. + - `PR #349 `_ 2.3.1 (2024-03-14) ------------------------ * Fixed a bug affecting how timezones are parsed by :class:`~pyairtable.orm.fields.DatetimeField`. - - `PR #342 `_. + - `PR #342 `_ * Fixed a bug affecting :meth:`~pyairtable.Base.create_table`. - - `PR #345 `_. + - `PR #345 `_ 2.3.0 (2024-02-25) ------------------------ @@ -86,10 +87,10 @@ Changelog Read more in :ref:`Migrating from 2.2 to 2.3`. * Added support for :ref:`managing permissions and shares` and :ref:`managing users`. - - `PR #337 `_. + - `PR #337 `_ * Added :meth:`Enterprise.audit_log ` to iterate page-by-page through `audit log events `__. - - `PR #330 `_. + - `PR #330 `_ * :meth:`Api.base `, :meth:`Api.table `, and :meth:`Base.table ` @@ -97,7 +98,7 @@ Changelog unless the caller passes a new keyword argument ``force=True``. This allows callers to validate the IDs/names of many bases or tables at once without having to perform expensive network overhead each time. - - `PR #336 `_. + - `PR #336 `_ 2.2.2 (2024-01-28) ------------------------ @@ -105,7 +106,7 @@ Changelog * Enterprise methods :meth:`~pyairtable.Enterprise.user`, :meth:`~pyairtable.Enterprise.users`, and :meth:`~pyairtable.Enterprise.group` now return collaborations by default. - - `PR #332 `_. + - `PR #332 `_ * Added more helper functions for formulas: :func:`~pyairtable.formulas.LESS`, :func:`~pyairtable.formulas.LESS_EQUAL`, @@ -113,36 +114,36 @@ Changelog :func:`~pyairtable.formulas.GREATER_EQUAL`, and :func:`~pyairtable.formulas.NOT_EQUAL`. - - `PR #323 `_. + - `PR #323 `_ 2.2.1 (2023-11-28) ------------------------ * :meth:`~pyairtable.Table.update` now accepts ``return_fields_by_field_id=True`` - - `PR #320 `_. + - `PR #320 `_ 2.2.0 (2023-11-13) ------------------------ * Fixed a bug in how webhook notification signatures are validated - - `PR #312 `_. + - `PR #312 `_ * Added support for reading and modifying :doc:`metadata` - - `PR #311 `_. + - `PR #311 `_ * Added support for the 'AI Text' field type - - `PR #310 `_. + - `PR #310 `_ * Batch methods can now accept generators or iterators, not just lists - - `PR #308 `_. + - `PR #308 `_ * Fixed a few documentation errors - `PR #301 `_, - `PR #306 `_. + `PR #306 `_ 2.1.0 (2023-08-18) ------------------------ * Added classes and methods for managing :ref:`webhooks`. - - `PR #291 `_. + - `PR #291 `_ * Added compatibility with Pydantic 2.0. - - `PR #288 `_. + - `PR #288 `_ 2.0.0 (2023-07-31) ------------------------ @@ -150,55 +151,55 @@ Changelog See :ref:`Migrating from 1.x to 2.0` for detailed migration notes. * Added :class:`~pyairtable.models.Comment` class; see :ref:`Commenting on Records`. - - `PR #282 `_. + - `PR #282 `_ * :meth:`~pyairtable.Table.batch_upsert` now returns the full payload from the Airtable API. - - `PR #281 `_. + - `PR #281 `_ * :ref:`ORM` module is no longer experimental and has a stable API. - - `PR #277 `_. + - `PR #277 `_ * Added :meth:`Model.batch_save ` and :meth:`Model.batch_delete `. - - `PR #274 `_. + - `PR #274 `_ * Added :meth:`Api.whoami ` method. - - `PR #273 `_. + - `PR #273 `_ * pyAirtable will automatically retry requests when throttled by Airtable's QPS. - - `PR #272 `_. + - `PR #272 `_ * ORM Meta attributes can now be defined as callables. - - `PR #268 `_. + - `PR #268 `_ * Removed ``ApiAbstract``. - - `PR #267 `_. + - `PR #267 `_ * Implemented strict type annotations on all functions and methods. - - `PR #263 `_. + - `PR #263 `_ * Return Model instances, not dicts, from :meth:`Model.all ` and :meth:`Model.first `. - - `PR #262 `_. + - `PR #262 `_ * Dropped support for Python 3.7. - - `PR #261 `_. + - `PR #261 `_ * :ref:`ORM` supports all Airtable field types. - - `PR #260 `_. + - `PR #260 `_ 1.5.0 (2023-05-15) ------------------------- * Add support for Airtable's upsert operation (see :ref:`Updating Records`). - - `PR #255 `_. + - `PR #255 `_ * Fix ``return_fields_by_field_id`` in :meth:`~pyairtable.Api.batch_create` and :meth:`~pyairtable.Api.batch_update`. - - `PR #252 `_. + - `PR #252 `_ * Fix ORM crash when Airtable returned additional fields. - - `PR #250 `_. + - `PR #250 `_ * Use POST for URLs that are longer than the 16k character limit set by the Airtable API. - - `PR #247 `_. + - `PR #247 `_ * Added ``endpoint_url=`` param to :class:`~pyairtable.Table`, :class:`~pyairtable.Base`, :class:`~pyairtable.Api`. - - `PR #243 `_. + - `PR #243 `_ * Added ORM :class:`~pyairtable.orm.fields.LookupField`. - - `PR #182 `_. + - `PR #182 `_ * Dropped support for Python 3.6 (reached end of life 2021-12-23) - - `PR #213 `_. + - `PR #213 `_ 1.4.0 (2022-12-14) ------------------------- * Added :func:`pyairtable.retry_strategy`. -* Misc fix in sleep for batch requests `PR #180 `_. +* Misc fix in sleep for batch requests `PR #180 `_ 1.3.0 (2022-08-23) ------------------------- @@ -210,7 +211,7 @@ See :ref:`Migrating from 1.x to 2.0` for detailed migration notes. 1.2.0 (2022-07-09) ------------------------- -* Fixed missing rate limit in :meth:`~pyairtable.Api.batch_update` - `PR #162 `_. +* Fixed missing rate limit in :meth:`~pyairtable.Api.batch_update` - `PR #162 `_ * Added support for new parameter `return_fields_by_field_id` - `PR #161 `_. See updated :ref:`Parameters`. * Added new ``OR`` formula - `PR #148 `_. See :mod:`pyairtable.formulas`. diff --git a/pyproject.toml b/pyproject.toml index 97b1b0d9..c4a55065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,5 @@ build-backend = "setuptools.build_meta" [tool.black] line-length = 88 -target-version = ['py38'] +target-version = ['py39'] include = '\.pyi?$' diff --git a/requirements-dev.txt b/requirements-dev.txt index bc59bf2a..c833c46c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,11 @@ sphinxext-opengraph revitron-sphinx-theme @ git+https://github.com/gtalarico/revitron-sphinx-theme.git@40f4b09fa5c199e3844153ef973a1155a56981dd sphinx-autodoc-typehints autodoc-pydantic<2 +sphinxcontrib-applehelp==1.0.4 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 # Packaging wheel==0.38.1 diff --git a/setup.cfg b/setup.cfg index f1d65a84..4ea54259 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,11 +18,11 @@ classifiers = Intended Audience :: Developers License :: OSI Approved :: MIT License Programming Language :: Python - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Programming Language :: Python :: Implementation :: CPython Topic :: Software Development diff --git a/tox.ini b/tox.ini index 31ed16f4..b39a2f04 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,13 @@ [tox] envlist = pre-commit - mypy-py3{8,9,10,11,12,13} - py3{8,9,10,11,12,13}{,-pydantic1,-requestsmin} + mypy-py3{9,10,11,12,13} + py3{9,10,11,12,13}{,-pydantic1,-requestsmin} integration coverage [gh-actions] python = - 3.8: py38, mypy-py38 3.9: py39, mypy-py39 3.10: py310, mypy-py310 3.11: py311, mypy-py311 @@ -32,9 +31,8 @@ deps = deps = pre-commit commands = pre-commit run --all-files -[testenv:mypy,mypy-py3{8,9,10,11,12,13}] +[testenv:mypy,mypy-py3{9,10,11,12,13}] basepython = - py38: python3.8 py39: python3.9 py310: python3.10 py311: python3.11 @@ -53,7 +51,7 @@ commands = python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} --cov-fail-under=100 [testenv:docs] -basepython = python3.8 +basepython = python3.9 deps = -r requirements-dev.txt commands = From 655bc23df77d79bfea13f781fbd74d43c2e02986 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 10 Sep 2024 18:25:02 -0700 Subject: [PATCH 201/272] pydantic v2: bump-pydantic and fix mypy errors --- pyairtable/_compat.py | 11 +---------- pyairtable/models/_base.py | 32 ++++++++++++++++---------------- pyairtable/models/schema.py | 4 ++-- setup.cfg | 2 +- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py index b59654b3..914dc03a 100644 --- a/pyairtable/_compat.py +++ b/pyairtable/_compat.py @@ -1,12 +1,3 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: # mypy really does not like this conditional import. - import pydantic as pydantic -else: - # Pydantic v2 broke a bunch of stuff. Luckily they provide a built-in v1. - try: - import pydantic.v1 as pydantic - except ImportError: # pragma: no cover - import pydantic +import pydantic __all__ = ["pydantic"] diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 6428e21c..605debf7 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -3,6 +3,7 @@ from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union import inflection +from pydantic import ConfigDict from typing_extensions import Self as SelfType from pyairtable._compat import pydantic @@ -18,31 +19,30 @@ class AirtableModel(pydantic.BaseModel): Base model for any data structures that will be loaded from the Airtable API. """ - class Config: - # Ignore field names we don't recognize, so applications don't crash - # if Airtable decides to add new attributes. - extra = "ignore" - - # Convert e.g. "base_invite_links" to "baseInviteLinks" for (de)serialization - alias_generator = partial(inflection.camelize, uppercase_first_letter=False) - - # Allow both base_invite_links= and baseInviteLinks= in constructor - allow_population_by_field_name = True + model_config = ConfigDict( + extra="ignore", + alias_generator=partial(inflection.camelize, uppercase_first_letter=False), + populate_by_name=True, + ) _raw: Any = pydantic.PrivateAttr() def __init__(self, **data: Any) -> None: - self._raw = data.copy() + raw = data.copy() + # Convert JSON-serializable input data to the types expected by our model. # For now this only converts ISO 8601 strings to datetime objects. - for field_model in self.__fields__.values(): - for name in {field_model.name, field_model.alias}: - if not (value := data.get(name)): + for field_name, field_model in self.model_fields.items(): + for name in {field_name, field_model.alias}: + if not name or not (value := data.get(name)): continue - if isinstance(value, str) and field_model.type_ is datetime: + if isinstance(value, str) and field_model.annotation is datetime: data[name] = datetime_from_iso_str(value) + super().__init__(**data) + self._raw = raw # must happen *after* __init__ + @classmethod def from_api( cls, @@ -166,7 +166,7 @@ def _reload(self, obj: Optional[Dict[str, Any]] = None) -> None: obj = self._api.get(self._url) copyable = type(self).from_api(obj, self._api, context=self._url_context) self.__dict__.update( - {key: copyable.__dict__.get(key) for key in type(self).__fields__} + {key: copyable.__dict__.get(key) for key in type(self).model_fields} ) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 159f21da..03dcec5f 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -569,7 +569,7 @@ class UserInfo( is_admin: bool = False is_super_admin: bool = False groups: List[NestedId] = _FL() - collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) + collaborations: "Collaborations" = _F("Collaborations") def logout(self) -> None: self._api.post(self._url + "/logout") @@ -588,7 +588,7 @@ class UserGroup(AirtableModel): created_time: datetime updated_time: datetime members: List["UserGroup.Member"] - collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations) + collaborations: "Collaborations" = _F("Collaborations") class Member(AirtableModel): user_id: str diff --git a/setup.cfg b/setup.cfg index 4ea54259..240f5d92 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,7 +31,7 @@ classifiers = packages = find: install_requires = inflection - pydantic + pydantic >= 2, < 3 requests >= 2.22.0 typing_extensions urllib3 >= 1.26 From 163f313438545034672cb4c197095f8963579b35 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 13 Sep 2024 18:28:37 -0700 Subject: [PATCH 202/272] pydantic v2: assert_typed_dict, fix missing defaults --- pyairtable/api/types.py | 10 ++++---- pyairtable/models/_base.py | 5 ++-- pyairtable/models/schema.py | 38 +++++++++++++++--------------- pyairtable/models/webhook.py | 32 ++++++++++++------------- tests/sample_data/BaseSchema.json | 3 +++ tests/sample_data/TableSchema.json | 3 +++ 6 files changed, 48 insertions(+), 43 deletions(-) diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 81807057..2a4b5b93 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -6,6 +6,7 @@ from functools import lru_cache from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast +from pydantic import TypeAdapter from typing_extensions import Required, TypeAlias, TypedDict from pyairtable._compat import pydantic @@ -397,13 +398,12 @@ class UploadAttachmentResultDict(TypedDict): @lru_cache -def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]: +def _create_model_from_typeddict(cls: Type[T]) -> TypeAdapter[Any]: """ Create a pydantic model from a TypedDict to use as a validator. Memoizes the result so we don't have to call this more than once per class. """ - # Mypy can't tell that we are using pydantic v1. - return pydantic.create_model_from_typeddict(cls) # type: ignore[no-any-return, operator, unused-ignore] + return TypeAdapter(cls) def assert_typed_dict(cls: Type[T], obj: Any) -> T: @@ -456,8 +456,8 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T: 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) + model = _create_model_from_typeddict(cls) # type: ignore[arg-type] + model.validate_python(obj) return cast(T, obj) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 605debf7..48e20d48 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -248,7 +248,7 @@ def save(self) -> None: raise RuntimeError("save() called with no URL specified") include = set(self.__writable) if self.__writable else None exclude = set(self.__readonly) if self.__readonly else None - data = self.dict( + data = self.model_dump( by_alias=True, include=include, exclude=exclude, @@ -264,8 +264,7 @@ def save(self) -> None: def __setattr__(self, name: str, value: Any) -> None: # Prevents implementers from changing values on readonly or non-writable fields. - # Mypy can't tell that we are using pydantic v1. - if name in self.__class__.__fields__: # type: ignore[operator, unused-ignore] + if name in self.__class__.model_fields: if self.__readonly and name in self.__readonly: raise AttributeError(name) if self.__writable is not None and name not in self.__writable: diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 03dcec5f..0f1d3495 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -310,7 +310,7 @@ class TableSchema( id: str name: str primary_field_id: str - description: Optional[str] + description: Optional[str] = None fields: List["FieldSchema"] views: List["ViewSchema"] @@ -345,8 +345,8 @@ class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): id: str type: str name: str - personal_for_user_id: Optional[str] - visible_field_ids: Optional[List[str]] + personal_for_user_id: Optional[str] = None + visible_field_ids: Optional[List[str]] = None class GroupCollaborator(AirtableModel): @@ -383,7 +383,7 @@ class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"): id: str type: str created_time: datetime - invited_email: Optional[str] + invited_email: Optional[str] = None referred_by_user_id: str permission_level: str restricted_to_email_domains: List[str] = _FL() @@ -561,10 +561,10 @@ class UserInfo( state: str is_sso_required: bool is_two_factor_auth_enabled: bool - last_activity_time: Optional[datetime] - created_time: Optional[datetime] - enterprise_user_type: Optional[str] - invited_to_airtable_by_user_id: Optional[str] + last_activity_time: Optional[datetime] = None + created_time: Optional[datetime] = None + enterprise_user_type: Optional[str] = None + invited_to_airtable_by_user_id: Optional[str] = None is_managed: bool = False is_admin: bool = False is_super_admin: bool = False @@ -617,8 +617,8 @@ class AITextFieldConfig(AirtableModel): class AITextFieldOptions(AirtableModel): - prompt: Optional[List[Union[str, "AITextFieldOptions.PromptField"]]] - referenced_field_ids: Optional[List[str]] + prompt: List[Union[str, "AITextFieldOptions.PromptField"]] = _FL() + referenced_field_ids: List[str] = _FL() class PromptField(AirtableModel): field: NestedFieldId @@ -673,7 +673,7 @@ class CountFieldConfig(AirtableModel): class CountFieldOptions(AirtableModel): is_valid: bool - record_link_field_id: Optional[str] + record_link_field_id: Optional[str] = None class CreatedByFieldConfig(AirtableModel): @@ -862,9 +862,9 @@ class MultipleLookupValuesFieldConfig(AirtableModel): class MultipleLookupValuesFieldOptions(AirtableModel): - field_id_in_linked_table: Optional[str] + field_id_in_linked_table: Optional[str] = None is_valid: bool - record_link_field_id: Optional[str] + record_link_field_id: Optional[str] = None result: Optional["FieldConfig"] @@ -881,8 +881,8 @@ class MultipleRecordLinksFieldOptions(AirtableModel): is_reversed: bool linked_table_id: str prefers_single_record_link: bool - inverse_link_field_id: Optional[str] - view_id_for_record_selection: Optional[str] + inverse_link_field_id: Optional[str] = None + view_id_for_record_selection: Optional[str] = None class MultipleSelectsFieldConfig(AirtableModel): @@ -957,9 +957,9 @@ class RollupFieldConfig(AirtableModel): class RollupFieldOptions(AirtableModel): - field_id_in_linked_table: Optional[str] + field_id_in_linked_table: Optional[str] = None is_valid: bool - record_link_field_id: Optional[str] + record_link_field_id: Optional[str] = None referenced_field_ids: Optional[List[str]] result: Optional["FieldConfig"] @@ -995,7 +995,7 @@ class SingleSelectFieldOptions(AirtableModel): class Choice(AirtableModel): id: str name: str - color: Optional[str] + color: Optional[str] = None class UrlFieldConfig(AirtableModel): @@ -1024,7 +1024,7 @@ class _FieldSchemaBase( ): id: str name: str - description: Optional[str] + description: Optional[str] = None # This section is auto-generated so that FieldSchema and FieldConfig are kept aligned. diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index d2f3d35f..f0313615 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[datetime] - notification_url: Optional[str] - last_notification_result: Optional["WebhookNotificationResult"] - expiration_time: Optional[datetime] + last_successful_notification_time: Optional[datetime] = None + notification_url: Optional[str] = None + last_notification_result: Optional["WebhookNotificationResult"] = None + expiration_time: Optional[datetime] = None specification: "WebhookSpecification" def enable_notifications(self) -> None: @@ -258,19 +258,19 @@ class WebhookSpecification(AirtableModel): class Options(AirtableModel): filters: "WebhookSpecification.Filters" - includes: Optional["WebhookSpecification.Includes"] + includes: Optional["WebhookSpecification.Includes"] = None class Filters(AirtableModel): data_types: List[str] - record_change_scope: Optional[str] + record_change_scope: Optional[str] = None change_types: List[str] = FL() from_sources: List[str] = FL() - source_options: Optional["WebhookSpecification.SourceOptions"] + source_options: Optional["WebhookSpecification.SourceOptions"] = None watch_data_in_field_ids: List[str] = FL() watch_schemas_of_field_ids: List[str] = FL() class SourceOptions(AirtableModel): - form_submission: Optional["WebhookSpecification.FormSubmission"] + form_submission: Optional["WebhookSpecification.FormSubmission"] = None class FormSubmission(AirtableModel): view_id: str @@ -282,7 +282,7 @@ class Includes(AirtableModel): class CreateWebhook(AirtableModel): - notification_url: Optional[str] + notification_url: Optional[str] = None specification: WebhookSpecification @@ -301,7 +301,7 @@ class CreateWebhookResponse(AirtableModel): mac_secret_base64: str #: The timestamp when the webhook will expire and be deleted. - expiration_time: Optional[datetime] + expiration_time: Optional[datetime] = None class WebhookPayload(AirtableModel): @@ -313,16 +313,16 @@ class WebhookPayload(AirtableModel): timestamp: datetime base_transaction_number: int payload_format: str - action_metadata: Optional["WebhookPayload.ActionMetadata"] + action_metadata: Optional["WebhookPayload.ActionMetadata"] = None changed_tables_by_id: Dict[str, "WebhookPayload.TableChanged"] = FD() created_tables_by_id: Dict[str, "WebhookPayload.TableCreated"] = FD() destroyed_table_ids: List[str] = FL() - error: Optional[bool] - error_code: Optional[str] = pydantic.Field(alias="code") + error: Optional[bool] = None + error_code: Optional[str] = pydantic.Field(alias="code", default=None) #: This is not a part of Airtable's webhook payload specification. #: This indicates the cursor field in the response which provided this payload. - cursor: Optional[int] + cursor: Optional[int] = None class ActionMetadata(AirtableModel): source: str @@ -333,8 +333,8 @@ class TableInfo(AirtableModel): description: Optional[str] = None class FieldInfo(AirtableModel): - name: Optional[str] - type: Optional[str] + name: Optional[str] = None + type: Optional[str] = None class FieldChanged(AirtableModel): current: "WebhookPayload.FieldInfo" diff --git a/tests/sample_data/BaseSchema.json b/tests/sample_data/BaseSchema.json index 993cae6a..5577acee 100644 --- a/tests/sample_data/BaseSchema.json +++ b/tests/sample_data/BaseSchema.json @@ -12,6 +12,9 @@ { "id": "fldoaIqdn5szURHpw", "name": "Pictures", + "options": { + "isReversed": false + }, "type": "multipleAttachments" }, { diff --git a/tests/sample_data/TableSchema.json b/tests/sample_data/TableSchema.json index ddeeb3c1..c91024e5 100644 --- a/tests/sample_data/TableSchema.json +++ b/tests/sample_data/TableSchema.json @@ -10,6 +10,9 @@ { "id": "fldoaIqdn5szURHpw", "name": "Pictures", + "options": { + "isReversed": false + }, "type": "multipleAttachments" }, { From 4468aa3f725f12015ff1acdf8dc8a8a1decb2455 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 14:55:12 -0700 Subject: [PATCH 203/272] pydantic v2: fix remaining missing defaults --- pyairtable/models/audit.py | 4 ++-- pyairtable/models/collaborator.py | 4 ++-- pyairtable/models/comment.py | 2 +- pyairtable/models/schema.py | 24 ++++++++++++------------ 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index 69e34992..c5536911 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -18,8 +18,8 @@ class AuditLogResponse(AirtableModel): pagination: Optional["AuditLogResponse.Pagination"] = None class Pagination(AirtableModel): - next: Optional[str] - previous: Optional[str] + next: Optional[str] = None + previous: Optional[str] = None class AuditLogEvent(AirtableModel): diff --git a/pyairtable/models/collaborator.py b/pyairtable/models/collaborator.py index a55c0332..ab0eb550 100644 --- a/pyairtable/models/collaborator.py +++ b/pyairtable/models/collaborator.py @@ -20,7 +20,7 @@ class Collaborator(AirtableModel): id: UserId #: The email address of the user. - email: Optional[str] + email: Optional[str] = None #: The display name of the user. - name: Optional[str] + name: Optional[str] = None diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index af5c53af..2f0ab149 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[datetime] + last_updated_time: Optional[datetime] = None #: The account which created the comment. author: Collaborator diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 0f1d3495..d4f4d105 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -24,10 +24,10 @@ def _F(classname: str, **kwargs: Any) -> Any: def _create_default_from_classname() -> Any: this_module = importlib.import_module(__name__) - obj = this_module + obj: Any = this_module for segment in classname.split("."): obj = getattr(obj, segment) - return obj + return obj() kwargs["default_factory"] = _create_default_from_classname return pydantic.Field(**kwargs) @@ -180,7 +180,7 @@ class InterfaceCollaborators( url="meta/bases/{base.id}/interfaces/{key}", ): created_time: datetime - first_publish_time: Optional[datetime] + first_publish_time: Optional[datetime] = None group_collaborators: List["GroupCollaborator"] = _FL() individual_collaborators: List["IndividualCollaborator"] = _FL() invite_links: List["InterfaceInviteLink"] = _FL() @@ -784,8 +784,8 @@ class FormulaFieldConfig(AirtableModel): class FormulaFieldOptions(AirtableModel): formula: str is_valid: bool - referenced_field_ids: Optional[List[str]] - result: Optional["FieldConfig"] + referenced_field_ids: Optional[List[str]] = None + result: Optional["FieldConfig"] = None class LastModifiedByFieldConfig(AirtableModel): @@ -807,8 +807,8 @@ class LastModifiedTimeFieldConfig(AirtableModel): class LastModifiedTimeFieldOptions(AirtableModel): is_valid: bool - referenced_field_ids: Optional[List[str]] - result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] + referenced_field_ids: Optional[List[str]] = None + result: Optional[Union["DateFieldConfig", "DateTimeFieldConfig"]] = None class ManualSortFieldConfig(AirtableModel): @@ -862,10 +862,10 @@ class MultipleLookupValuesFieldConfig(AirtableModel): class MultipleLookupValuesFieldOptions(AirtableModel): - field_id_in_linked_table: Optional[str] = None is_valid: bool + field_id_in_linked_table: Optional[str] = None record_link_field_id: Optional[str] = None - result: Optional["FieldConfig"] + result: Optional["FieldConfig"] = None class MultipleRecordLinksFieldConfig(AirtableModel): @@ -960,8 +960,8 @@ class RollupFieldOptions(AirtableModel): field_id_in_linked_table: Optional[str] = None is_valid: bool record_link_field_id: Optional[str] = None - referenced_field_ids: Optional[List[str]] - result: Optional["FieldConfig"] + referenced_field_ids: Optional[List[str]] = None + result: Optional["FieldConfig"] = None class SingleCollaboratorFieldConfig(AirtableModel): @@ -1013,7 +1013,7 @@ class UnknownFieldConfig(AirtableModel): """ type: str - options: Optional[Dict[str, Any]] + options: Optional[Dict[str, Any]] = None class _FieldSchemaBase( From 54849c686d3a5f49eca7b006d11ab6455eb91fd2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:00:49 -0700 Subject: [PATCH 204/272] pydantic v2: parse_obj => model_validate --- pyairtable/api/enterprise.py | 4 ++-- pyairtable/models/schema.py | 2 +- pyairtable/models/webhook.py | 2 +- tests/conftest.py | 2 +- tests/test_api_table.py | 2 +- tests/test_models_collaborator.py | 2 +- tests/test_models_comment.py | 4 ++-- tests/test_models_schema.py | 8 ++++---- tests/test_models_webhook.py | 4 ++-- tests/test_orm_generate.py | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 5dee6727..75f55e33 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -53,7 +53,7 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup: params = {"include": ["collaborations"] if collaborations else []} url = self.api.build_url(f"meta/groups/{group_id}") payload = self.api.get(url, params=params) - return UserGroup.parse_obj(payload) + return UserGroup.model_validate(payload) def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: """ @@ -219,7 +219,7 @@ def handle_event(event): offset_field=offset_field, ) for count, response in enumerate(iter_requests, start=1): - parsed = AuditLogResponse.parse_obj(response) + parsed = AuditLogResponse.model_validate(response) yield parsed if not parsed.events: return diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index d4f4d105..2064d6ac 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1367,7 +1367,7 @@ def parse_field_schema(obj: Dict[str, Any]) -> FieldSchema: Given a ``dict`` representing a field schema, parse it into the appropriate FieldSchema subclass. """ - return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema + return _HasFieldSchema.model_validate({"field_schema": obj}).field_schema update_forward_refs(vars()) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index f0313615..bb5fb5c4 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -237,7 +237,7 @@ def from_request( expected = "hmac-sha256=" + hmac.hexdigest() if header != expected: raise ValueError("X-Airtable-Content-MAC header failed validation") - return cls.parse_raw(body) + return cls.model_validate_json(body) class WebhookNotificationResult(AirtableModel): diff --git a/tests/conftest.py b/tests/conftest.py index 1567a2dd..9fcc5978 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -179,7 +179,7 @@ def _get_schema_obj(name: str, *, context: Any = None) -> Any: if context: obj = obj_cls.from_api(obj_data, api, context=context) else: - obj = obj_cls.parse_obj(obj_data) + obj = obj_cls.model_validate(obj_data) if obj_path: obj = eval(f"obj.{obj_path}", None, {"obj": obj}) diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 4a4f9d4f..8e9277f0 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -17,7 +17,7 @@ @pytest.fixture() def table_schema(sample_json, api, base) -> TableSchema: - return TableSchema.parse_obj(sample_json("TableSchema")) + return TableSchema.model_validate(sample_json("TableSchema")) @pytest.fixture diff --git a/tests/test_models_collaborator.py b/tests/test_models_collaborator.py index a1630475..4f47ab2e 100644 --- a/tests/test_models_collaborator.py +++ b/tests/test_models_collaborator.py @@ -10,7 +10,7 @@ def test_parse(): - user = Collaborator.parse_obj(fake_user_data) + user = Collaborator.model_validate(fake_user_data) assert user.id == fake_user_data["id"] assert user.email == fake_user_data["email"] assert user.name == fake_user_data["name"] diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 54698104..2f597472 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -26,7 +26,7 @@ def comments_url(base, table): def test_parse(comment_json): - c = Comment.parse_obj(comment_json) + c = Comment.model_validate(comment_json) assert isinstance(c.created_time, datetime.datetime) assert isinstance(c.last_updated_time, datetime.datetime) @@ -37,7 +37,7 @@ def test_missing_attributes(comment_json): """ del comment_json["lastUpdatedTime"] del comment_json["mentioned"] - comment = Comment.parse_obj(comment_json) + comment = Comment.model_validate(comment_json) assert comment.mentioned == {} assert comment.last_updated_time is None diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 250f0cf7..9c1aebcc 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -21,12 +21,12 @@ ) def test_parse(sample_json, clsname): cls = attrgetter(clsname)(schema) - cls.parse_obj(sample_json(clsname)) + cls.model_validate(sample_json(clsname)) @pytest.mark.parametrize("cls", schema.FieldSchema.__args__) def test_parse_field(sample_json, cls): - cls.parse_obj(sample_json("field_schema/" + cls.__name__)) + cls.model_validate(sample_json("field_schema/" + cls.__name__)) @pytest.mark.parametrize( @@ -44,7 +44,7 @@ def test_parse_field(sample_json, cls): ) def test_find_in_collection(clsname, method, id_or_name, sample_json): cls = attrgetter(clsname)(schema) - obj = cls.parse_obj(sample_json(clsname)) + obj = cls.model_validate(sample_json(clsname)) assert getattr(obj, method)(id_or_name) @@ -97,7 +97,7 @@ def test_find(): and skips any models that are marked as deleted. """ - collection = Outer.parse_obj( + collection = Outer.model_validate( { "inners": [ {"id": "0001", "name": "One"}, diff --git a/tests/test_models_webhook.py b/tests/test_models_webhook.py index 5a5572a0..6e9d2f20 100644 --- a/tests/test_models_webhook.py +++ b/tests/test_models_webhook.py @@ -31,7 +31,7 @@ def payload_json(sample_json): ) def test_parse(sample_json, clsname): cls = attrgetter(clsname)(pyairtable.models.webhook) - cls.parse_obj(sample_json(clsname)) + cls.model_validate(sample_json(clsname)) @pytest.mark.parametrize( @@ -67,7 +67,7 @@ def test_delete(webhook: Webhook, requests_mock): def test_error_payload(payload_json): payload_json.update({"error": True, "code": "INVALID_HOOK"}) - payload = WebhookPayload.parse_obj(payload_json) + payload = WebhookPayload.model_validate(payload_json) assert payload.error is True assert payload.error_code == "INVALID_HOOK" diff --git a/tests/test_orm_generate.py b/tests/test_orm_generate.py index 1446bce3..6692516e 100644 --- a/tests/test_orm_generate.py +++ b/tests/test_orm_generate.py @@ -61,7 +61,7 @@ def test_lookup_field_type_annotation(result_schema, expected): "type": "multipleLookupValues", "options": {"isValid": True, "result": result_schema}, } - obj = schema.MultipleLookupValuesFieldSchema.parse_obj(struct) + obj = schema.MultipleLookupValuesFieldSchema.model_validate(struct) assert generate.lookup_field_type_annotation(obj) == expected From 79a85f145a414be662df79a2bc4c95d1b47e197f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:04:39 -0700 Subject: [PATCH 205/272] pydantic v2: update a handful of new names --- pyairtable/api/base.py | 2 +- pyairtable/models/_base.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 67bee396..263428ad 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -279,7 +279,7 @@ def add_webhook( spec = WebhookSpecification.from_api(spec, self.api) create = CreateWebhook(notification_url=notify_url, specification=spec) - request = create.dict(by_alias=True, exclude_unset=True) + request = create.model_dump(by_alias=True, exclude_unset=True) response = self.api.post(self.webhooks_url, json=request) return CreateWebhookResponse.from_api(response, self.api) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 48e20d48..6f2ff8d0 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -117,7 +117,7 @@ def cascade_api( obj._set_api(api, context=context) # Find and apply API/context to nested models in every Pydantic field. - for field_name in type(obj).__fields__: + for field_name in type(obj).model_fields: if field_value := getattr(obj, field_name, None): cascade_api(field_value, api, context=context) @@ -300,7 +300,7 @@ def update_forward_refs( if id(obj) in memo: return memo.add(id(obj)) - obj.update_forward_refs() + obj.model_rebuild() return update_forward_refs(vars(obj), memo=memo) # If it's a mapping, update refs for any AirtableModel instances. for value in obj.values(): From 022f4fc560cd6d2be898e64708ad1cca51ddb638 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:34:49 -0700 Subject: [PATCH 206/272] pydantic v2: fix namespace conflict (model_*) --- docs/source/migrations.rst | 4 ++++ pyairtable/models/audit.py | 9 +++++++-- pyairtable/models/webhook.py | 10 +++++----- tests/test_api_enterprise.py | 4 ++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 79444bd4..62704422 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -118,6 +118,10 @@ Breaking name changes | has become :class:`pyairtable.exceptions.MissingValueError` * - | ``pyairtable.orm.fields.MultipleValues`` | has become :class:`pyairtable.exceptions.MultipleValuesError` + * - | ``pyairtable.models.AuditLogEvent.model_id`` + | has become :data:`pyairtable.models.AuditLogEvent.object_id` + * - | ``pyairtable.models.AuditLogEvent.model_type`` + | has become :data:`pyairtable.models.AuditLogEvent.object_type` Migrating from 2.2 to 2.3 diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index c5536911..0777c5e6 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -3,6 +3,7 @@ from typing_extensions import TypeAlias +from pyairtable._compat import pydantic from pyairtable.models._base import AirtableModel, update_forward_refs @@ -28,14 +29,18 @@ class AuditLogEvent(AirtableModel): See `Audit log events `__ for more information on how to interpret this data structure. + + To avoid namespace conflicts with the Pydantic library, the + ``modelId`` and ``modelType`` fields from the Airtable API are + represented as fields named ``object_id`` and ``object_type``. """ id: str timestamp: datetime action: str actor: "AuditLogActor" - model_id: str - model_type: str + object_id: str = pydantic.Field(alias="modelId") + object_type: str = pydantic.Field(alias="modelType") payload: "AuditLogPayload" payload_version: str context: "AuditLogEvent.Context" diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index bb5fb5c4..90d00ed2 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -338,7 +338,7 @@ class FieldInfo(AirtableModel): class FieldChanged(AirtableModel): current: "WebhookPayload.FieldInfo" - previous: Optional["WebhookPayload.FieldInfo"] + previous: Optional["WebhookPayload.FieldInfo"] = None class TableChanged(AirtableModel): changed_views_by_id: Dict[str, "WebhookPayload.ViewChanged"] = FD() @@ -346,7 +346,7 @@ class TableChanged(AirtableModel): changed_records_by_id: Dict[RecordId, "WebhookPayload.RecordChanged"] = FD() created_fields_by_id: Dict[str, "WebhookPayload.FieldInfo"] = FD() created_records_by_id: Dict[RecordId, "WebhookPayload.RecordCreated"] = FD() - changed_metadata: Optional["WebhookPayload.TableChanged.ChangedMetadata"] + changed_metadata: Optional["WebhookPayload.TableChanged.ChangedMetadata"] = None destroyed_field_ids: List[str] = FL() destroyed_record_ids: List[RecordId] = FL() @@ -360,14 +360,14 @@ class ViewChanged(AirtableModel): destroyed_record_ids: List[RecordId] = FL() class TableCreated(AirtableModel): - metadata: Optional["WebhookPayload.TableInfo"] + metadata: Optional["WebhookPayload.TableInfo"] = None fields_by_id: Dict[str, "WebhookPayload.FieldInfo"] = FD() records_by_id: Dict[RecordId, "WebhookPayload.RecordCreated"] = FD() class RecordChanged(AirtableModel): current: "WebhookPayload.CellValuesByFieldId" - previous: Optional["WebhookPayload.CellValuesByFieldId"] - unchanged: Optional["WebhookPayload.CellValuesByFieldId"] + previous: Optional["WebhookPayload.CellValuesByFieldId"] = None + unchanged: Optional["WebhookPayload.CellValuesByFieldId"] = None class CellValuesByFieldId(AirtableModel): cell_values_by_field_id: Dict[str, Any] diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 7f126735..21aea5c5 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -62,8 +62,8 @@ def fake_audit_log_events(counter, page_size=N_AUDIT_PAGE_SIZE): "timestamp": datetime.datetime.now().isoformat(), "action": "viewBase", "actor": {"type": "anonymousUser"}, - "model_id": (base_id := fake_id("app")), - "model_type": "base", + "modelId": (base_id := fake_id("app")), + "modelType": "base", "payload": {"name": "The Base Name"}, "payloadVersion": "1.0", "context": { From d95e70afc499bd7450cb629a03257774121b1178 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:36:34 -0700 Subject: [PATCH 207/272] pydantic v2: update_forward_refs => model_rebuild --- pyairtable/api/enterprise.py | 4 ++-- pyairtable/models/_base.py | 6 +++--- pyairtable/models/audit.py | 4 ++-- pyairtable/models/comment.py | 4 ++-- pyairtable/models/schema.py | 4 ++-- pyairtable/models/webhook.py | 4 ++-- tests/test_models.py | 6 +++--- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 75f55e33..3f92b221 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union from pyairtable._compat import pydantic -from pyairtable.models._base import AirtableModel, update_forward_refs +from pyairtable.models._base import AirtableModel, rebuild_models from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import ( @@ -405,7 +405,7 @@ class Error(AirtableModel): message: str -update_forward_refs(vars()) +rebuild_models(vars()) # These are at the bottom of the module to avoid circular imports diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 6f2ff8d0..cb2df7e3 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -273,7 +273,7 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) -def update_forward_refs( +def rebuild_models( obj: Union[Type[AirtableModel], Mapping[str, Any]], memo: Optional[Set[int]] = None, ) -> None: @@ -301,11 +301,11 @@ def update_forward_refs( return memo.add(id(obj)) obj.model_rebuild() - return update_forward_refs(vars(obj), memo=memo) + return rebuild_models(vars(obj), memo=memo) # If it's a mapping, update refs for any AirtableModel instances. for value in obj.values(): if isinstance(value, type) and issubclass(value, AirtableModel): - update_forward_refs(value, memo=memo) + rebuild_models(value, memo=memo) import pyairtable.api.api # noqa diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index 0777c5e6..c80313f9 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -4,7 +4,7 @@ from typing_extensions import TypeAlias from pyairtable._compat import pydantic -from pyairtable.models._base import AirtableModel, update_forward_refs +from pyairtable.models._base import AirtableModel, rebuild_models class AuditLogResponse(AirtableModel): @@ -78,4 +78,4 @@ class UserInfo(AirtableModel): AuditLogPayload: TypeAlias = Dict[str, Any] -update_forward_refs(vars()) +rebuild_models(vars()) diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 2f0ab149..fc458228 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -3,7 +3,7 @@ from pyairtable._compat import pydantic -from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs +from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, rebuild_models from .collaborator import Collaborator @@ -88,4 +88,4 @@ class Mentioned(AirtableModel): email: Optional[str] = None -update_forward_refs(vars()) +rebuild_models(vars()) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 2064d6ac..4fc4daef 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -13,7 +13,7 @@ CanDeleteModel, CanUpdateModel, RestfulModel, - update_forward_refs, + rebuild_models, ) _T = TypeVar("_T", bound=Any) @@ -1370,4 +1370,4 @@ def parse_field_schema(obj: Dict[str, Any]) -> FieldSchema: return _HasFieldSchema.model_validate({"field_schema": obj}).field_schema -update_forward_refs(vars()) +rebuild_models(vars()) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 90d00ed2..eb985f05 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -9,7 +9,7 @@ from pyairtable._compat import pydantic from pyairtable.api.types import RecordId -from ._base import AirtableModel, CanDeleteModel, update_forward_refs +from ._base import AirtableModel, CanDeleteModel, rebuild_models # Shortcuts to avoid lots of line wrapping FD: Callable[[], Any] = partial(pydantic.Field, default_factory=dict) @@ -383,4 +383,4 @@ class WebhookPayloads(AirtableModel): payloads: List[WebhookPayload] -update_forward_refs(vars()) +rebuild_models(vars()) diff --git a/tests/test_models.py b/tests/test_models.py index 7f75bd52..e830e616 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,7 @@ CanDeleteModel, CanUpdateModel, RestfulModel, - update_forward_refs, + rebuild_models, ) @@ -132,7 +132,7 @@ class Child(CanUpdateModel, url="foo/{parent.id}/child/{child.id}"): id: int name: str - update_forward_refs(Parent) + rebuild_models(Parent) parent_data = { "id": 1, @@ -248,7 +248,7 @@ class Inner(AirtableModel): Outer.Inner.Outer = Outer # This will cause RecursionError if we're not careful - update_forward_refs(Outer) + rebuild_models(Outer) def test_restfulmodel__set_url(api, base): From c844abc52d45f58dc122440d8ce16169aa00cdbf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:39:21 -0700 Subject: [PATCH 208/272] Remove pydantic v1 from test suite --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index b39a2f04..f25eb585 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = pre-commit mypy-py3{9,10,11,12,13} - py3{9,10,11,12,13}{,-pydantic1,-requestsmin} + py3{9,10,11,12,13}{,-requestsmin} integration coverage @@ -25,7 +25,6 @@ extras = cli deps = -r requirements-test.txt requestsmin: requests==2.22.0 # Keep in sync with setup.cfg - pydantic1: pydantic<2 # Lots of projects still use 1.x [testenv:pre-commit] deps = pre-commit From 9c3161d64ea47cf255c06eb0f5c32deac5a453dc Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 15:44:22 -0700 Subject: [PATCH 209/272] Remove pydantic compatibility shim --- pyairtable/_compat.py | 3 --- pyairtable/api/enterprise.py | 3 ++- pyairtable/api/types.py | 8 +++----- pyairtable/models/_base.py | 5 ++--- pyairtable/models/audit.py | 2 +- pyairtable/models/comment.py | 2 +- pyairtable/models/schema.py | 2 +- pyairtable/models/webhook.py | 2 +- tests/test_api_types.py | 2 +- 9 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 pyairtable/_compat.py diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py deleted file mode 100644 index 914dc03a..00000000 --- a/pyairtable/_compat.py +++ /dev/null @@ -1,3 +0,0 @@ -import pydantic - -__all__ = ["pydantic"] diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 3f92b221..b5b133ab 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,7 +1,8 @@ import datetime from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union -from pyairtable._compat import pydantic +import pydantic + from pyairtable.models._base import AirtableModel, rebuild_models from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 2a4b5b93..3f34b8c1 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -6,11 +6,9 @@ from functools import lru_cache from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast -from pydantic import TypeAdapter +import pydantic from typing_extensions import Required, TypeAlias, TypedDict -from pyairtable._compat import pydantic - T = TypeVar("T") #: An alias for ``str`` used internally for disambiguation. @@ -398,12 +396,12 @@ class UploadAttachmentResultDict(TypedDict): @lru_cache -def _create_model_from_typeddict(cls: Type[T]) -> TypeAdapter[Any]: +def _create_model_from_typeddict(cls: Type[T]) -> pydantic.TypeAdapter[Any]: """ Create a pydantic model from a TypedDict to use as a validator. Memoizes the result so we don't have to call this more than once per class. """ - return TypeAdapter(cls) + return pydantic.TypeAdapter(cls) def assert_typed_dict(cls: Type[T], obj: Any) -> T: diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index cb2df7e3..526723a0 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -3,10 +3,9 @@ from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union import inflection -from pydantic import ConfigDict +import pydantic from typing_extensions import Self as SelfType -from pyairtable._compat import pydantic from pyairtable.utils import ( _append_docstring_text, datetime_from_iso_str, @@ -19,7 +18,7 @@ class AirtableModel(pydantic.BaseModel): Base model for any data structures that will be loaded from the Airtable API. """ - model_config = ConfigDict( + model_config = pydantic.ConfigDict( extra="ignore", alias_generator=partial(inflection.camelize, uppercase_first_letter=False), populate_by_name=True, diff --git a/pyairtable/models/audit.py b/pyairtable/models/audit.py index c80313f9..5f1a1c86 100644 --- a/pyairtable/models/audit.py +++ b/pyairtable/models/audit.py @@ -1,9 +1,9 @@ from datetime import datetime from typing import Any, Dict, List, Optional +import pydantic from typing_extensions import TypeAlias -from pyairtable._compat import pydantic from pyairtable.models._base import AirtableModel, rebuild_models diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index fc458228..e9566320 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Dict, Optional -from pyairtable._compat import pydantic +import pydantic from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, rebuild_models from .collaborator import Collaborator diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 4fc4daef..5bfb1628 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -3,9 +3,9 @@ from functools import partial from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast +import pydantic from typing_extensions import TypeAlias -from pyairtable._compat import pydantic from pyairtable.api.types import AddCollaboratorDict from ._base import ( diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index eb985f05..9a93284c 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -4,9 +4,9 @@ from hmac import HMAC from typing import Any, Callable, Dict, Iterator, List, Optional, Union +import pydantic from typing_extensions import Self as SelfType -from pyairtable._compat import pydantic from pyairtable.api.types import RecordId from ._base import AirtableModel, CanDeleteModel, rebuild_models diff --git a/tests/test_api_types.py b/tests/test_api_types.py index ede5a0d4..2ed38ff2 100644 --- a/tests/test_api_types.py +++ b/tests/test_api_types.py @@ -1,6 +1,6 @@ +import pydantic import pytest -from pyairtable._compat import pydantic from pyairtable.api import types as T from pyairtable.testing import fake_attachment, fake_id, fake_record, fake_user From 1f2ac18fcde1f4bbb32770bfd3d66ff4a1ffbefb Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 17:54:10 -0700 Subject: [PATCH 210/272] Note new pydantic dependency as a breaking change --- docs/source/changelog.rst | 2 ++ docs/source/migrations.rst | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 57498878..680e5c42 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -51,6 +51,8 @@ Changelog - `PR #389 `_ * Dropped support for Python 3.8. - `PR #395 `_ +* Dropped support for Pydantic 1.x. + - `PR #397 `_ 2.3.4 (2024-10-21) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index 62704422..d1c60a21 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -9,7 +9,18 @@ Migration Guide Migrating from 2.x to 3.0 ============================ -In this release we've made a number of breaking changes, summarized below. +The 3.0 release introduces a number of breaking changes, summarized below. + +Updated minimum dependencies +--------------------------------------------- + +* pyAirtable 3.0 is tested on Python 3.9 or higher. It may continue to work on Python 3.8 +for some time, but bug reports related to Python 3.8 compatibility will not be accepted. + +* pyAirtable 3.0 requires Pydantic 2. If your project still uses Pydantic 1, +you will need to continue to use pyAirtable 2.x until you can upgrade Pydantic. +Read the `Pydantic v2 migration guide `__ +for more information. Deprecated metadata module removed --------------------------------------------- From 7258fbfe0c6d0ecffafb88e9de90ace256509415 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 22 Oct 2024 21:21:59 -0700 Subject: [PATCH 211/272] Fix docs for Pydantic v2 --- docs/source/api.rst | 8 +++++--- docs/source/migrations.rst | 9 ++++----- docs/source/webhooks.rst | 4 ---- requirements-dev.txt | 2 +- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 18cfe4e2..7bc3caf0 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -33,6 +33,7 @@ API: pyairtable.api.enterprise .. automodule:: pyairtable.api.enterprise :members: :exclude-members: Enterprise + :inherited-members: BaseModel, AirtableModel API: pyairtable.api.types @@ -61,7 +62,7 @@ API: pyairtable.models .. automodule:: pyairtable.models :members: - :inherited-members: AirtableModel + :inherited-members: BaseModel, AirtableModel API: pyairtable.models.comment @@ -70,7 +71,7 @@ API: pyairtable.models.comment .. automodule:: pyairtable.models.comment :members: :exclude-members: Comment - :inherited-members: AirtableModel + :inherited-members: BaseModel, AirtableModel API: pyairtable.models.schema @@ -78,6 +79,7 @@ API: pyairtable.models.schema .. automodule:: pyairtable.models.schema :members: + :inherited-members: BaseModel, AirtableModel API: pyairtable.models.webhook @@ -86,7 +88,7 @@ API: pyairtable.models.webhook .. automodule:: pyairtable.models.webhook :members: :exclude-members: Webhook, WebhookNotification, WebhookPayload - :inherited-members: AirtableModel + :inherited-members: BaseModel, AirtableModel API: pyairtable.orm diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index d1c60a21..f85d023c 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -15,12 +15,11 @@ Updated minimum dependencies --------------------------------------------- * pyAirtable 3.0 is tested on Python 3.9 or higher. It may continue to work on Python 3.8 -for some time, but bug reports related to Python 3.8 compatibility will not be accepted. - + for some time, but bug reports related to Python 3.8 compatibility will not be accepted. * pyAirtable 3.0 requires Pydantic 2. If your project still uses Pydantic 1, -you will need to continue to use pyAirtable 2.x until you can upgrade Pydantic. -Read the `Pydantic v2 migration guide `__ -for more information. + you will need to continue to use pyAirtable 2.x until you can upgrade Pydantic. + Read the `Pydantic v2 migration guide `__ + for more information. Deprecated metadata module removed --------------------------------------------- diff --git a/docs/source/webhooks.rst b/docs/source/webhooks.rst index 9f6a132d..a3ce77eb 100644 --- a/docs/source/webhooks.rst +++ b/docs/source/webhooks.rst @@ -22,7 +22,3 @@ using a straightforward API within the :class:`~pyairtable.Base` class. .. automethod:: pyairtable.models.Webhook.payloads :noindex: - -.. autoclass:: pyairtable.models.WebhookNotification - :noindex: - :members: from_request diff --git a/requirements-dev.txt b/requirements-dev.txt index c833c46c..67a5783a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,7 +5,7 @@ sphinx-autoapi sphinxext-opengraph revitron-sphinx-theme @ git+https://github.com/gtalarico/revitron-sphinx-theme.git@40f4b09fa5c199e3844153ef973a1155a56981dd sphinx-autodoc-typehints -autodoc-pydantic<2 +autodoc-pydantic>=2 sphinxcontrib-applehelp==1.0.4 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==2.0.1 From 3fe23af13d7050636ba74ffbca37fe06a4b37c10 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 22:48:53 -0700 Subject: [PATCH 212/272] Do not use Session.send() as it skips parts of Session.request --- pyairtable/api/api.py | 43 +++++++++++++++++++++++++++++++++---------- pyairtable/testing.py | 10 +++++----- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index 65c91d36..ec0928aa 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -1,9 +1,7 @@ import posixpath -from functools import partialmethod from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union import requests -from requests import PreparedRequest from requests.sessions import Session from typing_extensions import TypeAlias @@ -274,16 +272,41 @@ def request( json=json, ) - return self._perform_request(prepared) + response = self.session.request( + method=method, + url=url, + params=request_params, + json=json, + ) + return self._process_response(response) - get = partialmethod(request, "GET") - post = partialmethod(request, "POST") - patch = partialmethod(request, "PATCH") - delete = partialmethod(request, "DELETE") + def get(self, url: str, **kwargs: Any) -> Any: + """ + Make a GET request to the Airtable API. + See :meth:`~Api.request` for keyword arguments. + """ + return self.request("GET", url, **kwargs) - def _perform_request(self, prepared: PreparedRequest) -> Any: - response = self.session.send(prepared, timeout=self.timeout) - return self._process_response(response) + def post(self, url: str, **kwargs: Any) -> Any: + """ + Make a POST request to the Airtable API. + See :meth:`~Api.request` for keyword arguments. + """ + return self.request("POST", url, **kwargs) + + def patch(self, url: str, **kwargs: Any) -> Any: + """ + Make a PATCH request to the Airtable API. + See :meth:`~Api.request` for keyword arguments. + """ + return self.request("PATCH", url, **kwargs) + + def delete(self, url: str, **kwargs: Any) -> Any: + """ + Make a DELETE request to the Airtable API. + See :meth:`~Api.request` for keyword arguments. + """ + return self.request("DELETE", url, **kwargs) def _process_response(self, response: requests.Response) -> Any: try: diff --git a/pyairtable/testing.py b/pyairtable/testing.py index b3b0da4b..4f198e82 100644 --- a/pyairtable/testing.py +++ b/pyairtable/testing.py @@ -259,7 +259,7 @@ def test_your_function(requests_mock, mock_airtable, monkeypatch): # The list of APIs that are mocked by this class. mocked = [ - "Api._perform_request", + "Api.request", "Table.iterate", "Table.get", "Table.create", @@ -294,7 +294,7 @@ def _reset(self) -> None: def __enter__(self) -> Self: if self._stack: raise RuntimeError("MockAirtable is not reentrant") - if hasattr(Api._perform_request, "mock"): + if hasattr(Api.request, "mock"): raise RuntimeError("MockAirtable cannot be nested") self._reset() self._stack = ExitStack() @@ -456,11 +456,11 @@ def clear(self) -> None: # side effects - def _api__perform_request(self, method: str, url: str, **kwargs: Any) -> Any: + def _api_request(self, api: Api, method: str, url: str, **kwargs: Any) -> Any: if not self.passthrough: raise RuntimeError("unhandled call to Api.request") - mocked = self._mocks["Api._perform_request"] - return mocked.temp_original(method, url, **kwargs) + mocked = self._mocks["Api.request"] + return mocked.temp_original(api, method, url, **kwargs) def _table_iterate(self, table: Table, **options: Any) -> List[List[RecordDict]]: return [list(self.records[(table.base.id, table.name)].values())] From 101bd52f0ff16f0afbb4d24173a594d136d94d36 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:10:10 -0700 Subject: [PATCH 213/272] Url and UrlBuilder classes --- pyairtable/utils.py | 223 ++++++++++++++++++++++++++++++++++++++++---- tests/test_utils.py | 85 +++++++++++++++++ 2 files changed, 291 insertions(+), 17 deletions(-) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 6b12744a..4b6e66fb 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -1,12 +1,15 @@ import inspect import re import textwrap +import urllib.parse import warnings from datetime import date, datetime from functools import partial, wraps from typing import ( + TYPE_CHECKING, Any, Callable, + Dict, Generic, Iterable, Iterator, @@ -19,10 +22,14 @@ ) import requests -from typing_extensions import ParamSpec, Protocol +from typing_extensions import ParamSpec, Protocol, Self from pyairtable.api.types import AnyRecordDict, CreateAttachmentByUrl, FieldValue +if TYPE_CHECKING: + from pyairtable.api.api import Api + + P = ParamSpec("P") R = TypeVar("R", covariant=True) T = TypeVar("T") @@ -180,8 +187,9 @@ def _decorated(*args: Any, **kwargs: Any) -> Any: return _decorated # type: ignore[return-value] -def _prepend_docstring_text(obj: Any, text: str) -> None: - if not (doc := obj.__doc__): +def _prepend_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> None: + doc = obj.__doc__ or "" + if skip_empty and not doc: return doc = doc.lstrip("\n") if has_leading_spaces := re.match(r"^\s+", doc): @@ -189,8 +197,9 @@ def _prepend_docstring_text(obj: Any, text: str) -> None: obj.__doc__ = f"{text}\n\n{doc}" -def _append_docstring_text(obj: Any, text: str) -> None: - if not (doc := obj.__doc__): +def _append_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> None: + doc = obj.__doc__ or "" + if skip_empty and not doc: return doc = doc.rstrip("\n") if has_leading_spaces := re.match(r"^\s+", doc): @@ -311,17 +320,196 @@ def _getter(record: AnyRecordDict) -> Any: 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]]] +class Url(str): + """ + Wrapper for ``str`` that adds Path-like syntax for extending + URL components and adding query params. + + >>> url = Url('http://example.com') + >>> url + Url('http://example.com') + >>> url / 'foo' & {'a': 1, 'b': [2, 3, 4]} + Url('http://example.com/foo?a=1&b=2&b=3&b=4') + >>> url // [1, 2, 3, 4] + Url('http://example.com/1/2/3/4') + """ + + def _parse(self) -> urllib.parse.ParseResult: + """ + Shortcut for `urllib.parse.urlparse `_. + """ + return urllib.parse.urlparse(self) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({super().__repr__()})" + + def __truediv__(self, other: Any) -> Self: + return self.add_path(other) + + def __floordiv__(self, others: Iterable[Any]) -> Self: + return self.add_path(*others) + + def __and__(self, params: Dict[str, Any]) -> Self: + return self.add_qs(params) + + def add_path(self, *others: Iterable[Any]) -> Self: + """ + Build a copy of this URL with additional path segments. + + >>> url = Url('http://example.com') + >>> url.add_path("a", "b", "c") + Url('http://example.com/a/b/c') + + The shorthand ``/`` has the same effect and can be used with a single path segment. + The shorthand ``//`` can be used with an iterable of path segments. + + >>> url / "a" / "b" / "c" + Url('http://example.com/a/b/c') + >>> url // ["a", "b", "c"] + Url('http://example.com/a/b/c') + """ + if not others: + raise TypeError("add_path() requires at least one argument") + parsed = self._parse() + if parsed.query: + raise ValueError("cannot add path segments after params") + parts = [str(other) for other in others] + if parsed.path: + parts.insert(0, parsed.path.rstrip("/")) + return self.replace_url(path="/".join(parts)) + + def add_qs( + self, + params: Optional[Dict[str, Any]] = None, + **other_params: Any, + ) -> Self: + """ + Build a copy of this URL with additional query parameters. + The shorthand ``&`` has the same effect. + + >>> url = Url('http://example.com') + >>> url.add_qs({"a": 1}, b=[2, 3, 4]) + Url('http://example.com?a=1&b=2&b=3&b=4') + >>> url & {"a": 1, "b": [2, 3, 4]} + Url('http://example.com?a=1&b=2&b=3&b=4') + """ + if not (params or other_params): + raise TypeError("add_qs() requires at least one argument") + params = {} if params is None else params + params.update(other_params) + parsed = self._parse() + qs = urllib.parse.parse_qs(parsed.query) + qs.update(params) + return self.replace_url(query=urllib.parse.urlencode(qs, doseq=True)) + + def replace_url(self, **kwargs: Any) -> Self: + """ + Build a copy of this URL with the given components replaced. + + >>> url = Url('http://example.com') + >>> url.replace(scheme='https', path='/foo') + Url('https://example.com/foo') + """ + return self.__class__(urllib.parse.urlunparse(self._parse()._replace(**kwargs))) + + +class UrlBuilder: + """ + Utility for defining URL patterns within an Airtable API class. + Each instance of UrlBuilder will inspect its own class attributes + and modify them to reflect the actual URL that should be used + based on the context (Table, Base, etc.) provided. + + The pattern for use in pyAirtable is: + + .. code-block:: python + + from functools import cached_property + from pyairtable.utils import UrlBuilder + + class SomeObject: + attr1: str + attr2: int + + class _urls(UrlBuilder): + url1 = "/path/to/{attr1}" + url2 = "/path/to/{attr2}" + + urls = cached_property(_urls) + + ...which ensures the URLs are built only once and are accessible via ``.urls``, + and have the ``SomeObject`` instance available as context, and build + readable docstrings for the ``SomeObject`` class documentation. + """ + + context: Any + api: "Api" + + def __init__(self, context: Any = None): + self.context = context + self.api = self._find_api(context) + for attr, value in vars(self.__class__).items(): + if attr.startswith("_") or not isinstance(value, str): + continue + setattr(self, attr, self.build_url(value)) + + def build_url(self, value: str, **kwargs: Any) -> Url: + if "{" in value: + context = {**vars(self.context), **kwargs, "self": self.context} + value = value.format_map(context) + return self.api.build_url(value) + + def __init_subclass__(cls, **kwargs: Any) -> None: + # This is a documentation hack for pyAirtable use cases only, where we + # subclass UrlBuilder within the definition of the class that uses it. + # + # We dynamically add a docstring to each subclass explaining its use, + # and we rely on Sphinx to document the cached_property, not the class. + # + # Will be skipped if the subclass is passed skip_docstring=True. + if "." not in cls.__qualname__ or kwargs.pop("skip_docstring", False): + return super().__init_subclass__(**kwargs) + try: + sample_url = next(k for (k, v) in vars(cls).items() if isinstance(v, Url)) + except StopIteration: + # if no URLs defined, don't do anything + return super().__init_subclass__(**kwargs) + + parent_clsname = cls.__qualname__.rsplit(".", 1)[0] + parent_modname = cls.__module__ + parent_varname = parent_clsname.lower().replace(".", "_") + docstring = ( + f"URLs associated with :class:`~{parent_modname}.{parent_clsname}`" + " can be accessed via ``.urls`` using the following syntax:\n\n" + ".. code-block:: python\n\n" + f""" + >>> {parent_varname} = {parent_clsname}(...) + >>> {parent_varname}.urls.{sample_url} + Url('https://api.airtable.com/...') + """ + "\n\nThese properties are all instances of :class:`~pyairtable.utils.Url`." + ) + + for name, obj in vars(cls).items(): + qualname = f"{parent_modname}::{cls.__qualname__}.{name}" + if isinstance(obj, Url): + docstring += f"\n\n.. autoattribute:: {qualname}\n :noindex:" + elif callable(obj): + docstring += f"\n\n.. automethod:: {qualname}\n :noindex:" + + _append_docstring_text(cls, docstring, skip_empty=False) + + @classmethod + def _find_api(self, context: Any) -> "Api": + from pyairtable.api.api import Api # avoid circular import + + if isinstance(context, Api): + return context + if isinstance(api := getattr(context, "api", None), Api): + return api + raise TypeError("context must be an instance of Api or have an 'api' attribute") + + __all__ = [ "attachment", "cache_unless_forced", @@ -341,5 +529,6 @@ def _getter(record: AnyRecordDict) -> Any: "is_record_id", "is_table_id", "is_user_id", + "Url", + "UrlBuilder", ] -# [[[end]]] (checksum: 7cf950d19fee128ae3f395ddbc475c0f) diff --git a/tests/test_utils.py b/tests/test_utils.py index c8b9dd40..427628a5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -148,3 +148,88 @@ def test_fieldgetter__required_str(): assert get_abc_require_b(record) == ("one", "two") with pytest.raises(KeyError): get_abc_require_b(fake_record(Alpha="one")) + + +def test_url_builder(base): + class Example(utils.UrlBuilder): + static = "one/two/three" + with_attr = "id/{id}" + with_self_attr = "self.id/{self.id}" + with_property = "self.name/{self.name}" + _ignored = "ignored" + + urls = Example(base) + assert urls.static == "https://api.airtable.com/v0/one/two/three" + assert urls.with_attr == f"https://api.airtable.com/v0/id/{base.id}" + assert urls.with_self_attr == f"https://api.airtable.com/v0/self.id/{base.id}" + assert urls.with_property == f"https://api.airtable.com/v0/self.name/{base.name}" + assert urls._ignored == "ignored" + + +@pytest.mark.parametrize("obj", [None, object(), {"api": object()}]) +def test_url_builder__invalid_context(obj): + with pytest.raises(TypeError): + utils.UrlBuilder(obj) + + +def test_url_builder__modifies_docstring(): + """ + This test is a bit meta, but it ensures that anyone else who wants to use UrlBuilder + can skip docstring creation by passing skip_docstring=True + """ + + class NormalBehavior(utils.UrlBuilder): + test = utils.Url("https://example.com") + + class MissingDocstring(utils.UrlBuilder, skip_docstring=True): + test = utils.Url("https://example.com") + + class ExistingDocstring(utils.UrlBuilder, skip_docstring=True): + """This is the docstring.""" + + test = utils.Url("https://example.com") + + assert "URLs associated with :class:" in NormalBehavior.__doc__ + assert MissingDocstring.__doc__ is None + assert ExistingDocstring.__doc__ == "This is the docstring." + + +def test_url(): + v = utils.Url("https://example.com") + assert v == "https://example.com" + assert v / "foo/bar" / "baz" == "https://example.com/foo/bar/baz" + assert v // [1, 2, "a", "b"] == "https://example.com/1/2/a/b" + assert v & {"a": 1, "b": [2, 3, 4]} == "https://example.com?a=1&b=2&b=3&b=4" + assert v.add_path(1, 2, "a", "b") == "https://example.com/1/2/a/b" + assert v.add_qs({"a": 1}, b=[2, 3, 4]) == "https://example.com?a=1&b=2&b=3&b=4" + + with pytest.raises(TypeError): + v.add_path() + with pytest.raises(TypeError): + v.add_qs() + + +def test_url__parse(): + v = utils.Url("https://example.com:443/asdf?a=1&b=2&b=3#foo") + parsed = v._parse() + assert parsed.scheme == "https" + assert parsed.netloc == "example.com:443" + assert parsed.path == "/asdf" + assert parsed.query == "a=1&b=2&b=3" + assert parsed.fragment == "foo" + assert parsed.hostname == "example.com" + assert parsed.port == 443 + + +def test_url__replace(): + v = utils.Url("https://example.com:443/asdf?a=1&b=2&b=3#foo") + assert v.replace_url(netloc="foo.com") == "https://foo.com/asdf?a=1&b=2&b=3#foo" + + +def test_url_cannot_append_after_params(): + # cannot add path segments after params + v = utils.Url("https://example.com?a=1&b=2") + with pytest.raises(ValueError): + v / "foo" + with pytest.raises(ValueError): + v // ["foo", "bar"] From b4af6d26f2fc8ef6e89932ca9218ae77cd44c68e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:56:29 -0700 Subject: [PATCH 214/272] Api.urls --- pyairtable/api/api.py | 28 ++++++++++++++++++++-------- tests/test_api_api.py | 2 +- tests/test_typing.py | 3 ++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index ec0928aa..c8924890 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -1,4 +1,4 @@ -import posixpath +from functools import cached_property from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union import requests @@ -11,7 +11,13 @@ from pyairtable.api.types import UserAndScopesDict, assert_typed_dict from pyairtable.api.workspace import Workspace from pyairtable.models.schema import Bases -from pyairtable.utils import cache_unless_forced, chunked, enterprise_only +from pyairtable.utils import ( + Url, + UrlBuilder, + cache_unless_forced, + chunked, + enterprise_only, +) T = TypeVar("T") TimeoutTuple: TypeAlias = Tuple[int, int] @@ -40,10 +46,16 @@ class Api: # Cached metadata to reduce API calls _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None - endpoint_url: str + endpoint_url: Url session: Session use_field_ids: bool + class _urls(UrlBuilder): + whoami = Url("meta/whoami") + bases = Url("meta/bases") + + urls = cached_property(_urls) + def __init__( self, api_key: str, @@ -77,7 +89,7 @@ def __init__( else: self.session = retrying._RetryingSession(retry_strategy) - self.endpoint_url = endpoint_url + self.endpoint_url = Url(endpoint_url) self.timeout = timeout self.api_key = api_key self.use_field_ids = use_field_ids @@ -102,7 +114,7 @@ def whoami(self) -> UserAndScopesDict: Return the current user ID and (if connected via OAuth) the list of scopes. See `Get user ID & scopes `_ for more information. """ - data = self.request("GET", self.build_url("meta/whoami")) + data = self.request("GET", self.urls.whoami) return assert_typed_dict(UserAndScopesDict, data) def workspace(self, workspace_id: str) -> Workspace: @@ -136,7 +148,7 @@ def _base_info(self) -> Bases: """ Return a schema object that represents all bases available via the API. """ - url = self.build_url("meta/bases") + url = self.urls.bases data = { "bases": [ base_info @@ -211,12 +223,12 @@ def table( base = self.base(base_id, validate=validate, force=force) return base.table(table_name, validate=validate, force=force) - def build_url(self, *components: str) -> str: + def build_url(self, *components: str) -> Url: """ Build a URL to the Airtable API endpoint with the given URL components, including the API version number. """ - return posixpath.join(self.endpoint_url, self.VERSION, *components) + return self.endpoint_url / self.VERSION // components def request( self, diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 6e5f32f7..6583f4b9 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -7,7 +7,7 @@ @pytest.fixture def mock_bases_endpoint(api, requests_mock, sample_json): - return requests_mock.get(api.build_url("meta/bases"), json=sample_json("Bases")) + return requests_mock.get(api.urls.bases, json=sample_json("Bases")) def test_repr(api): diff --git a/tests/test_typing.py b/tests/test_typing.py index 412542e0..ce06cdc0 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -10,6 +10,7 @@ import pyairtable import pyairtable.api.types as T import pyairtable.orm.lists as L +import pyairtable.utils from pyairtable import orm if TYPE_CHECKING: @@ -22,7 +23,7 @@ # Ensure the type signatures for pyairtable.Api don't change. api = pyairtable.Api(access_token) - assert_type(api.build_url("foo", "bar"), str) + assert_type(api.build_url("foo", "bar"), pyairtable.utils.Url) assert_type(api.base(base_id), pyairtable.Base) assert_type(api.table(base_id, table_name), pyairtable.Table) assert_type(api.whoami(), T.UserAndScopesDict) From 1c7bce8e0f4f86577ad90e9cd07133c6f73b1d0e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:56:02 -0700 Subject: [PATCH 215/272] Base.urls --- pyairtable/api/base.py | 58 +++++++++++++++++----------- tests/conftest.py | 10 ++--- tests/test_api_base.py | 24 +++++------- tests/test_models_schema.py | 14 ++++++- tests/test_testing__mock_airtable.py | 2 +- tests/test_typing.py | 2 +- 6 files changed, 65 insertions(+), 45 deletions(-) diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 263428ad..317bf365 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -1,4 +1,5 @@ import warnings +from functools import cached_property from typing import Any, Dict, List, Optional, Sequence, Union import pyairtable.api.api @@ -10,7 +11,7 @@ Webhook, WebhookSpecification, ) -from pyairtable.utils import cache_unless_forced, enterprise_only +from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only class Base: @@ -37,6 +38,33 @@ class Base: _schema: Optional[BaseSchema] = None _shares: Optional[List[BaseShares.Info]] = None + class _urls(UrlBuilder): + #: URL for retrieving the base's metadata and collaborators. + meta = Url("meta/bases/{id}") + + #: URL for retrieving information about the base's interfaces. + interfaces = meta / "interfaces" + + #: URL for retrieving the base's shares. + shares = meta / "shares" + + #: URL for retrieving the base's schema. + tables = meta / "tables" + + #: URL for POST requests that modify collaborations on the base. + collaborators = meta / "collaborators" + + #: URL for retrieving or modifying the base's webhooks. + webhooks = Url("bases/{id}/webhooks") + + def interface(self, interface_id: str) -> Url: + """ + URL for retrieving information about a specific interface on the base. + """ + return self.interfaces / interface_id + + urls = cached_property(_urls) + def __init__( self, api: Union["pyairtable.api.api.Api", str], @@ -154,23 +182,13 @@ def create_table( `Airtable field model `__. description: The table description. Must be no longer than 20k characters. """ - url = self.meta_url("tables") + url = self.urls.tables payload = {"name": name, "fields": fields} if description: payload["description"] = description response = self.api.post(url, json=payload) return self.table(response["id"], validate=True, force=True) - @property - def url(self) -> str: - return self.api.build_url(self.id) - - def meta_url(self, *components: Any) -> str: - """ - Build a URL to a metadata endpoint for this base. - """ - return self.api.build_url("meta/bases", self.id, *components) - @cache_unless_forced def schema(self) -> BaseSchema: """ @@ -184,15 +202,11 @@ def schema(self) -> BaseSchema: >>> base.schema().table("My Table") TableSchema(id="...", name="My Table", ...) """ - url = self.meta_url("tables") + url = self.urls.tables params = {"include": ["visibleFieldIds"]} data = self.api.get(url, params=params) return BaseSchema.from_api(data, self.api, context=self) - @property - def webhooks_url(self) -> str: - return self.api.build_url("bases", self.id, "webhooks") - def webhooks(self) -> List[Webhook]: """ Retrieve all the base's webhooks @@ -214,7 +228,7 @@ def webhooks(self) -> List[Webhook]: ) ] """ - response = self.api.get(self.webhooks_url) + response = self.api.get(self.urls.webhooks) return [ Webhook.from_api(data, self.api, context=self) for data in response["webhooks"] @@ -280,7 +294,7 @@ def add_webhook( create = CreateWebhook(notification_url=notify_url, specification=spec) request = create.model_dump(by_alias=True, exclude_unset=True) - response = self.api.post(self.webhooks_url, json=request) + response = self.api.post(self.urls.webhooks, json=request) return CreateWebhookResponse.from_api(response, self.api) @enterprise_only @@ -290,7 +304,7 @@ def collaborators(self) -> "BaseCollaborators": Retrieve `base collaborators `__. """ params = {"include": ["collaborators", "inviteLinks", "interfaces"]} - data = self.api.get(self.meta_url(), params=params) + data = self.api.get(self.urls.meta, params=params) return BaseCollaborators.from_api(data, self.api, context=self) @enterprise_only @@ -299,7 +313,7 @@ def shares(self) -> List[BaseShares.Info]: """ Retrieve `base shares `__. """ - data = self.api.get(self.meta_url("shares")) + data = self.api.get(self.urls.shares) shares_obj = BaseShares.from_api(data, self.api, context=self) return shares_obj.shares @@ -312,4 +326,4 @@ def delete(self) -> None: >>> base = api.base("appMxESAta6clCCwF") >>> base.delete() """ - self.api.delete(self.meta_url()) + self.api.delete(self.urls.meta) diff --git a/tests/conftest.py b/tests/conftest.py index 9fcc5978..00969c87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -191,12 +191,12 @@ def _get_schema_obj(name: str, *, context: Any = None) -> Any: @pytest.fixture def mock_base_metadata(base, sample_json, requests_mock): base_json = sample_json("BaseCollaborators") - requests_mock.get(base.api.build_url("meta/bases"), json=sample_json("Bases")) - requests_mock.get(base.meta_url(), json=base_json) - requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) - requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) + requests_mock.get(base.api.urls.bases, json=sample_json("Bases")) + requests_mock.get(base.urls.meta, json=base_json) + requests_mock.get(base.urls.tables, json=sample_json("BaseSchema")) + requests_mock.get(base.urls.shares, json=sample_json("BaseShares")) for pbd_id, pbd_json in base_json["interfaces"].items(): - requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json) + requests_mock.get(base.urls.interface(pbd_id), json=pbd_json) @pytest.fixture diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 4fadda8d..307d070c 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -9,7 +9,7 @@ @pytest.fixture def mock_tables_endpoint(base, requests_mock, sample_json): - return requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + return requests_mock.get(base.urls.tables, json=sample_json("BaseSchema")) def test_constructor(api): @@ -58,10 +58,6 @@ def test_repr(api, kwargs, expected): assert repr(base) == expected -def test_url(base): - assert base.url == "https://api.airtable.com/v0/appLkNDICXNqxSDhG" - - def test_schema(base: Base, mock_tables_endpoint): table_schema = base.schema().table("tbltp8DGLhqbUmjK1") assert table_schema.name == "Apartments" @@ -80,7 +76,7 @@ def test_table(base: Base, requests_mock): assert isinstance(rv, Table) assert rv.base == base assert rv.name == "tablename" - assert rv.url == f"https://api.airtable.com/v0/{base.id}/tablename" + assert rv.urls.records == f"https://api.airtable.com/v0/{base.id}/tablename" def test_table_validate(base: Base, mock_tables_endpoint): @@ -114,14 +110,14 @@ def test_tables(base: Base, mock_tables_endpoint): def test_collaborators(base: Base, requests_mock, sample_json): - requests_mock.get(base.meta_url(), json=sample_json("BaseCollaborators")) + requests_mock.get(base.urls.meta, json=sample_json("BaseCollaborators")) result = base.collaborators() assert result.individual_collaborators.via_base[0].email == "foo@bam.com" assert result.group_collaborators.via_workspace[0].group_id == "ugp1mKGb3KXUyQfOZ" def test_shares(base: Base, requests_mock, sample_json): - requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares")) + requests_mock.get(base.urls.shares, json=sample_json("BaseShares")) result = base.shares() assert result[0].state == "enabled" assert result[1].effective_email_domain_allow_list == [] @@ -129,7 +125,7 @@ def test_shares(base: Base, requests_mock, sample_json): def test_webhooks(base: Base, requests_mock, sample_json): m = requests_mock.get( - base.webhooks_url, + base.urls.webhooks, json={"webhooks": [sample_json("Webhook")]}, ) webhooks = base.webhooks() @@ -140,7 +136,7 @@ def test_webhooks(base: Base, requests_mock, sample_json): def test_webhook(base: Base, requests_mock, sample_json): - requests_mock.get(base.webhooks_url, json={"webhooks": [sample_json("Webhook")]}) + requests_mock.get(base.urls.webhooks, json={"webhooks": [sample_json("Webhook")]}) webhook = base.webhook("ach00000000000001") assert webhook.id == "ach00000000000001" assert webhook.notification_url == "https://example.com/receive-ping" @@ -171,7 +167,7 @@ def _callback(request, context): } } } - m = requests_mock.post(base.webhooks_url, json=_callback) + m = requests_mock.post(base.urls.webhooks, json=_callback) result = base.add_webhook("https://example.com/cb", spec) assert m.call_count == 1 @@ -187,7 +183,7 @@ def test_name(api, base, requests_mock): or if it is available in cached schema information. """ requests_mock.get( - base.meta_url(), + base.urls.meta, json={ "id": base.id, "name": "Mocked Base Name", @@ -286,7 +282,7 @@ def test_delete(base, requests_mock): """ Test that Base.delete() hits the right endpoint. """ - m = requests_mock.delete(base.meta_url(), json={"id": base.id, "deleted": True}) + m = requests_mock.delete(base.urls.meta, json={"id": base.id, "deleted": True}) base.delete() assert m.call_count == 1 @@ -295,7 +291,7 @@ def test_delete__enterprise_only_table(api, base, requests_mock): """ Test that Base.delete() explains why it might be getting a 404. """ - requests_mock.delete(base.meta_url(), status_code=404) + requests_mock.delete(base.urls.meta, status_code=404) with pytest.raises(HTTPError) as excinfo: base.delete() assert "Base.delete() requires an enterprise billing plan" in str(excinfo) diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 9c1aebcc..29961be8 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -9,6 +9,16 @@ from pyairtable.testing import fake_id +@pytest.fixture +def mock_base_metadata(base, sample_json, requests_mock): + base_json = sample_json("BaseCollaborators") + requests_mock.get(base.urls.meta, json=base_json) + requests_mock.get(base.urls.tables, json=sample_json("BaseSchema")) + requests_mock.get(base.urls.shares, json=sample_json("BaseShares")) + for pbd_id, pbd_json in base_json["interfaces"].items(): + requests_mock.get(base.urls.interface(pbd_id), json=pbd_json) + + @pytest.mark.parametrize( "clsname", [ @@ -128,8 +138,8 @@ def test_base_collaborators__add( Test that we can call base.collaborators().add_{user,group} to grant access to the base. """ - m = requests_mock.post(base.meta_url("collaborators"), body="") method = getattr(base.collaborators(), f"add_{kind}") + m = requests_mock.post(base.urls.collaborators, body="") method(id, "read") assert m.call_count == 1 assert m.last_request.json() == { @@ -229,7 +239,7 @@ def test_invite_link__delete( @pytest.fixture def interface_url(base): - return base.meta_url("interfaces", "pbdLkNDICXNqxSDhG") + return base.urls.interface("pbdLkNDICXNqxSDhG") @pytest.mark.parametrize("kind", ("user", "group")) diff --git a/tests/test_testing__mock_airtable.py b/tests/test_testing__mock_airtable.py index 60168198..54434426 100644 --- a/tests/test_testing__mock_airtable.py +++ b/tests/test_testing__mock_airtable.py @@ -246,7 +246,7 @@ def test_passthrough(mock_airtable, requests_mock, base, monkeypatch): """ Test that we can temporarily pass through unhandled methods to the requests library. """ - requests_mock.get(base.meta_url("tables"), json={"tables": []}) + requests_mock.get(base.urls.tables, json={"tables": []}) with monkeypatch.context() as mctx: mctx.setattr(mock_airtable, "passthrough", True) diff --git a/tests/test_typing.py b/tests/test_typing.py index ce06cdc0..41724789 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -31,7 +31,7 @@ # Ensure the type signatures for pyairtable.Base don't change. base = pyairtable.Base(api, base_id) assert_type(base.table(table_name), pyairtable.Table) - assert_type(base.url, str) + assert_type(base.id, str) # Ensure the type signatures for pyairtable.Table don't change. table = pyairtable.Table(None, base, table_name) From 90f08a291c65ab3d0f87618a25e509342b10b7de Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:54:45 -0700 Subject: [PATCH 216/272] Table.urls --- pyairtable/api/table.py | 101 +++++++++++++++++---------- tests/test_api_table.py | 68 +++++++++--------- tests/test_models_comment.py | 2 +- tests/test_orm.py | 12 ++-- tests/test_orm_fields.py | 17 +++-- tests/test_orm_model.py | 9 ++- tests/test_orm_model__memoization.py | 8 +-- tests/test_params.py | 2 +- tests/test_url_escape.py | 2 +- 9 files changed, 125 insertions(+), 96 deletions(-) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 687958cd..235b5989 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -1,9 +1,9 @@ import base64 import mimetypes import os -import posixpath import urllib.parse import warnings +from functools import cached_property from pathlib import Path from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload @@ -23,7 +23,7 @@ ) from pyairtable.formulas import Formula, to_formula_str from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema -from pyairtable.utils import is_table_id +from pyairtable.utils import Url, UrlBuilder, is_table_id class Table: @@ -45,6 +45,36 @@ class Table: # Cached schema information to reduce API calls _schema: Optional[TableSchema] = None + class _urls(UrlBuilder): + #: URL for retrieving all records in the table + records = Url("{base.id}/{self.id_or_name}") + + #: URL for retrieving all records in the table via POST, + #: when the request is too large to fit into GET parameters. + records_post = records / "listRecords" + fields = Url("meta/bases/{base.id}/tables/{self.id_or_name}/fields") + + def record(self, record_id: RecordId) -> Url: + """ + URL for a specific record in the table. + """ + return self.records / record_id + + def record_comments(self, record_id: RecordId) -> Url: + """ + URL for comments on a specific record in the table. + """ + return self.record(record_id) / "comments" + + def upload_attachment(self, record_id: RecordId, field: str) -> Url: + """ + URL for uploading an attachment to a specific field in a specific record. + """ + url = self.build_url(f"{{base.id}}/{record_id}/{field}/uploadAttachment") + return url.replace_url(netloc="content.airtable.com") + + urls = cached_property(_urls) + @overload def __init__( self, @@ -158,26 +188,26 @@ def id(self) -> str: return self.schema().id @property - def url(self) -> str: + def id_or_name(self, quoted: bool = True) -> str: """ - Build the URL for this table. - """ - token = self._schema.id if self._schema else self.name - return self.api.build_url(self.base.id, urllib.parse.quote(token, safe="")) + Return the table ID if it is known, otherwise the table name used for the constructor. + This is the URL component used to identify the table in Airtable's API. - def meta_url(self, *components: str) -> str: - """ - Build a URL to a metadata endpoint for this table. - """ - return self.api.build_url( - f"meta/bases/{self.base.id}/tables/{self.id}", *components - ) + Args: + quoted: Whether to return a URL-encoded value. - def record_url(self, record_id: RecordId, *components: str) -> str: - """ - Build the URL for the given record ID, with optional trailing components. + Usage: + + >>> table = base.table("Apartments") + >>> table.id_or_name + 'Apartments' + >>> table.schema() + >>> table.id_or_name + 'tblXXXXXXXXXXXXXX' """ - return posixpath.join(self.url, record_id, *components) + value = self._schema.id if self._schema else self.name + value = value if not quoted else urllib.parse.quote(value, safe="") + return value @property def api(self) -> "pyairtable.api.api.Api": @@ -204,7 +234,7 @@ def get(self, record_id: RecordId, **options: Any) -> RecordDict: """ if self.api.use_field_ids: options.setdefault("use_field_ids", self.api.use_field_ids) - record = self.api.get(self.record_url(record_id), options=options) + record = self.api.get(self.urls.record(record_id), options=options) return assert_typed_dict(RecordDict, record) def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: @@ -241,8 +271,8 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: options.setdefault("use_field_ids", self.api.use_field_ids) for page in self.api.iterate_requests( method="get", - url=self.url, - fallback=("post", f"{self.url}/listRecords"), + url=self.urls.records, + fallback=("post", self.urls.records_post), options=options, ): yield assert_typed_dicts(RecordDict, page.get("records", [])) @@ -316,7 +346,7 @@ def create( if use_field_ids is None: use_field_ids = self.api.use_field_ids created = self.api.post( - url=self.url, + url=self.urls.records, json={ "fields": fields, "typecast": typecast, @@ -363,7 +393,7 @@ def batch_create( for chunk in self.api.chunked(records): new_records = [{"fields": fields} for fields in chunk] response = self.api.post( - url=self.url, + url=self.urls.records, json={ "records": new_records, "typecast": typecast, @@ -402,7 +432,7 @@ def update( method = "put" if replace else "patch" updated = self.api.request( method=method, - url=self.record_url(record_id), + url=self.urls.record(record_id), json={ "fields": fields, "typecast": typecast, @@ -442,7 +472,7 @@ def batch_update( chunk_records = [{"id": x["id"], "fields": x["fields"]} for x in chunk] response = self.api.request( method=method, - url=self.url, + url=self.urls.records, json={ "records": chunk_records, "typecast": typecast, @@ -510,7 +540,7 @@ def batch_upsert( ] response = self.api.request( method=method, - url=self.url, + url=self.urls.records, json={ "records": formatted_records, "typecast": typecast, @@ -541,7 +571,7 @@ def delete(self, record_id: RecordId) -> RecordDeletedDict: """ return assert_typed_dict( RecordDeletedDict, - self.api.delete(self.record_url(record_id)), + self.api.delete(self.urls.record(record_id)), ) def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]: @@ -566,7 +596,7 @@ def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict record_ids = list(record_ids) for chunk in self.api.chunked(record_ids): - result = self.api.delete(self.url, params={"records[]": chunk}) + result = self.api.delete(self.urls.records, params={"records[]": chunk}) deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"]) return deleted_records @@ -603,11 +633,10 @@ def comments(self, record_id: RecordId) -> List["pyairtable.models.Comment"]: Args: record_id: |arg_record_id| """ - url = self.record_url(record_id, "comments") + url = self.urls.record_comments(record_id) + ctx = {"record_url": self.urls.record(record_id)} return [ - pyairtable.models.Comment.from_api( - comment, self.api, context={"record_url": self.record_url(record_id)} - ) + pyairtable.models.Comment.from_api(comment, self.api, context=ctx) for page in self.api.iterate_requests("GET", url) for comment in page["comments"] ] @@ -632,10 +661,10 @@ def add_comment( record_id: |arg_record_id| text: The text of the comment. Use ``@[usrIdentifier]`` to mention users. """ - url = self.record_url(record_id, "comments") + url = self.urls.record_comments(record_id) response = self.api.post(url, json={"text": text}) return pyairtable.models.Comment.from_api( - response, self.api, context={"record_url": self.record_url(record_id)} + response, self.api, context={"record_url": self.urls.record(record_id)} ) def schema(self, *, force: bool = False) -> TableSchema: @@ -691,7 +720,7 @@ def create_field( request["description"] = description if options: request["options"] = options - response = self.api.post(self.meta_url("fields"), json=request) + response = self.api.post(self.urls.fields, json=request) # This hopscotch ensures that the FieldSchema object we return has an API and a URL, # and that developers don't need to reload our schema to be able to access it. field_schema = parse_field_schema(response) @@ -763,7 +792,7 @@ def upload_attachment( 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" + url = self.urls.upload_attachment(record_id, field) content = content.encode() if isinstance(content, str) else content payload = { "contentType": content_type, diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 8e9277f0..1de9e5ba 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -1,5 +1,4 @@ from datetime import datetime, timezone -from posixpath import join as urljoin from unittest import mock import pytest @@ -25,7 +24,7 @@ def mock_schema(table, requests_mock, sample_json): table_schema = sample_json("TableSchema") table_schema["id"] = table.name = fake_id("tbl") return requests_mock.get( - table.base.meta_url("tables") + "?include=visibleFieldIds", + table.base.urls.tables + "?include=visibleFieldIds", json={"tables": [table_schema]}, ) @@ -45,7 +44,9 @@ def test_constructor_with_schema(base: Base, table_schema: TableSchema): assert table.api == base.api assert table.base == base assert table.name == table_schema.name - assert table.url == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}" + assert ( + table.urls.records == f"https://api.airtable.com/v0/{base.id}/{table_schema.id}" + ) assert ( repr(table) == f"
" @@ -91,7 +92,7 @@ def test_schema(base, requests_mock, sample_json): Test that we can load schema from API. """ table = base.table("Apartments") - m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + m = requests_mock.get(base.urls.tables, json=sample_json("BaseSchema")) assert isinstance(schema := table.schema(), TableSchema) assert m.call_count == 1 assert schema.id == "tbltp8DGLhqbUmjK1" @@ -102,7 +103,7 @@ def test_id(base, requests_mock, sample_json): Test that we load schema from API if we need the ID and don't have it, but if we get a name that *looks* like an ID, we trust it. """ - m = requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema")) + m = requests_mock.get(base.urls.tables, json=sample_json("BaseSchema")) table = base.table("tbltp8DGLhqbUmjK1") assert table.id == "tbltp8DGLhqbUmjK1" @@ -123,7 +124,7 @@ def test_id(base, requests_mock, sample_json): ) def test_url(api: Api, base_id, table_name, table_url_suffix): table = api.table(base_id, table_name) - assert table.url == f"https://api.airtable.com/v0/{table_url_suffix}" + assert table.urls.records == f"https://api.airtable.com/v0/{table_url_suffix}" def test_chunk(table: Table): @@ -134,11 +135,6 @@ def test_chunk(table: Table): assert chunks[3] == [3] -def test_record_url(table: Table): - rv = table.record_url("xxx") - assert rv == urljoin(table.url, "xxx") - - def test_api_key(table: Table, mock_response_single): def match_auth_header(request): expected_auth_header = "Bearer {}".format(table.api.api_key) @@ -149,7 +145,7 @@ def match_auth_header(request): with Mocker() as m: m.get( - table.record_url("rec"), + table.urls.record("rec"), status_code=200, json=mock_response_single, additional_matcher=match_auth_header, @@ -161,7 +157,7 @@ def match_auth_header(request): def test_get(table: Table, mock_response_single): _id = mock_response_single["id"] with Mocker() as mock: - mock.get(table.record_url(_id), status_code=200, json=mock_response_single) + mock.get(table.urls.record(_id), status_code=200, json=mock_response_single) resp = table.get(_id) assert dict_equals(resp, mock_response_single) @@ -169,7 +165,7 @@ def test_get(table: Table, mock_response_single): def test_first(table: Table, mock_response_single): mock_response = {"records": [mock_response_single]} with Mocker() as mock: - url = Request("get", table.url, params={"maxRecords": 1}).prepare().url + url = Request("get", table.urls.records, params={"maxRecords": 1}).prepare().url mock.get( url, status_code=200, @@ -183,7 +179,7 @@ def test_first(table: Table, mock_response_single): def test_first_via_post(table: Table, mock_response_single): mock_response = {"records": [mock_response_single]} with Mocker() as mock: - url = table.url + "/listRecords" + url = table.urls.records_post formula = f"RECORD_ID() != '{'x' * 17000}'" mock_endpoint = mock.post(url, status_code=200, json=mock_response) rv = table.first(formula=formula) @@ -199,7 +195,7 @@ def test_first_via_post(table: Table, mock_response_single): def test_first_none(table: Table, mock_response_single): mock_response = {"records": []} with Mocker() as mock: - url = Request("get", table.url, params={"maxRecords": 1}).prepare().url + url = Request("get", table.urls.records, params={"maxRecords": 1}).prepare().url mock.get( url, status_code=200, @@ -211,7 +207,7 @@ def test_first_none(table: Table, mock_response_single): def test_all(table, requests_mock, mock_response_list, mock_records): requests_mock.get( - table.url, + table.urls.records, status_code=200, json=mock_response_list[0], complete_qs=True, @@ -220,13 +216,13 @@ def test_all(table, requests_mock, mock_response_list, mock_records): offset = resp.get("offset", None) if not offset: continue - offset_url = table.url + "?offset={}".format(offset) requests_mock.get( - offset_url, + table.urls.records.add_qs(offset=offset), status_code=200, json=mock_response_list[1], complete_qs=True, ) + response = table.all() for n, resp in enumerate(response): @@ -260,7 +256,7 @@ def test_all__params(table, requests_mock, kwargs, expected): """ Test that parameters to all() get translated to query string correctly. """ - m = requests_mock.get(table.url, status_code=200, json={"records": []}) + m = requests_mock.get(table.urls.records, status_code=200, json={"records": []}) table.all(**kwargs) assert m.last_request.qs == expected @@ -268,7 +264,7 @@ def test_all__params(table, requests_mock, kwargs, expected): def test_iterate(table: Table, mock_response_list, mock_records): with Mocker() as mock: mock.get( - table.url, + table.urls.records, status_code=200, json=mock_response_list[0], complete_qs=True, @@ -278,7 +274,7 @@ def test_iterate(table: Table, mock_response_list, mock_records): if not offset: continue params = {"offset": offset} - offset_url = Request("get", table.url, params=params).prepare().url + offset_url = Request("get", table.urls.records, params=params).prepare().url mock.get( offset_url, status_code=200, @@ -303,7 +299,7 @@ def test_iterate__formula_conversion(table): m.assert_called_once_with( method="get", - url=table.url, + url=table.urls.records, fallback=mock.ANY, options={ "formula": "AND({Name}='Alice')", @@ -315,7 +311,7 @@ def test_create(table: Table, mock_response_single): with Mocker() as mock: post_data = mock_response_single["fields"] mock.post( - table.url, + table.urls.records, status_code=201, json=mock_response_single, additional_matcher=match_request_data(post_data), @@ -329,7 +325,7 @@ def test_batch_create(table: Table, container, mock_records): with Mocker() as mock: for chunk in _chunk(mock_records, 10): mock.post( - table.url, + table.urls.records, status_code=201, json={"records": chunk}, ) @@ -345,7 +341,7 @@ def test_update(table: Table, mock_response_single, replace, http_method): with Mocker() as mock: mock.register_uri( http_method, - urljoin(table.url, id_), + table.urls.record(id_), status_code=201, json=mock_response_single, additional_matcher=match_request_data(post_data), @@ -361,7 +357,7 @@ def test_batch_update(table: Table, container, replace, http_method): with Mocker() as mock: mock.register_uri( http_method, - table.url, + table.urls.records, response_list=[ {"json": {"records": chunk}} for chunk in table.api.chunked(records) ], @@ -391,7 +387,7 @@ def test_batch_upsert(table: Table, container, replace, http_method, monkeypatch with Mocker() as mock: mock.register_uri( http_method, - table.url, + table.urls.records, response_list=[{"json": response} for response in responses], ) monkeypatch.setattr(table.api, "MAX_RECORDS_PER_REQUEST", 1) @@ -420,7 +416,7 @@ def test_delete(table: Table, mock_response_single): id_ = mock_response_single["id"] expected = {"deleted": True, "id": id_} with Mocker() as mock: - mock.delete(urljoin(table.url, id_), status_code=201, json=expected) + mock.delete(table.urls.record(id_), status_code=201, json=expected) resp = table.delete(id_) assert resp == expected @@ -432,7 +428,9 @@ def test_batch_delete(table: Table, container, mock_records): for chunk in _chunk(ids, 10): json_response = {"records": [{"deleted": True, "id": id_} for id_ in chunk]} url_match = ( - Request("get", table.url, params={"records[]": chunk}).prepare().url + Request("get", table.urls.records, params={"records[]": chunk}) + .prepare() + .url ) mock.delete( url_match, @@ -450,7 +448,7 @@ def test_create_field(table, mock_schema, requests_mock, sample_json): Tests the API for creating a field (but without actually performing the operation). """ mock_create = requests_mock.post( - table.meta_url("fields"), + table.urls.fields, json=sample_json("field_schema/SingleSelectFieldSchema"), ) @@ -503,7 +501,7 @@ def test_use_field_ids__get_record(table, monkeypatch, requests_mock): (but not the explicit behavior) of Table.get() """ record = fake_record() - url = table.record_url(record_id := record["id"]) + url = table.urls.record(record_id := record["id"]) m = requests_mock.register_uri("GET", url, json=record) # by default, we don't pass the param at all @@ -531,7 +529,7 @@ def test_use_field_ids__get_records(table, monkeypatch, requests_mock, method_na Test that setting api.use_field_ids=True will change the default behavior (but not the explicit behavior) of Table.all() and Table.first() """ - m = requests_mock.register_uri("GET", table.url, json={"records": []}) + m = requests_mock.register_uri("GET", table.urls.records, json={"records": []}) # by default, we don't pass the param at all method = getattr(table, method_name) @@ -577,9 +575,9 @@ def test_use_field_ids__post( Test that setting api.use_field_ids=True will change the default behavior (but not the explicit behavior) of the create/update API methods on Table. """ - url = f"{table.url}/{suffix}".rstrip("/") + url = table.urls.records / suffix print(f"{url=}") - m = requests_mock.register_uri(http_method, url, json=response) + m = requests_mock.register_uri(http_method, url.rstrip("/"), json=response) # by default, the param is False method = getattr(table, method_name) diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 2f597472..4895ae57 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -16,7 +16,7 @@ def comment_json(sample_json): @pytest.fixture def comment(comment_json, table): - record_url = table.record_url(RECORD_ID) + record_url = table.urls.record(RECORD_ID) return Comment.from_api(comment_json, table.api, context={"record_url": record_url}) diff --git a/tests/test_orm.py b/tests/test_orm.py index 4f39b347..1ead27bb 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -208,7 +208,7 @@ def test_linked_record(): assert not contact.address[0].street with Mocker() as mock: - url = Address.meta.table.record_url(address.id) + url = Address.meta.table.urls.record(address.id) mock.get(url, status_code=200, json=record) contact.address[0].fetch() @@ -227,11 +227,11 @@ def test_linked_record_can_be_saved(requests_mock, access_linked_records): """ address_json = fake_record(Number=123, Street="Fake St") address_id = address_json["id"] - address_url_re = re.escape(Address.meta.table.url + "?filterByFormula=") + address_url_re = re.escape(Address.meta.table.urls.records + "?filterByFormula=") contact_json = fake_record(Email="alice@example.com", Link=[address_id]) contact_id = contact_json["id"] - contact_url = Contact.meta.table.record_url(contact_id) - contact_url_re = re.escape(Contact.meta.table.url + "?filterByFormula=") + contact_url = Contact.meta.table.urls.record(contact_id) + contact_url_re = re.escape(Contact.meta.table.urls.records + "?filterByFormula=") requests_mock.get(re.compile(address_url_re), json={"records": [address_json]}) requests_mock.get(re.compile(contact_url_re), json={"records": [contact_json]}) requests_mock.get(contact_url, json=contact_json) @@ -290,12 +290,12 @@ def test_undeclared_field(requests_mock, test_case): ) requests_mock.get( - Address.meta.table.url, + Address.meta.table.urls.records, status_code=200, json={"records": [record]}, ) requests_mock.get( - Address.meta.table.record_url(record["id"]), + Address.meta.table.urls.record(record["id"]), status_code=200, json=record, ) diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index aed47ede..d291b89d 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -697,13 +697,12 @@ def test_link_field__cycle(requests_mock): rec_b = {"id": id_b, "createdTime": DATETIME_S, "fields": {"Friends": [id_c]}} rec_c = {"id": id_c, "createdTime": DATETIME_S, "fields": {"Friends": [id_a]}} - requests_mock.get(Person.meta.table.record_url(id_a), json=rec_a) + requests_mock.get(Person.meta.table.urls.record(id_a), json=rec_a) a = Person.from_id(id_a) + url = Person.meta.table.urls.records for record in (rec_a, rec_b, rec_c): - url_re = re.compile( - re.escape(Person.meta.table.url + "?filterByFormula=") + ".*" + record["id"] - ) + url_re = re.compile(re.escape(f"{url}?filterByFormula=") + ".*" + record["id"]) requests_mock.get(url_re, json={"records": [record]}) assert a.friends[0].id == id_b @@ -718,7 +717,7 @@ def test_link_field__load_many(requests_mock): """ person_id = fake_id("rec", "A") - person_url = Person.meta.table.record_url(person_id) + person_url = Person.meta.table.urls.record(person_id) friend_ids = [fake_id("rec", c) for c in "123456789ABCDEF"] person_json = { @@ -740,7 +739,7 @@ def test_link_field__load_many(requests_mock): # The mocked URL specifically includes every record ID in our test set, # to ensure the library isn't somehow dropping records from its query. url_regex = ".*".join( - [re.escape(Person.meta.table.url + "?filterByFormula="), *friend_ids] + [re.escape(Person.meta.table.urls.records + "?filterByFormula="), *friend_ids] ) mock_list = requests_mock.get( re.compile(url_regex), @@ -796,10 +795,10 @@ def _cb(request, context): } requests_mock.get( - Book.meta.table.url, + Book.meta.table.urls.records, json={"records": [b1.to_record(), b2.to_record()]}, ) - m = requests_mock.patch(Author.meta.table.record_url(author.id), json=_cb) + m = requests_mock.patch(Author.meta.table.urls.record(author.id), json=_cb) exec(mutation, {}, {"author": author, "book": b2}) assert author._changed["Books"] author.save() @@ -1020,7 +1019,7 @@ def patch_callback(request, context): "fields": request.json()["fields"], } - m = requests_mock.patch(M.meta.table.record_url(obj.id), json=patch_callback) + m = requests_mock.patch(M.meta.table.urls.record(obj.id), json=patch_callback) # Test that we parse the "Z" into UTC correctly assert obj.dt.date() == datetime.date(2024, 2, 29) diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 2e1559b5..ff4763f0 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -223,8 +223,8 @@ def test_from_ids(mock_api): contacts = FakeModel.from_ids(fake_ids) mock_api.assert_called_once_with( method="get", - url=FakeModel.meta.table.url, - fallback=("post", FakeModel.meta.table.url + "/listRecords"), + url=FakeModel.meta.table.urls.records, + fallback=("post", FakeModel.meta.table.urls.records_post), options={ "formula": ( "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(fake_ids)) @@ -295,7 +295,10 @@ def test_get_fields_by_id(fake_records_by_id): """ with Mocker() as mock: mock.get( - f"{FakeModelByIds.meta.table.url}?&returnFieldsByFieldId=1&cellFormat=json", + FakeModelByIds.meta.table.urls.records.add_qs( + returnFieldsByFieldId=1, + cellFormat="json", + ), json=fake_records_by_id, complete_qs=True, status_code=200, diff --git a/tests/test_orm_model__memoization.py b/tests/test_orm_model__memoization.py index 053d02d1..3f900059 100644 --- a/tests/test_orm_model__memoization.py +++ b/tests/test_orm_model__memoization.py @@ -47,24 +47,24 @@ def record_mocks(requests_mock): # for Model.all mocks.get_authors = requests_mock.get( - Author.meta.table.url, + Author.meta.table.urls.records, json={"records": list(mocks.authors.values())}, ) mocks.get_books = requests_mock.get( - Book.meta.table.url, + Book.meta.table.urls.records, json={"records": list(mocks.books.values())}, ) # for Model.from_id mocks.get_author = { record_id: requests_mock.get( - Author.meta.table.record_url(record_id), json=record_data + Author.meta.table.urls.record(record_id), json=record_data ) for record_id, record_data in mocks.authors.items() } mocks.get_book = { record_id: requests_mock.get( - Book.meta.table.record_url(record_id), json=record_data + Book.meta.table.urls.record(record_id), json=record_data ) for record_id, record_data in mocks.books.items() } diff --git a/tests/test_params.py b/tests/test_params.py index bd23820c..81820c9e 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -30,7 +30,7 @@ def test_params_integration(table, mock_records, mock_response_iterator): "&returnFieldsByFieldId=1" "" ) - mock_url = "{0}?{1}".format(table.url, url_params) + mock_url = "{0}?{1}".format(table.urls.records, url_params) m.get(mock_url, status_code=200, json=mock_response_iterator) response = table.all(**params) for n, resp in enumerate(response): diff --git a/tests/test_url_escape.py b/tests/test_url_escape.py index bb6c06bf..240edb0c 100644 --- a/tests/test_url_escape.py +++ b/tests/test_url_escape.py @@ -17,4 +17,4 @@ def test_url_escape(base, table_name, escaped): table names (which Airtable *will* allow). """ table = base.table(table_name) - assert table.url.endswith(escaped) + assert table.urls.records.endswith(escaped) From 30c71a9860fc2376839b4a5bc0bdc949d740eafb Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:41:52 -0700 Subject: [PATCH 217/272] Workspace.urls --- pyairtable/api/workspace.py | 28 ++++++++++++++++++---------- tests/conftest.py | 2 +- tests/test_api_workspace.py | 12 ++++++++---- tests/test_models_schema.py | 4 ++-- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index ed1cc453..5293d4eb 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -1,7 +1,8 @@ +from functools import cached_property from typing import Any, Dict, List, Optional, Sequence, Union from pyairtable.models.schema import WorkspaceCollaborators -from pyairtable.utils import cache_unless_forced, enterprise_only +from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only class Workspace: @@ -20,14 +21,22 @@ class Workspace: _collaborators: Optional[WorkspaceCollaborators] = None + class _urls(UrlBuilder): + #: URL for retrieving the workspace's metadata and collaborators. + meta = Url("meta/workspaces/{id}") + + #: URL for moving a base to a new workspace. + move_base = meta / "moveBase" + + #: URL for POST requests that modify collaborations on the workspace. + collaborators = meta / "collaborators" + + urls = cached_property(_urls) + def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): self.api = api self.id = workspace_id - @property - def url(self) -> str: - return self.api.build_url("meta/workspaces", self.id) - def create_base( self, name: str, @@ -43,7 +52,7 @@ def create_base( tables: A list of ``dict`` objects that conform to Airtable's `Table model `__. """ - url = self.api.build_url("meta/bases") + url = self.api.urls.bases payload = {"name": name, "workspaceId": self.id, "tables": list(tables)} response = self.api.post(url, json=payload) return self.api.base(response["id"], validate=True, force=True) @@ -60,7 +69,7 @@ def collaborators(self) -> WorkspaceCollaborators: See https://airtable.com/developers/web/api/get-workspace-collaborators """ params = {"include": ["collaborators", "inviteLinks"]} - payload = self.api.get(self.url, params=params) + payload = self.api.get(self.urls.meta, params=params) return WorkspaceCollaborators.from_api(payload, self.api, context=self) @enterprise_only @@ -89,7 +98,7 @@ def delete(self) -> None: >>> ws = api.workspace("wspmhESAta6clCCwF") >>> ws.delete() """ - self.api.delete(self.url) + self.api.delete(self.urls.meta) @enterprise_only def move_base( @@ -113,8 +122,7 @@ def move_base( payload: Dict[str, Any] = {"baseId": base_id, "targetWorkspaceId": target_id} if index is not None: payload["targetIndex"] = index - url = self.url + "/moveBase" - self.api.post(url, json=payload) + self.api.post(self.urls.move_base, json=payload) # These are at the bottom of the module to avoid circular imports diff --git a/tests/conftest.py b/tests/conftest.py index 00969c87..b2790f7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -202,7 +202,7 @@ def mock_base_metadata(base, sample_json, requests_mock): @pytest.fixture def mock_workspace_metadata(workspace, sample_json, requests_mock): workspace_json = sample_json("WorkspaceCollaborators") - requests_mock.get(workspace.url, json=workspace_json) + requests_mock.get(workspace.urls.meta, json=workspace_json) @pytest.fixture diff --git a/tests/test_api_workspace.py b/tests/test_api_workspace.py index ad66933e..1bf51064 100644 --- a/tests/test_api_workspace.py +++ b/tests/test_api_workspace.py @@ -16,7 +16,9 @@ def workspace(api, workspace_id): @pytest.fixture def mock_info(workspace, requests_mock, sample_json): - return requests_mock.get(workspace.url, json=sample_json("WorkspaceCollaborators")) + return requests_mock.get( + workspace.urls.meta, json=sample_json("WorkspaceCollaborators") + ) def test_collaborators(workspace, mock_info): @@ -39,7 +41,7 @@ def test_bases(workspace, mock_info): def test_create_base(workspace, requests_mock, sample_json): - url = workspace.api.build_url("meta/bases") + url = workspace.api.urls.bases requests_mock.get(url, json=sample_json("Bases")) requests_mock.post(url, json={"id": "appLkNDICXNqxSDhG"}) base = workspace.create_base("Base Name", []) @@ -48,7 +50,9 @@ def test_create_base(workspace, requests_mock, sample_json): def test_delete(workspace, requests_mock): - m = requests_mock.delete(workspace.url, json={"id": workspace.id, "deleted": True}) + m = requests_mock.delete( + workspace.urls.meta, json={"id": workspace.id, "deleted": True} + ) workspace.delete() assert m.call_count == 1 @@ -73,7 +77,7 @@ def test_move_base( expected, requests_mock, ): - m = requests_mock.post(workspace.url + "/moveBase") + m = requests_mock.post(workspace.urls.move_base) workspace.move_base(locals()[base_param], locals()[workspace_param], **kwargs) assert m.call_count == 1 assert m.request_history[-1].json() == { diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 29961be8..65d29649 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -161,9 +161,9 @@ def test_workspace_collaborators__add(api, kind, id, requests_mock, sample_json) """ workspace_json = sample_json("WorkspaceCollaborators") workspace = api.workspace(workspace_json["id"]) - requests_mock.get(workspace.url, json=workspace_json) - m = requests_mock.post(f"{workspace.url}/collaborators", body="") + requests_mock.get(workspace.urls.meta, json=workspace_json) method = getattr(workspace.collaborators(), f"add_{kind}") + m = requests_mock.post(workspace.urls.collaborators, body="") method(id, "read") assert m.call_count == 1 assert m.last_request.json() == { From c70dba70b7f455d041f56676563a69bb59f5c70d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:43:03 -0700 Subject: [PATCH 218/272] Enterprise.urls --- pyairtable/api/enterprise.py | 75 ++++++++++++++++++++++++++++-------- pyairtable/models/schema.py | 2 +- tests/test_api_enterprise.py | 29 ++++++++------ tests/test_cli.py | 12 +++--- 4 files changed, 83 insertions(+), 35 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index b5b133ab..7fedcfd5 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,4 +1,5 @@ import datetime +from functools import cached_property, partialmethod from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union import pydantic @@ -7,6 +8,8 @@ from pyairtable.models.audit import AuditLogResponse from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.utils import ( + Url, + UrlBuilder, cache_unless_forced, coerce_iso_str, coerce_list_str, @@ -24,22 +27,66 @@ class Enterprise: ['wspmhESAta6clCCwF', ...] """ + class _urls(UrlBuilder): + #: URL for retrieving basic information about the enterprise. + meta = Url("meta/enterpriseAccounts/{id}") + + #: URL for retrieving information about all users. + users = meta / "users" + + #: URL for retrieving information about all user groups. + groups = Url("meta/groups") + + #: URL for claiming a user into an enterprise. + claim_users = meta / "claim/users" + + #: URL for retrieving audit log events. + audit_log = meta / "auditLogEvents" + + def user(self, user_id: str) -> Url: + """ + URL for retrieving information about a single user. + """ + return self.users / user_id + + def group(self, group_id: str) -> Url: + """ + URL for retrieving information about a single user group. + """ + return self.groups / group_id + + def admin_access(self, action: Literal["grant", "revoke"]) -> Url: + """ + URL for granting or revoking admin access to one or more users. + """ + return self.meta / f"users/{action}AdminAccess" + + def remove_user(self, user_id: str) -> Url: + """ + URL for removing a user from the enterprise. + """ + return self.user(user_id) / "remove" + + #: URL for granting admin access to one or more users. + grant_admin = partialmethod(admin_access, "grant") + + #: URL for revoking admin access from one or more users. + revoke_admin = partialmethod(admin_access, "revoke") + + urls = cached_property(_urls) + def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): self.api = api self.id = workspace_id self._info: Optional[EnterpriseInfo] = None - @property - def url(self) -> str: - return self.api.build_url("meta/enterpriseAccounts", self.id) - @cache_unless_forced def info(self) -> EnterpriseInfo: """ Retrieve basic information about the enterprise, caching the result. """ params = {"include": ["collaborators", "inviteLinks"]} - response = self.api.get(self.url, params=params) + response = self.api.get(self.urls.meta, params=params) return EnterpriseInfo.from_api(response, self.api) def group(self, group_id: str, collaborations: bool = True) -> UserGroup: @@ -52,8 +99,7 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup: from Airtable. This may result in faster responses. """ params = {"include": ["collaborations"] if collaborations else []} - url = self.api.build_url(f"meta/groups/{group_id}") - payload = self.api.get(url, params=params) + payload = self.api.get(self.urls.group(group_id), params=params) return UserGroup.model_validate(payload) def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: @@ -89,7 +135,7 @@ def users( (emails if "@" in value else user_ids).append(value) response = self.api.get( - url=f"{self.url}/users", + url=self.urls.users, params={ "id": user_ids, "email": emails, @@ -212,10 +258,9 @@ def handle_event(event): } params = {k: v for (k, v) in params.items() if v} offset_field = "next" if sort_asc else "previous" - url = self.api.build_url(f"meta/enterpriseAccounts/{self.id}/auditLogEvents") iter_requests = self.api.iterate_requests( method="GET", - url=url, + url=self.urls.audit_log, params=params, offset_field=offset_field, ) @@ -246,7 +291,7 @@ def remove_user( workspaces. If the user is not the sole owner of any workspaces, this is optional and will be ignored if provided. """ - url = f"{self.url}/users/{user_id}/remove" + url = self.urls.remove_user(user_id) payload: Dict[str, Any] = {"isDryRun": False} if replacement: payload["replacementOwnerId"] = replacement @@ -277,7 +322,7 @@ def claim_users( for (key, value) in users.items() ] } - response = self.api.post(f"{self.url}/users/claim", json=payload) + response = self.api.post(self.urls.claim_users, json=payload) return ManageUsersResponse.from_api(response, self.api, context=self) def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse": @@ -287,7 +332,7 @@ def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse": Args: emails: A list or other iterable of email addresses. """ - response = self.api.delete(f"{self.url}/users", params={"email": list(emails)}) + response = self.api.delete(self.urls.users, params={"email": list(emails)}) return DeleteUsersResponse.from_api(response, self.api, context=self) def grant_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse": @@ -311,10 +356,10 @@ def revoke_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse": return self._post_admin_access("revoke", users) def _post_admin_access( - self, action: str, users: Iterable[Union[str, UserInfo]] + self, action: Literal["grant", "revoke"], users: Iterable[Union[str, UserInfo]] ) -> "ManageUsersResponse": response = self.api.post( - f"{self.url}/users/{action}AdminAccess", + self.urls.admin_access(action), json={ "users": [ {"email": user_id} if "@" in user_id else {"id": user_id} diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 5bfb1628..61d02565 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -546,7 +546,7 @@ class WorkspaceCollaboration(AirtableModel): class UserInfo( CanUpdateModel, CanDeleteModel, - url="{enterprise.url}/users/{self.id}", + url="{enterprise.urls.users}/{self.id}", writable=["state", "email", "first_name", "last_name"], ): """ diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 21aea5c5..86555b4d 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -19,19 +19,24 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): m.json_group = sample_json("UserGroup") m.user_id = m.json_user["id"] m.group_id = m.json_group["id"] - m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo")) + m.get_info = requests_mock.get( + enterprise_url := "https://api.airtable.com/v0/meta/enterpriseAccounts/entUBq2RGdihxl3vU", + json=sample_json("EnterpriseInfo"), + ) m.get_user = requests_mock.get( - f"{enterprise.url}/users/{m.user_id}", json=m.json_user + f"{enterprise_url}/users/usrL2PNC5o3H4lBEi", + json=m.json_user, + ) + m.get_users = requests_mock.get( + f"{enterprise_url}/users", + json=m.json_users, ) - m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.json_users) m.get_group = requests_mock.get( - enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"), + "https://api.airtable.com/v0/meta/groups/ugp1mKGb3KXUyQfOZ", json=m.json_group, ) m.get_audit_log = requests_mock.get( - enterprise.api.build_url( - f"meta/enterpriseAccounts/{enterprise.id}/auditLogEvents" - ), + f"{enterprise_url}/auditLogEvents", response_list=[ { "json": { @@ -45,11 +50,11 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): ], ) m.remove_user = requests_mock.post( - enterprise.url + f"/users/{m.user_id}/remove", + f"{enterprise_url}/users/{m.user_id}/remove", json=sample_json("UserRemoved"), ) m.claim_users = requests_mock.post( - enterprise.url + "/users/claim", + f"{enterprise_url}/claim/users", json={"errors": []}, ) return m @@ -240,7 +245,7 @@ def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): @pytest.fixture def user_info(enterprise, enterprise_mocks): user_info = enterprise.user(enterprise_mocks.user_id) - assert user_info._url == f"{enterprise.url}/users/{user_info.id}" + assert user_info._url == f"{enterprise.urls.users}/{user_info.id}" return user_info @@ -293,7 +298,7 @@ def test_delete_users(enterprise, requests_mock): ], } emails = [f"foo{n}@bar.com" for n in range(5)] - m = requests_mock.delete(enterprise.url + "/users", json=response) + m = requests_mock.delete(enterprise.urls.users, json=response) parsed = enterprise.delete_users(emails) assert m.call_count == 1 assert m.last_request.qs == {"email": emails} @@ -305,7 +310,7 @@ def test_delete_users(enterprise, requests_mock): @pytest.mark.parametrize("action", ["grant", "revoke"]) def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action): user = enterprise.user(enterprise_mocks.user_id) - m = requests_mock.post(f"{enterprise.url}/users/{action}AdminAccess", json={}) + m = requests_mock.post(enterprise.urls.admin_access(action), json={}) method = getattr(enterprise, f"{action}_admin") result = method( fake_user_id := fake_id("usr"), diff --git a/tests/test_cli.py b/tests/test_cli.py index aa0940ea..8831a043 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,14 +27,12 @@ def mock_metadata( user_info = sample_json("UserInfo") user_group = sample_json("UserGroup") enterprise_info = sample_json("EnterpriseInfo") - requests_mock.get(api.build_url("meta/whoami"), json={"id": user_id}) - requests_mock.get(enterprise.url, json=enterprise_info) - requests_mock.get(f"{enterprise.url}/users/{user_id}", json=user_info) - requests_mock.get(f"{enterprise.url}/users", json={"users": [user_info]}) + requests_mock.get(api.urls.whoami, json={"id": user_id}) + requests_mock.get(enterprise.urls.meta, json=enterprise_info) + requests_mock.get(enterprise.urls.users, json={"users": [user_info]}) + requests_mock.get(enterprise.urls.user(user_id), json=user_info) for group_id in enterprise_info["groupIds"]: - requests_mock.get( - enterprise.api.build_url(f"meta/groups/{group_id}"), json=user_group - ) + requests_mock.get(enterprise.urls.group(group_id), json=user_group) @pytest.fixture From 6779baabfdf42cecf3716777dbe6da5d6701f961 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 26 Oct 2024 12:57:04 -0700 Subject: [PATCH 219/272] Breaking change to generating URLs --- docs/source/changelog.rst | 4 +++- docs/source/migrations.rst | 31 +++++++++++++++++++++++++++++++ tox.ini | 6 +++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 680e5c42..c68d6ad2 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -53,12 +53,14 @@ Changelog - `PR #395 `_ * Dropped support for Pydantic 1.x. - `PR #397 `_ +* Refactored methods/properties for constructing URLs in the API. + - `PR #399 `_ 2.3.4 (2024-10-21) ------------------------ * Fixed a crash at import time under Python 3.13. - `PR #396 `_ + - `PR #396 `_ 2.3.3 (2024-03-22) ------------------------ diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index f85d023c..c6fc6a8c 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -27,6 +27,37 @@ Deprecated metadata module removed The 3.0 release removed the ``pyairtable.metadata`` module. For supported alternatives, see :doc:`metadata`. +Changes to generating URLs +--------------------------------------------- + +The following properties and methods for constructing URLs have been renamed or removed. +These methods now return instances of :class:`~pyairtable.utils.Url`, which is a +subclass of ``str`` that has some overloaded operators. See docs for more details. + +.. list-table:: + :header-rows: 1 + + * - Building a URL in 2.x + - Building a URL in 3.0 + * - ``table.url`` + - ``table.urls.records`` + * - ``table.record_url(record_id)`` + - ``table.urls.record(record_id)`` + * - ``table.meta_url("one", "two")`` + - ``table.urls.meta / "one" / "two"`` + * - ``table.meta_url(*parts)`` + - ``table.urls.meta // parts`` + * - ``base.url`` + - (removed; was invalid) + * - ``base.meta_url("one", "two")`` + - ``base.urls.meta / "one" / "two"`` + * - ``base.webhooks_url()`` + - ``base.urls.webhooks`` + * - ``enterprise.url`` + - ``enterprise.urls.meta`` + * - ``workspace.url`` + - ``workspace.urls.meta`` + Changes to the formulas module --------------------------------------------- diff --git a/tox.ini b/tox.ini index f25eb585..e112cc7e 100644 --- a/tox.ini +++ b/tox.ini @@ -47,7 +47,11 @@ commands = [testenv:coverage] passenv = COVERAGE_FORMAT commands = - python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} --cov-fail-under=100 + python -m pytest -m 'not integration' \ + --cov=pyairtable \ + --cov-report={env:COVERAGE_FORMAT:html} \ + --cov-report=term-missing \ + --cov-fail-under=100 [testenv:docs] basepython = python3.9 From 2c91f1eb2143689c982e97b3f1b8d98c88a08548 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 2 Nov 2024 15:33:01 -0700 Subject: [PATCH 220/272] scripts/find_model_changes.py --- scripts/find_model_changes.py | 302 ++++++++++++++++++++++++++++++++++ tox.ini | 2 +- 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 scripts/find_model_changes.py diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py new file mode 100644 index 00000000..2988d7f6 --- /dev/null +++ b/scripts/find_model_changes.py @@ -0,0 +1,302 @@ +""" +Scans the API documentation on airtable.com and compares it to the models in pyAirtable. +Attempts to flag any places where the library is missing fields or has extra undocumented fields. +""" + +import importlib +import json +import re +from functools import cached_property +from operator import attrgetter +from typing import Any, Dict, Iterator, List, Type + +import requests + +from pyairtable.models._base import AirtableModel + +API_PREFIX = "https://airtable.com/developers/web/api" +API_INTRO = f"{API_PREFIX}/introduction" +INITDATA_RE = r"]*>\s*window\.initData = (\{.*\})\s*" + +SCAN_MODELS = { + "pyairtable.models.audit:AuditLogResponse": "operations:audit-log-events:response:schema", + "pyairtable.models.audit:AuditLogEvent": "operations:audit-log-events:response:schema:@events:items", + "pyairtable.models.audit:AuditLogEvent.Context": "operations:audit-log-events:response:schema:@events:items:@context", + "pyairtable.models.audit:AuditLogEvent.Origin": "operations:audit-log-events:response:schema:@events:items:@origin", + "pyairtable.models.audit:AuditLogActor": "schemas:audit-log-actor", + "pyairtable.models.audit:AuditLogActor.UserInfo": "schemas:audit-log-actor:@user", + "pyairtable.models.collaborator:Collaborator": "operations:list-comments:response:schema:@comments:items:@author", + "pyairtable.models.comment:Comment": "operations:list-comments:response:schema:@comments:items", + "pyairtable.models.comment:Reaction": "operations:list-comments:response:schema:@comments:items:@reactions:items", + "pyairtable.models.comment:Reaction.EmojiInfo": "operations:list-comments:response:schema:@comments:items:@reactions:items:@emoji", + "pyairtable.models.comment:Reaction.ReactingUser": "operations:list-comments:response:schema:@comments:items:@reactions:items:@reactingUser", + "pyairtable.models.comment:Mentioned": "schemas:user-mentioned", + "pyairtable.models.schema:BaseSchema": "operations:get-base-schema:response:schema", + "pyairtable.models.schema:TableSchema": "schemas:table-model", + "pyairtable.models.schema:Bases": "operations:list-bases:response:schema", + "pyairtable.models.schema:Bases.Info": "operations:list-bases:response:schema:@bases:items", + "pyairtable.models.schema:BaseCollaborators": "operations:get-base-collaborators:response:schema", + "pyairtable.models.schema:BaseCollaborators.IndividualCollaborators": "operations:get-base-collaborators:response:schema:@individualCollaborators", + "pyairtable.models.schema:BaseCollaborators.GroupCollaborators": "operations:get-base-collaborators:response:schema:@groupCollaborators", + "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:additionalProperties", + "pyairtable.models.schema:BaseCollaborators.InviteLinks": "operations:get-base-collaborators:response:schema:@inviteLinks", + "pyairtable.models.schema:BaseShares": "operations:list-shares:response:schema", + "pyairtable.models.schema:BaseShares.Info": "operations:list-shares:response:schema:@shares:items", + "pyairtable.models.schema:ViewSchema": "operations:get-view-metadata:response:schema", + "pyairtable.models.schema:InviteLink": "schemas:invite-link", + "pyairtable.models.schema:WorkspaceInviteLink": "schemas:invite-link", + "pyairtable.models.schema:InterfaceInviteLink": "schemas:invite-link", + "pyairtable.models.schema:EnterpriseInfo": "operations:get-enterprise:response:schema", + "pyairtable.models.schema:EnterpriseInfo.EmailDomain": "operations:get-enterprise:response:schema:@emailDomains:items", + "pyairtable.models.schema:WorkspaceCollaborators": "operations:get-workspace-collaborators:response:schema", + "pyairtable.models.schema:WorkspaceCollaborators.Restrictions": "operations:get-workspace-collaborators:response:schema:@workspaceRestrictions", + "pyairtable.models.schema:WorkspaceCollaborators.GroupCollaborators": "operations:get-workspace-collaborators:response:schema:@groupCollaborators", + "pyairtable.models.schema:WorkspaceCollaborators.IndividualCollaborators": "operations:get-workspace-collaborators:response:schema:@individualCollaborators", + "pyairtable.models.schema:WorkspaceCollaborators.InviteLinks": "operations:get-workspace-collaborators:response:schema:@inviteLinks", + "pyairtable.models.schema:GroupCollaborator": "schemas:group-collaborator", + "pyairtable.models.schema:IndividualCollaborator": "schemas:individual-collaborator", + "pyairtable.models.schema:BaseGroupCollaborator": "schemas:base-group-collaborator", + "pyairtable.models.schema:BaseIndividualCollaborator": "schemas:base-individual-collaborator", + "pyairtable.models.schema:BaseInviteLink": "schemas:base-invite-link", + "pyairtable.models.schema:Collaborations": "schemas:collaborations", + "pyairtable.models.schema:Collaborations.BaseCollaboration": "schemas:collaborations:@baseCollaborations:items", + "pyairtable.models.schema:Collaborations.InterfaceCollaboration": "schemas:collaborations:@interfaceCollaborations:items", + "pyairtable.models.schema:Collaborations.WorkspaceCollaboration": "schemas:collaborations:@workspaceCollaborations:items", + "pyairtable.models.schema:UserInfo": "operations:get-user-by-id:response:schema", + "pyairtable.models.schema:UserGroup": "operations:get-user-group:response:schema", + "pyairtable.models.schema:UserGroup.Member": "operations:get-user-group:response:schema:@members:items", + "pyairtable.models.webhook:Webhook": "operations:list-webhooks:response:schema:@webhooks:items", + "pyairtable.models.webhook:WebhookNotificationResult": "schemas:webhooks-notification", + "pyairtable.models.webhook:WebhookError": "schemas:webhooks-notification:@error", + "pyairtable.models.webhook:WebhookPayloads": "operations:list-webhook-payloads:response:schema", + "pyairtable.models.webhook:WebhookPayload": "schemas:webhooks-payload", + "pyairtable.models.webhook:WebhookPayload.ActionMetadata": "schemas:webhooks-action", + "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties:@current", + "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.TableChanged": "schemas:webhooks-table-changed", + "pyairtable.models.webhook:WebhookPayload.TableChanged.ChangedMetadata": "schemas:webhooks-table-changed:@changedMetadata", + "pyairtable.models.webhook:WebhookPayload.TableInfo": "schemas:webhooks-table-changed:@changedMetadata:@current", + "pyairtable.models.webhook:WebhookPayload.TableCreated": "schemas:webhooks-table-created", + "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:additionalProperties", + "pyairtable.models.webhook:CreateWebhook": "operations:create-a-webhook:request:schema", + "pyairtable.models.webhook:CreateWebhookResponse": "operations:create-a-webhook:response:schema", + "pyairtable.models.webhook:WebhookSpecification": "operations:create-a-webhook:request:schema:@specification", + "pyairtable.models.webhook:WebhookSpecification.Options": "schemas:webhooks-specification", + "pyairtable.models.webhook:WebhookSpecification.Includes": "schemas:webhooks-specification:@includes", + "pyairtable.models.webhook:WebhookSpecification.Filters": "schemas:webhooks-specification:@filters", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions": "schemas:webhooks-specification:@filters:@sourceOptions", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formSubmission", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormPageSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formPageSubmission", +} + +IGNORED = [ + "pyairtable.models.audit.AuditLogResponse.Pagination", # pagination, not exposed + "pyairtable.models.schema.NestedId", # internal + "pyairtable.models.schema.NestedFieldId", # internal + "pyairtable.models.schema.Bases.offset", # pagination, not exposed + "pyairtable.models.schema.BaseCollaborators.collaborators", # deprecated + "pyairtable.models.schema.WorkspaceCollaborators.collaborators", # deprecated + "pyairtable.models.webhook.WebhookPayload.cursor", # pyAirtable provides this + "pyairtable.models.schema.BaseShares.Info.shareTokenPrefix", # deprecated + "pyairtable.models.webhook.WebhookPayload.CellValuesByFieldId", # undefined in schema + "pyairtable.models.webhook.WebhookNotification", # undefined in schema +] + + +def main() -> None: + initdata = get_api_data() + issues: List[str] = [] + + # Find missing/extra fields + for model_path, initdata_path in SCAN_MODELS.items(): + modname, clsname = model_path.split(":", 1) + model_module = importlib.import_module(modname) + model_cls = attrgetter(clsname)(model_module) + initdata_path = initdata_path.replace(":@", ":properties:") + issues.extend(scan_schema(model_cls, initdata.get_nested(initdata_path))) + + if not issues: + print("No missing/extra fields found in scanned classes") + else: + for issue in issues: + print(issue) + + # Find unscanned model classes + issues.clear() + modules = sorted({model_path.split(":")[0] for model_path in SCAN_MODELS}) + for modname in modules: + if not ignore_name(modname): + mod = importlib.import_module(modname) + issues.extend(scan_missing(mod, prefix=(modname + ":"))) + + if not issues: + print("No unscanned classes found in scanned modules") + else: + for issue in issues: + print(issue) + + +def ignore_name(name: str) -> bool: + if "." in name and any(ignore_name(n) for n in name.split(".")): + return True + return ( + name in IGNORED + or name.startswith("_") + or name.endswith("FieldConfig") + or name.endswith("FieldOptions") + or name.endswith("FieldSchema") + ) + + +class ApiData(Dict[str, Any]): + """ + Wrapper around ``dict`` that adds convenient behavior for reading the API definition. + """ + + def __getitem__(self, key: str) -> Any: + # handy shortcuts + if key == "operations": + return self.by_operation + if key == "schemas": + return self.by_model_name + return super().__getitem__(key) + + def get_nested(self, path: str, separator: str = ":") -> Any: + """ + Retrieves nested objects with a path-like syntax. + """ + get_from = self + traversed = [] + while separator in path: + next_key, path = path.split(separator, 1) + traversed.append(next_key) + try: + get_from = get_from[next_key] + except KeyError: + raise KeyError(*traversed) + return get_from[path] + + @cached_property + def by_operation(self) -> Dict[str, Dict[str, Any]]: + """ + Simplifies traversal of request/response information for defined web API operations, + grouping them by the operation name instead of path/method. + """ + result: Dict[str, Dict[str, Any]] = {} + paths: Dict[str, Dict[str, Any]] = self["openApi"]["paths"] + methodinfo_dicts = [ + methodinfo + for pathinfo in paths.values() + for methodinfo in pathinfo.values() + if isinstance(methodinfo, dict) + ] + for methodinfo in methodinfo_dicts: + methodname = str(methodinfo["operationId"]).lower() + r = result[methodname] = {} + try: + r["response"] = methodinfo["responses"]["200"]["content"]["application/json"] # fmt: skip + except KeyError: + pass + try: + r["request"] = methodinfo["requestBody"]["content"]["application/json"] # fmt: skip + except KeyError: + pass + + return result + + @cached_property + def by_model_name(self) -> Dict[str, Dict[str, Any]]: + """ + Simplifies traversal of schema information by preemptively collapsing + anyOf models + """ + return { + key: self.collapse_schema(self.get_model(name)) + for name in self["openApi"]["components"]["schemas"] + for key in (str(name), str(name).lower()) + } + + def get_model(self, name: str) -> Dict[str, Any]: + """ + Retrieve a model schema by name. + """ + return self.collapse_schema( + self.get_nested(f"openApi:components:schemas:{name}") + ) + + def collapse_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Merge together properties of all entries in anyOf or allOf schemas. + This is acceptable for our use case, but a bad idea in most other cases. + """ + if set(schema) == {"$ref"}: + if (ref := schema["$ref"]).startswith("#/components/schemas/"): + return self.collapse_schema(self.get_model(ref.split("/")[-1])) + raise ValueError(f"unhandled $ref: {ref}") + + for key in ("anyOf", "allOf"): + if key not in schema: + continue + collected_properties = {} + subschema: Dict[str, Any] + for subschema in list(schema[key]): + if subschema.get("type") == "object" or "$ref" in subschema: + collected_properties.update( + self.collapse_schema(subschema).get("properties", {}) + ) + return {"properties": collected_properties} + + return schema + + +def get_api_data() -> ApiData: + """ + Retrieve API information. + """ + response = requests.get(API_INTRO) + response.raise_for_status() + match = re.search(INITDATA_RE, response.text) + if not match: + raise RuntimeError(f"could not find {INITDATA_RE!r} in {API_INTRO}") + return ApiData(json.loads(match.group(1))) + + +def scan_schema(cls: Type[AirtableModel], schema: Dict[str, Any]) -> Iterator[str]: + """ + Yield error messages for missing or undocumented fields. + """ + + name = f"{cls.__module__}.{cls.__qualname__}" + model_aliases = {f.alias for f in cls.model_fields.values() if f.alias} + api_properties = set(schema["properties"]) + missing_keys = api_properties - model_aliases + extra_keys = model_aliases - api_properties + for missing_key in missing_keys: + if not ignore_name(f"{name}.{missing_key}"): + yield f"{name} is missing field: {missing_key}" + for extra_key in extra_keys: + if not ignore_name(f"{name}.{extra_key}"): + yield (f"{name} has undocumented field: {extra_key}") + + +def scan_missing(container: Any, prefix: str) -> Iterator[str]: + """ + Yield error messages for models within the given container which were not scanned. + """ + for name, obj in vars(container).items(): + if not isinstance(obj, type) or not issubclass(obj, AirtableModel): + continue + # ignore imported models in other modules + if not prefix.startswith(obj.__module__): + continue + if ignore_name(f"{obj.__module__}.{obj.__qualname__}"): + continue + if (subpath := f"{prefix}{name}") not in SCAN_MODELS: + yield f"{subpath} was not scanned" + yield from scan_missing(obj, prefix=(subpath + ".")) + + +if __name__ == "__main__": + main() diff --git a/tox.ini b/tox.ini index f25eb585..eef75d1a 100644 --- a/tox.ini +++ b/tox.ini @@ -38,7 +38,7 @@ basepython = py312: python3.12 py313: python3.13 deps = -r requirements-dev.txt -commands = mypy --strict pyairtable tests/test_typing.py +commands = mypy --strict pyairtable scripts tests/test_typing.py [testenv:integration] commands = From 29e1a6a259f9b427664d49c58b352449da69e889 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 2 Nov 2024 15:46:11 -0700 Subject: [PATCH 221/272] Add missing model fields --- pyairtable/models/_base.py | 2 +- pyairtable/models/comment.py | 32 ++++++++++++++++++++++++++- pyairtable/models/schema.py | 7 ++++++ pyairtable/models/webhook.py | 10 ++++++--- tests/sample_data/Comment.json | 12 +++++++++- tests/sample_data/EnterpriseInfo.json | 1 + tests/sample_data/UserInfo.json | 1 + tests/test_api_base.py | 1 + tests/test_models_comment.py | 10 +++++---- 9 files changed, 66 insertions(+), 10 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 526723a0..0db55ffb 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -24,7 +24,7 @@ class AirtableModel(pydantic.BaseModel): populate_by_name=True, ) - _raw: Any = pydantic.PrivateAttr() + _raw: Dict[str, Any] = pydantic.PrivateAttr() def __init__(self, **data: Any) -> None: raw = data.copy() diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index e9566320..8cd15238 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Dict, Optional +from typing import Dict, List, Optional import pydantic @@ -62,6 +62,12 @@ class Comment( #: Users or groups that were mentioned in the text. mentioned: Dict[str, "Mentioned"] = pydantic.Field(default_factory=dict) + #: The comment ID of the parent comment, if this comment is a threaded reply. + parent_comment_id: Optional[str] = None + + #: List of reactions to this comment. + reactions: List["Reaction"] = pydantic.Field(default_factory=list) + class Mentioned(AirtableModel): """ @@ -88,4 +94,28 @@ class Mentioned(AirtableModel): email: Optional[str] = None +class Reaction(AirtableModel): + """ + A reaction to a comment. + """ + + class EmojiInfo(AirtableModel): + unicode_character: str + + class ReactingUser(AirtableModel): + user_id: str + email: Optional[str] = None + name: Optional[str] = None + + emoji_info: EmojiInfo = pydantic.Field(alias="emoji") + reacting_user: ReactingUser + + @property + def emoji(self) -> str: + """ + The emoji character used for the reaction. + """ + return chr(int(self.emoji_info.unicode_character, 16)) + + rebuild_models(vars()) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 5bfb1628..9727276d 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -168,6 +168,7 @@ class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"): id: str name: str + created_time: datetime permission_level: str workspace_id: str interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD() @@ -179,6 +180,8 @@ class InterfaceCollaborators( _Collaborators, url="meta/bases/{base.id}/interfaces/{key}", ): + id: str + name: str created_time: datetime first_publish_time: Optional[datetime] = None group_collaborators: List["GroupCollaborator"] = _FL() @@ -219,6 +222,7 @@ class Info( created_time: datetime share_id: str type: str + can_be_synced: Optional[bool] = None is_password_protected: bool block_installation_id: Optional[str] = None restricted_to_email_domains: List[str] = _FL() @@ -434,6 +438,8 @@ class EnterpriseInfo(AirtableModel): user_ids: List[str] workspace_ids: List[str] email_domains: List["EnterpriseInfo.EmailDomain"] + root_enterprise_id: str = pydantic.Field(alias="rootEnterpriseAccountId") + descendant_enterprise_ids: List[str] = _FL(alias="descendantEnterpriseAccountIds") class EmailDomain(AirtableModel): email_domain: str @@ -559,6 +565,7 @@ class UserInfo( name: str email: str state: str + is_service_account: bool is_sso_required: bool is_two_factor_auth_enabled: bool last_activity_time: Optional[datetime] = None diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 9a93284c..4ec32d13 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -270,10 +270,14 @@ class Filters(AirtableModel): watch_schemas_of_field_ids: List[str] = FL() class SourceOptions(AirtableModel): - form_submission: Optional["WebhookSpecification.FormSubmission"] = None + form_submission: Optional["FormSubmission"] = None + form_page_submission: Optional["FormPageSubmission"] = None - class FormSubmission(AirtableModel): - view_id: str + class FormSubmission(AirtableModel): + view_id: str + + class FormPageSubmission(AirtableModel): + page_id: str class Includes(AirtableModel): include_cell_values_in_field_ids: List[str] = FL() diff --git a/tests/sample_data/Comment.json b/tests/sample_data/Comment.json index b5bc4dcc..21336ae0 100644 --- a/tests/sample_data/Comment.json +++ b/tests/sample_data/Comment.json @@ -14,5 +14,15 @@ "email": "alice@example.com", "type": "user" } - } + }, + "parentCommentId": "comkNDICXNqxSDhGL", + "reactions": [ + { + "emoji": {"unicodeCharacter": "1f44d"}, + "reactingUser": { + "userId": "usr0000000reacted", + "email": "carol@example.com" + } + } + ] } diff --git a/tests/sample_data/EnterpriseInfo.json b/tests/sample_data/EnterpriseInfo.json index 02ebde50..4ff1caa8 100644 --- a/tests/sample_data/EnterpriseInfo.json +++ b/tests/sample_data/EnterpriseInfo.json @@ -11,6 +11,7 @@ "ugpR8ZT9KtIgp8Bh3" ], "id": "entUBq2RGdihxl3vU", + "rootEnterpriseAccountId": "entUBq2RGdihxl3vU", "userIds": [ "usrL2PNC5o3H4lBEi", "usrsOEchC9xuwRgKk", diff --git a/tests/sample_data/UserInfo.json b/tests/sample_data/UserInfo.json index 2097ffe3..359dee0b 100644 --- a/tests/sample_data/UserInfo.json +++ b/tests/sample_data/UserInfo.json @@ -39,6 +39,7 @@ "id": "usrL2PNC5o3H4lBEi", "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk", "isManaged": true, + "isServiceAccount": false, "isSsoRequired": true, "isTwoFactorAuthEnabled": false, "lastActivityTime": "2019-01-03T12:33:12.421Z", diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 4fadda8d..cb3c05ce 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -190,6 +190,7 @@ def test_name(api, base, requests_mock): base.meta_url(), json={ "id": base.id, + "createdTime": "2021-01-01T00:00:00.000Z", "name": "Mocked Base Name", "permissionLevel": "create", "workspaceId": "wspFake", diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py index 2f597472..aba8e434 100644 --- a/tests/test_models_comment.py +++ b/tests/test_models_comment.py @@ -25,10 +25,12 @@ def comments_url(base, table): return f"https://api.airtable.com/v0/{base.id}/{table.name}/{RECORD_ID}/comments" -def test_parse(comment_json): - c = Comment.model_validate(comment_json) - assert isinstance(c.created_time, datetime.datetime) - assert isinstance(c.last_updated_time, datetime.datetime) +def test_parse(comment): + assert isinstance(comment.created_time, datetime.datetime) + assert isinstance(comment.last_updated_time, datetime.datetime) + assert comment.author.id == "usrLkNDICXNqxSDhG" + assert comment.mentioned["usr00000mentioned"].display_name == "Alice Doe" + assert comment.reactions[0].emoji == "๐Ÿ‘" def test_missing_attributes(comment_json): From 57600d942f4e39b78f10a962f5975286187db5af Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 7 Nov 2024 21:48:02 -0800 Subject: [PATCH 222/272] WebhookSpecification.options.includes.include_cell_values_in_field_ids --- pyairtable/models/webhook.py | 4 ++-- tests/sample_data/Webhook.json | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 4ec32d13..e457d2c0 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -2,7 +2,7 @@ from datetime import datetime from functools import partial from hmac import HMAC -from typing import Any, Callable, Dict, Iterator, List, Optional, Union +from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Union import pydantic from typing_extensions import Self as SelfType @@ -280,7 +280,7 @@ class FormPageSubmission(AirtableModel): page_id: str class Includes(AirtableModel): - include_cell_values_in_field_ids: List[str] = FL() + include_cell_values_in_field_ids: Union[None, List[str], Literal["all"]] = None include_previous_cell_values: bool = False include_previous_field_definitions: bool = False diff --git a/tests/sample_data/Webhook.json b/tests/sample_data/Webhook.json index 9185589c..839917e2 100644 --- a/tests/sample_data/Webhook.json +++ b/tests/sample_data/Webhook.json @@ -20,7 +20,24 @@ "options": { "filters": { "dataTypes": ["tableData"], - "recordChangeScope": "tbltp8DGLhqbUmjK1" + "changeTypes": ["add", "remove", "update"], + "fromSources": ["client"], + "recordChangeScope": "tbltp8DGLhqbUmjK1", + "sourceOptions": { + "formPageSubmission": { + "pageId": "pbdLkNDICXNqxSDhG" + }, + "formSubmission": { + "viewId": "viwLkNDICXNqxSDhG" + } + }, + "watchDataInFieldIds": ["fldLkNDICXNqxSDhG"], + "watchSchemasOfFieldIds": ["fldLkNDICXNqxSDhG"] + }, + "includes": { + "includeCellValuesInFieldIds": "all", + "includePreviousCellValues": false, + "includePreviousFieldDefinitions": false } } } From 067086f76ee523e2aa1e1a088ce0f52813b7bc5c Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 21:46:14 -0800 Subject: [PATCH 223/272] Fix enterprise.info and enterprise.users after new API params --- pyairtable/api/enterprise.py | 57 ++++++++++++++-- pyairtable/models/schema.py | 22 +++++++ pyairtable/utils.py | 4 +- scripts/find_model_changes.py | 32 +++++---- tests/sample_data/EnterpriseInfo.json | 3 + tests/sample_data/UserInfo.json | 3 +- tests/test_api_enterprise.py | 94 ++++++++++++++++++++++++++- 7 files changed, 191 insertions(+), 24 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 7fedcfd5..d897129d 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -81,11 +81,26 @@ def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): self._info: Optional[EnterpriseInfo] = None @cache_unless_forced - def info(self) -> EnterpriseInfo: + def info( + self, + *, + aggregated: bool = False, + descendants: bool = False, + ) -> EnterpriseInfo: """ Retrieve basic information about the enterprise, caching the result. + Calls `Get enterprise `__. + + Args: + aggregated: if ``True``, include aggregated values across the enterprise. + descendants: if ``True``, include information about the enterprise's descendant orgs. """ - params = {"include": ["collaborators", "inviteLinks"]} + include = [] + if aggregated: + include.append("aggregated") + if descendants: + include.append("descendants") + params = {"include": include} response = self.api.get(self.urls.meta, params=params) return EnterpriseInfo.from_api(response, self.api) @@ -102,7 +117,14 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup: payload = self.api.get(self.urls.group(group_id), params=params) return UserGroup.model_validate(payload) - def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: + def user( + self, + id_or_email: str, + *, + collaborations: bool = True, + aggregated: bool = False, + descendants: bool = False, + ) -> UserInfo: """ Retrieve information on a single user with the given ID or email. @@ -110,13 +132,26 @@ def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address. collaborations: If ``False``, no collaboration data will be requested from Airtable. This may result in faster responses. + aggregated: If ``True``, includes the user's aggregated values + across this enterprise account and its descendants. + descendants: If ``True``, includes information about the user + in a ``dict`` keyed per descendant enterprise account. """ - return self.users([id_or_email], collaborations=collaborations)[0] + users = self.users( + [id_or_email], + collaborations=collaborations, + aggregated=aggregated, + descendants=descendants, + ) + return users[0] def users( self, ids_or_emails: Iterable[str], + *, collaborations: bool = True, + aggregated: bool = False, + descendants: bool = False, ) -> List[UserInfo]: """ Retrieve information on the users with the given IDs or emails. @@ -128,18 +163,30 @@ def users( or email addresses (or both). collaborations: If ``False``, no collaboration data will be requested from Airtable. This may result in faster responses. + aggregated: If ``True``, includes the user's aggregated values + across this enterprise account and its descendants. + descendants: If ``True``, includes information about the user + in a ``dict`` keyed per descendant enterprise account. """ user_ids: List[str] = [] emails: List[str] = [] for value in ids_or_emails: (emails if "@" in value else user_ids).append(value) + include = [] + if collaborations: + include.append("collaborations") + if aggregated: + include.append("aggregated") + if descendants: + include.append("descendants") + response = self.api.get( url=self.urls.users, params={ "id": user_ids, "email": emails, - "include": ["collaborations"] if collaborations else [], + "include": include, }, ) # key by user ID to avoid returning duplicates diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 4ac071bc..c7155ba7 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -440,11 +440,18 @@ class EnterpriseInfo(AirtableModel): email_domains: List["EnterpriseInfo.EmailDomain"] root_enterprise_id: str = pydantic.Field(alias="rootEnterpriseAccountId") descendant_enterprise_ids: List[str] = _FL(alias="descendantEnterpriseAccountIds") + aggregated: Optional["EnterpriseInfo.AggregatedIds"] = None + descendants: Dict[str, "EnterpriseInfo.AggregatedIds"] = _FD() class EmailDomain(AirtableModel): email_domain: str is_sso_required: bool + class AggregatedIds(AirtableModel): + group_ids: List[str] = _FL() + user_ids: List[str] = _FL() + workspace_ids: List[str] = _FL() + class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): """ @@ -577,10 +584,25 @@ class UserInfo( is_super_admin: bool = False groups: List[NestedId] = _FL() collaborations: "Collaborations" = _F("Collaborations") + descendants: Dict[str, "UserInfo.DescendantIds"] = _FD() + aggregated: Optional["UserInfo.AggregatedIds"] = None def logout(self) -> None: self._api.post(self._url + "/logout") + class DescendantIds(AirtableModel): + last_activity_time: Optional[datetime] = None + collaborations: Optional["Collaborations"] = None + is_admin: bool = False + is_managed: bool = False + groups: List[NestedId] = _FL() + + class AggregatedIds(AirtableModel): + last_activity_time: Optional[datetime] = None + collaborations: Optional["Collaborations"] = None + is_admin: bool = False + groups: List[NestedId] = _FL() + class UserGroup(AirtableModel): """ diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 4b6e66fb..a08d0259 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -233,9 +233,9 @@ def cache_unless_forced(func: Callable[[C], R]) -> _FetchMethod[C, R]: attr = "_cached_" + attr.lstrip("_") @wraps(func) - def _inner(self: C, *, force: bool = False) -> R: + def _inner(self: C, *, force: bool = False, **kwargs: Any) -> R: if force or getattr(self, attr, None) is None: - setattr(self, attr, func(self)) + setattr(self, attr, func(self, **kwargs)) return cast(R, getattr(self, attr)) _inner.__annotations__["force"] = bool diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index 2988d7f6..b82d7d60 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -38,7 +38,7 @@ "pyairtable.models.schema:BaseCollaborators": "operations:get-base-collaborators:response:schema", "pyairtable.models.schema:BaseCollaborators.IndividualCollaborators": "operations:get-base-collaborators:response:schema:@individualCollaborators", "pyairtable.models.schema:BaseCollaborators.GroupCollaborators": "operations:get-base-collaborators:response:schema:@groupCollaborators", - "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:additionalProperties", + "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:*", "pyairtable.models.schema:BaseCollaborators.InviteLinks": "operations:get-base-collaborators:response:schema:@inviteLinks", "pyairtable.models.schema:BaseShares": "operations:list-shares:response:schema", "pyairtable.models.schema:BaseShares.Info": "operations:list-shares:response:schema:@shares:items", @@ -48,6 +48,7 @@ "pyairtable.models.schema:InterfaceInviteLink": "schemas:invite-link", "pyairtable.models.schema:EnterpriseInfo": "operations:get-enterprise:response:schema", "pyairtable.models.schema:EnterpriseInfo.EmailDomain": "operations:get-enterprise:response:schema:@emailDomains:items", + "pyairtable.models.schema:EnterpriseInfo.AggregatedIds": "operations:get-enterprise:response:schema:@aggregated", "pyairtable.models.schema:WorkspaceCollaborators": "operations:get-workspace-collaborators:response:schema", "pyairtable.models.schema:WorkspaceCollaborators.Restrictions": "operations:get-workspace-collaborators:response:schema:@workspaceRestrictions", "pyairtable.models.schema:WorkspaceCollaborators.GroupCollaborators": "operations:get-workspace-collaborators:response:schema:@groupCollaborators", @@ -63,6 +64,8 @@ "pyairtable.models.schema:Collaborations.InterfaceCollaboration": "schemas:collaborations:@interfaceCollaborations:items", "pyairtable.models.schema:Collaborations.WorkspaceCollaboration": "schemas:collaborations:@workspaceCollaborations:items", "pyairtable.models.schema:UserInfo": "operations:get-user-by-id:response:schema", + "pyairtable.models.schema:UserInfo.AggregatedIds": "operations:get-user-by-id:response:schema:@aggregated", + "pyairtable.models.schema:UserInfo.DescendantIds": "operations:get-user-by-id:response:schema:@descendants:*", "pyairtable.models.schema:UserGroup": "operations:get-user-group:response:schema", "pyairtable.models.schema:UserGroup.Member": "operations:get-user-group:response:schema:@members:items", "pyairtable.models.webhook:Webhook": "operations:list-webhooks:response:schema:@webhooks:items", @@ -71,15 +74,15 @@ "pyairtable.models.webhook:WebhookPayloads": "operations:list-webhook-payloads:response:schema", "pyairtable.models.webhook:WebhookPayload": "schemas:webhooks-payload", "pyairtable.models.webhook:WebhookPayload.ActionMetadata": "schemas:webhooks-action", - "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties", - "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties:@current", - "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:additionalProperties", - "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:*", + "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:*:@current", + "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:*", + "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:*", "pyairtable.models.webhook:WebhookPayload.TableChanged": "schemas:webhooks-table-changed", "pyairtable.models.webhook:WebhookPayload.TableChanged.ChangedMetadata": "schemas:webhooks-table-changed:@changedMetadata", "pyairtable.models.webhook:WebhookPayload.TableInfo": "schemas:webhooks-table-changed:@changedMetadata:@current", "pyairtable.models.webhook:WebhookPayload.TableCreated": "schemas:webhooks-table-created", - "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:*", "pyairtable.models.webhook:CreateWebhook": "operations:create-a-webhook:request:schema", "pyairtable.models.webhook:CreateWebhookResponse": "operations:create-a-webhook:response:schema", "pyairtable.models.webhook:WebhookSpecification": "operations:create-a-webhook:request:schema:@specification", @@ -115,6 +118,7 @@ def main() -> None: model_module = importlib.import_module(modname) model_cls = attrgetter(clsname)(model_module) initdata_path = initdata_path.replace(":@", ":properties:") + initdata_path = re.sub(r":\*(:|$)", r":additionalProperties\1", initdata_path) issues.extend(scan_schema(model_cls, initdata.get_nested(initdata_path))) if not issues: @@ -169,14 +173,16 @@ def get_nested(self, path: str, separator: str = ":") -> Any: """ get_from = self traversed = [] - while separator in path: - next_key, path = path.split(separator, 1) - traversed.append(next_key) - try: + try: + while separator in path: + next_key, path = path.split(separator, 1) + traversed.append(next_key) get_from = get_from[next_key] - except KeyError: - raise KeyError(*traversed) - return get_from[path] + traversed.append(path) + return get_from[path] + except KeyError as exc: + exc.args = tuple(traversed) + raise exc @cached_property def by_operation(self) -> Dict[str, Dict[str, Any]]: diff --git a/tests/sample_data/EnterpriseInfo.json b/tests/sample_data/EnterpriseInfo.json index 4ff1caa8..bd848286 100644 --- a/tests/sample_data/EnterpriseInfo.json +++ b/tests/sample_data/EnterpriseInfo.json @@ -1,5 +1,8 @@ { "createdTime": "2019-01-03T12:33:12.421Z", + "descendantEnterpriseAccountIds": [ + "entJ9ZQ5vz9ZQ5vz9" + ], "emailDomains": [ { "emailDomain": "foobar.com", diff --git a/tests/sample_data/UserInfo.json b/tests/sample_data/UserInfo.json index 359dee0b..71bf12f4 100644 --- a/tests/sample_data/UserInfo.json +++ b/tests/sample_data/UserInfo.json @@ -12,7 +12,7 @@ { "baseId": "appLkNDICXNqxSDhG", "createdTime": "2019-01-03T12:33:12.421Z", - "grantedByUserId": "usrqccqnMB2eHylqB", + "grantedByUserId": "usrogvSbotRtzdtZW", "interfaceId": "pbdyGA3PsOziEHPDE", "permissionLevel": "edit" } @@ -38,6 +38,7 @@ ], "id": "usrL2PNC5o3H4lBEi", "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk", + "isAdmin": true, "isManaged": true, "isServiceAccount": false, "isSsoRequired": true, diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 86555b4d..f202039e 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timezone from unittest.mock import Mock, call, patch import pytest @@ -17,11 +17,12 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): m.json_user = sample_json("UserInfo") m.json_users = {"users": [m.json_user]} m.json_group = sample_json("UserGroup") + m.json_enterprise = sample_json("EnterpriseInfo") m.user_id = m.json_user["id"] m.group_id = m.json_group["id"] m.get_info = requests_mock.get( enterprise_url := "https://api.airtable.com/v0/meta/enterpriseAccounts/entUBq2RGdihxl3vU", - json=sample_json("EnterpriseInfo"), + json=m.json_enterprise, ) m.get_user = requests_mock.get( f"{enterprise_url}/users/usrL2PNC5o3H4lBEi", @@ -64,7 +65,7 @@ def fake_audit_log_events(counter, page_size=N_AUDIT_PAGE_SIZE): return [ { "id": str(counter * 1000 + n), - "timestamp": datetime.datetime.now().isoformat(), + "timestamp": datetime.now().isoformat(), "action": "viewBase", "actor": {"type": "anonymousUser"}, "modelId": (base_id := fake_id("app")), @@ -92,6 +93,35 @@ def test_info(enterprise, enterprise_mocks): assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU" assert enterprise_mocks.get_info.call_count == 2 + assert "aggregated" not in enterprise_mocks.get_info.last_request.qs + assert "descendants" not in enterprise_mocks.get_info.last_request.qs + + +def test_info__aggregated_descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_enterprise["aggregated"] = { + "groupIds": ["ugp1mKGb3KXUyQfOZ"], + "userIds": ["usrL2PNC5o3H4lBEi"], + "workspaceIds": ["wspmhESAta6clCCwF"], + } + enterprise_mocks.json_enterprise["descendants"] = { + (sub_ent_id := fake_id("ent")): { + "groupIds": ["ugp1mKGb3KXUyDESC"], + "userIds": ["usrL2PNC5o3H4DESC"], + "workspaceIds": ["wspmhESAta6clDESC"], + } + } + info = enterprise.info(aggregated=True, descendants=True) + assert enterprise_mocks.get_info.call_count == 1 + assert enterprise_mocks.get_info.last_request.qs["include"] == [ + "aggregated", + "descendants", + ] + assert info.aggregated.group_ids == ["ugp1mKGb3KXUyQfOZ"] + assert info.aggregated.user_ids == ["usrL2PNC5o3H4lBEi"] + assert info.aggregated.workspace_ids == ["wspmhESAta6clCCwF"] + assert info.descendants[sub_ent_id].group_ids == ["ugp1mKGb3KXUyDESC"] + assert info.descendants[sub_ent_id].user_ids == ["usrL2PNC5o3H4DESC"] + assert info.descendants[sub_ent_id].workspace_ids == ["wspmhESAta6clDESC"] def test_user(enterprise, enterprise_mocks): @@ -117,6 +147,34 @@ def test_user__no_collaboration(enterprise, enterprise_mocks): assert not user.collaborations.workspaces +def test_user__descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["descendants"] = { + (other_ent_id := fake_id("ent")): { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + } + user = enterprise.user(enterprise_mocks.user_id, descendants=True) + d = user.descendants[other_ent_id] + assert d.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert d.is_admin is True + assert d.groups[0].id == fake_group_id + + +def test_user__aggregates(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["aggregated"] = { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + user = enterprise.user(enterprise_mocks.user_id, aggregated=True) + a = user.aggregated + assert a.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert a.is_admin is True + assert a.groups[0].id == fake_group_id + + @pytest.mark.parametrize( "search_for", ( @@ -133,6 +191,36 @@ def test_users(enterprise, search_for): assert user.state == "provisioned" +def test_users__descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["descendants"] = { + (other_ent_id := fake_id("ent")): { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + } + users = enterprise.users([enterprise_mocks.user_id], descendants=True) + assert len(users) == 1 + d = users[0].descendants[other_ent_id] + assert d.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert d.is_admin is True + assert d.groups[0].id == fake_group_id + + +def test_users__aggregates(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["aggregated"] = { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + users = enterprise.users([enterprise_mocks.user_id], aggregated=True) + assert len(users) == 1 + a = users[0].aggregated + assert a.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert a.is_admin is True + assert a.groups[0].id == fake_group_id + + def test_group(enterprise, enterprise_mocks): grp = enterprise.group("ugp1mKGb3KXUyQfOZ") assert enterprise_mocks.get_group.call_count == 1 From d4c44bec9e9e78feadf31b7f5923c79197c5f9bc Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:30:18 -0800 Subject: [PATCH 224/272] Scan enterprise models for missing fields --- pyairtable/api/enterprise.py | 14 +++++++++++--- scripts/find_model_changes.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d897129d..cf93fa78 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,4 +1,4 @@ -import datetime +from datetime import date, datetime from functools import cached_property, partialmethod from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union @@ -205,8 +205,8 @@ def audit_log( sort_asc: Optional[bool] = False, previous: Optional[str] = None, next: Optional[str] = None, - start_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, - end_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, + start_time: Optional[Union[str, date, datetime]] = None, + end_time: Optional[Union[str, date, datetime]] = None, user_id: Optional[Union[str, Iterable[str]]] = None, event_type: Optional[Union[str, Iterable[str]]] = None, model_id: Optional[Union[str, Iterable[str]]] = None, @@ -436,6 +436,8 @@ class Workspace(AirtableModel): workspace_id: str workspace_name: str user_id: str = "" + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Unshared(AirtableModel): bases: List["UserRemoved.Unshared.Base"] @@ -447,6 +449,8 @@ class Base(AirtableModel): base_id: str base_name: str former_permission_level: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Interface(AirtableModel): user_id: str @@ -454,12 +458,16 @@ class Interface(AirtableModel): interface_id: str interface_name: str former_permission_level: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Workspace(AirtableModel): user_id: str former_permission_level: str workspace_id: str workspace_name: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class DeleteUsersResponse(AirtableModel): diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index b82d7d60..e2e0396c 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -19,6 +19,18 @@ INITDATA_RE = r"]*>\s*window\.initData = (\{.*\})\s*" SCAN_MODELS = { + "pyairtable.api.enterprise:UserRemoved": "operations:remove-user-from-enterprise:response:schema", + "pyairtable.api.enterprise:UserRemoved.Shared": "operations:remove-user-from-enterprise:response:schema:@shared", + "pyairtable.api.enterprise:UserRemoved.Shared.Workspace": "operations:remove-user-from-enterprise:response:schema:@shared:@workspaces:items", + "pyairtable.api.enterprise:UserRemoved.Unshared": "operations:remove-user-from-enterprise:response:schema:@unshared", + "pyairtable.api.enterprise:UserRemoved.Unshared.Base": "operations:remove-user-from-enterprise:response:schema:@unshared:@bases:items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Interface": "operations:remove-user-from-enterprise:response:schema:@unshared:@interfaces:items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Workspace": "operations:remove-user-from-enterprise:response:schema:@unshared:@workspaces:items", + "pyairtable.api.enterprise:DeleteUsersResponse": "operations:delete-users-by-email:response:schema", + "pyairtable.api.enterprise:DeleteUsersResponse.UserInfo": "operations:delete-users-by-email:response:schema:@deletedUsers:items", + "pyairtable.api.enterprise:DeleteUsersResponse.Error": "operations:delete-users-by-email:response:schema:@errors:items", + "pyairtable.api.enterprise:ManageUsersResponse": "operations:manage-user-membership:response:schema", + "pyairtable.api.enterprise:ManageUsersResponse.Error": "operations:manage-user-membership:response:schema:@errors:items", "pyairtable.models.audit:AuditLogResponse": "operations:audit-log-events:response:schema", "pyairtable.models.audit:AuditLogEvent": "operations:audit-log-events:response:schema:@events:items", "pyairtable.models.audit:AuditLogEvent.Context": "operations:audit-log-events:response:schema:@events:items:@context", From 39386eb0fc4dbd90d15d730f331b998f5eb83996 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:33:37 -0800 Subject: [PATCH 225/272] Add `descendants=` kwarg to Enterprise.remove_user --- pyairtable/api/enterprise.py | 5 +++++ tests/test_api_enterprise.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index cf93fa78..d3e8ac52 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -323,6 +323,8 @@ def remove_user( self, user_id: str, replacement: Optional[str] = None, + *, + descendants: bool = False, ) -> "UserRemoved": """ Unshare a user from all enterprise workspaces, bases, and interfaces. @@ -337,11 +339,14 @@ def remove_user( specify a replacement user ID to be added as the new owner of such workspaces. If the user is not the sole owner of any workspaces, this is optional and will be ignored if provided. + descendants: If ``True``, removes the user from descendant enterprise accounts. """ url = self.urls.remove_user(user_id) payload: Dict[str, Any] = {"isDryRun": False} if replacement: payload["replacementOwnerId"] = replacement + if descendants: + payload["removeFromDescendants"] = True response = self.api.post(url, json=payload) return UserRemoved.from_api(response, self.api, context=self) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index f202039e..2f11e1ef 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -321,6 +321,10 @@ def test_audit_log__sortorder( {"replacement": "otherUser"}, {"isDryRun": False, "replacementOwnerId": "otherUser"}, ), + ( + {"descendants": True}, + {"isDryRun": False, "removeFromDescendants": True}, + ), ], ) def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): From 62b68b6f8f10be1a1554b66a30decd9f39cd8e32 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:39:24 -0800 Subject: [PATCH 226/272] Enterprise.create_descendant --- pyairtable/api/enterprise.py | 18 ++++++++++++++++++ tests/test_api_enterprise.py | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d3e8ac52..dcf87fdb 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union import pydantic +from typing_extensions import Self from pyairtable.models._base import AirtableModel, rebuild_models from pyairtable.models.audit import AuditLogResponse @@ -43,6 +44,9 @@ class _urls(UrlBuilder): #: URL for retrieving audit log events. audit_log = meta / "auditLogEvents" + #: URL for managing descendant enterprise accounts. + descendants = meta / "descendants" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -422,6 +426,20 @@ def _post_admin_access( ) return ManageUsersResponse.from_api(response, self.api, context=self) + def create_descendant(self, name: str) -> Self: + """ + Creates a descendant enterprise account of the enterprise account. + Descendant enterprise accounts can only be created for root enterprise accounts + with the Enterprise Hub feature enabled. + + See `Create descendant enterprise `__. + + Args: + name: The name to give the new account. + """ + response = self.api.post(self.urls.descendants, json={"name": name}) + return self.__class__(self.api, response["id"]) + class UserRemoved(AirtableModel): """ diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 2f11e1ef..edc5294a 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -3,7 +3,11 @@ import pytest -from pyairtable.api.enterprise import DeleteUsersResponse, ManageUsersResponse +from pyairtable.api.enterprise import ( + DeleteUsersResponse, + Enterprise, + ManageUsersResponse, +) from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.testing import fake_id @@ -418,3 +422,12 @@ def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action {"id": user.id}, ] } + + +def test_create_descendant(enterprise, requests_mock): + sub_ent_id = fake_id("ent") + m = requests_mock.post(enterprise.urls.descendants, json={"id": sub_ent_id}) + descendant = enterprise.create_descendant("Some name") + assert m.call_count == 1 + assert m.last_request.json() == {"name": "Some name"} + assert isinstance(descendant, Enterprise) From 8c331aad1ee86b8ce91d6ae7ba90795d08f45b2f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:55:13 -0800 Subject: [PATCH 227/272] Enterprise.move_groups --- pyairtable/api/enterprise.py | 46 +++++++++++++++++++++++++++++++++++- tests/test_api_enterprise.py | 38 +++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index dcf87fdb..7b970cb0 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -7,7 +7,7 @@ from pyairtable.models._base import AirtableModel, rebuild_models from pyairtable.models.audit import AuditLogResponse -from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo +from pyairtable.models.schema import EnterpriseInfo, NestedId, UserGroup, UserInfo from pyairtable.utils import ( Url, UrlBuilder, @@ -47,6 +47,9 @@ class _urls(UrlBuilder): #: URL for managing descendant enterprise accounts. descendants = meta / "descendants" + #: URL for moving user groups between enterprise accounts. + move_groups = meta / "moveGroups" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -440,6 +443,32 @@ def create_descendant(self, name: str) -> Self: response = self.api.post(self.urls.descendants, json={"name": name}) return self.__class__(self.api, response["id"]) + def move_groups( + self, + group_ids: Iterable[str], + target: Union[str, Self], + ) -> "MoveGroupsResponse": + """ + Move one or more user groups from the current enterprise account + into a different enterprise account within the same organization. + + See `Move user groups `__. + + Args: + group_ids: User group IDs. + target: The ID of the target enterprise, or an instance of :class:`~pyairtable.Enterprise`. + """ + if isinstance(target, Enterprise): + target = target.id + response = self.api.post( + self.urls.move_groups, + json={ + "groupIds": group_ids, + "targetEnterpriseAccountId": target, + }, + ) + return MoveGroupsResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): """ @@ -529,6 +558,21 @@ class Error(AirtableModel): message: str +class MoveError(AirtableModel): + id: str + type: str + message: str + + +class MoveGroupsResponse(AirtableModel): + """ + Returned by `Move user groups `__. + """ + + moved_groups: List[NestedId] = pydantic.Field(default_factory=list) + errors: List[MoveError] = pydantic.Field(default_factory=list) + + rebuild_models(vars()) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index edc5294a..93f8bad2 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -62,6 +62,17 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): f"{enterprise_url}/claim/users", json={"errors": []}, ) + m.create_descendants = requests_mock.post( + f"{enterprise_url}/descendants", json={"id": fake_id("ent")} + ) + m.move_groups_json = {} + m.move_groups = requests_mock.post( + f"{enterprise_url}/moveGroups", json=m.move_groups_json + ) + m.move_workspaces_json = {} + m.move_workspaces = requests_mock.post( + f"{enterprise_url}/moveWorkspaces", json=m.move_workspaces_json + ) return m @@ -424,10 +435,27 @@ def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action } -def test_create_descendant(enterprise, requests_mock): - sub_ent_id = fake_id("ent") - m = requests_mock.post(enterprise.urls.descendants, json={"id": sub_ent_id}) +def test_create_descendant(enterprise, enterprise_mocks): descendant = enterprise.create_descendant("Some name") - assert m.call_count == 1 - assert m.last_request.json() == {"name": "Some name"} + assert enterprise_mocks.create_descendants.call_count == 1 + assert enterprise_mocks.create_descendants.last_request.json() == { + "name": "Some name" + } assert isinstance(descendant, Enterprise) + + +def test_move_groups(api, enterprise, enterprise_mocks): + other_id = fake_id("ent") + group_ids = [fake_id("ugp") for _ in range(3)] + enterprise_mocks.move_groups_json["movedGroups"] = [ + {"id": group_id} for group_id in group_ids + ] + for target in [other_id, api.enterprise(other_id)]: + enterprise_mocks.move_groups.reset() + result = enterprise.move_groups(group_ids, target) + assert enterprise_mocks.move_groups.call_count == 1 + assert enterprise_mocks.move_groups.last_request.json() == { + "targetEnterpriseAccountId": other_id, + "groupIds": group_ids, + } + assert set(m.id for m in result.moved_groups) == set(group_ids) From 5bb22bc28317f61fc419e1ef561bade742297cb3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:59:44 -0800 Subject: [PATCH 228/272] Enterprise.move_workspaces --- pyairtable/api/enterprise.py | 38 +++++++++++++++++++++++++++++++++++ scripts/find_model_changes.py | 3 +++ tests/test_api_enterprise.py | 17 ++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 7b970cb0..557097e0 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -50,6 +50,9 @@ class _urls(UrlBuilder): #: URL for moving user groups between enterprise accounts. move_groups = meta / "moveGroups" + #: URL for moving workspaces between enterprise accounts. + move_workspaces = meta / "moveWorkspaces" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -469,6 +472,32 @@ def move_groups( ) return MoveGroupsResponse.from_api(response, self.api, context=self) + def move_workspaces( + self, + workspace_ids: Iterable[str], + target: Union[str, Self], + ) -> "MoveWorkspacesResponse": + """ + Move one or more workspaces from the current enterprise account + into a different enterprise account within the same organization. + + See `Move workspaces `__. + + Args: + workspace_ids: The list of workspace IDs. + target: The ID of the target enterprise, or an instance of :class:`~pyairtable.Enterprise`. + """ + if isinstance(target, Enterprise): + target = target.id + response = self.api.post( + self.urls.move_workspaces, + json={ + "workspaceIds": workspace_ids, + "targetEnterpriseAccountId": target, + }, + ) + return MoveWorkspacesResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): """ @@ -573,6 +602,15 @@ class MoveGroupsResponse(AirtableModel): errors: List[MoveError] = pydantic.Field(default_factory=list) +class MoveWorkspacesResponse(AirtableModel): + """ + Returned by `Move workspaces `__. + """ + + moved_workspaces: List[NestedId] = pydantic.Field(default_factory=list) + errors: List[MoveError] = pydantic.Field(default_factory=list) + + rebuild_models(vars()) diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index e2e0396c..4c661357 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -31,6 +31,9 @@ "pyairtable.api.enterprise:DeleteUsersResponse.Error": "operations:delete-users-by-email:response:schema:@errors:items", "pyairtable.api.enterprise:ManageUsersResponse": "operations:manage-user-membership:response:schema", "pyairtable.api.enterprise:ManageUsersResponse.Error": "operations:manage-user-membership:response:schema:@errors:items", + "pyairtable.api.enterprise:MoveError": "operations:move-workspaces:response:schema:@errors:items", + "pyairtable.api.enterprise:MoveGroupsResponse": "operations:move-user-groups:response:schema", + "pyairtable.api.enterprise:MoveWorkspacesResponse": "operations:move-workspaces:response:schema", "pyairtable.models.audit:AuditLogResponse": "operations:audit-log-events:response:schema", "pyairtable.models.audit:AuditLogEvent": "operations:audit-log-events:response:schema:@events:items", "pyairtable.models.audit:AuditLogEvent.Context": "operations:audit-log-events:response:schema:@events:items:@context", diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 93f8bad2..68e6e833 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -459,3 +459,20 @@ def test_move_groups(api, enterprise, enterprise_mocks): "groupIds": group_ids, } assert set(m.id for m in result.moved_groups) == set(group_ids) + + +def test_move_workspaces(api, enterprise, enterprise_mocks): + other_id = fake_id("ent") + workspace_ids = [fake_id("wsp") for _ in range(3)] + enterprise_mocks.move_workspaces_json["movedWorkspaces"] = [ + {"id": workspace_id} for workspace_id in workspace_ids + ] + for target in [other_id, api.enterprise(other_id)]: + enterprise_mocks.move_workspaces.reset() + result = enterprise.move_workspaces(workspace_ids, target) + assert enterprise_mocks.move_workspaces.call_count == 1 + assert enterprise_mocks.move_workspaces.last_request.json() == { + "targetEnterpriseAccountId": other_id, + "workspaceIds": workspace_ids, + } + assert set(m.id for m in result.moved_workspaces) == set(workspace_ids) From 6872e4b9d91256c488ece81bed6eff7edad81d1b Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 17 Aug 2024 23:29:54 -0700 Subject: [PATCH 229/272] Release 3.0.0 --- docs/source/changelog.rst | 111 ++++++++++++++++++++++---------------- pyairtable/__init__.py | 2 +- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c68d6ad2..3d934c9a 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,59 +2,76 @@ Changelog ========= -3.0 (TBD) +3.0 (2024-11-15) ------------------------ -* Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. - - `PR #329 `_ -* :class:`~pyairtable.orm.fields.TextField` and - :class:`~pyairtable.orm.fields.CheckboxField` return ``""`` - or ``False`` instead of ``None``. - - `PR #347 `_ -* Changed the type of :data:`~pyairtable.orm.Model.created_time` - from ``str`` to ``datetime``, along with all other timestamp fields - used in :ref:`API: pyairtable.models`. - - `PR #352 `_ -* Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField` - for record links that should only contain one record. - - `PR #354 `_ -* Support ``use_field_ids`` in the :ref:`ORM`. - - `PR #355 `_ -* Removed the ``pyairtable.metadata`` module. - - `PR #360 `_ -* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. - - `PR #362 `_ -* Added ORM fields that :ref:`require a non-null value `. - - `PR #363 `_ -* Refactored methods for accessing ORM model configuration. - - `PR #366 `_ -* Added support for :ref:`memoization of ORM models `. - - `PR #369 `_ -* Added `Enterprise.grant_access ` - and `Enterprise.revoke_access `. - - `PR #373 `_ -* Added command line utility and ORM module generator. See :doc:`cli`. - - `PR #376 `_ -* Changed the behavior of :meth:`Model.save ` - to no longer send unmodified field values to the API. - - `PR #381 `_ -* Added ``use_field_ids=`` parameter to :class:`~pyairtable.Api`. - - `PR #386 `_ -* Changed the return type of :meth:`Model.save ` - from ``bool`` to :class:`~pyairtable.orm.SaveResult`. - - `PR #387 `_ -* Added :class:`pyairtable.testing.MockAirtable` for easier testing. - - `PR #388 `_ +* Added support for `new enterprise API endpoints `__. + - `PR #407 `_ +* Refactored methods/properties for constructing URLs in the API. + - `PR #399 `_ +* Dropped support for Pydantic 1.x. + - `PR #397 `_ +* Dropped support for Python 3.8. + - `PR #395 `_ * Added support for `Upload attachment `_ via :meth:`Table.upload_attachment ` or :meth:`AttachmentsList.upload `. - `PR #389 `_ -* Dropped support for Python 3.8. - - `PR #395 `_ -* Dropped support for Pydantic 1.x. - - `PR #397 `_ -* Refactored methods/properties for constructing URLs in the API. - - `PR #399 `_ +* Added :class:`pyairtable.testing.MockAirtable` for easier testing. + - `PR #388 `_ +* Changed the return type of :meth:`Model.save ` + from ``bool`` to :class:`~pyairtable.orm.SaveResult`. + - `PR #387 `_ +* Added ``use_field_ids=`` parameter to :class:`~pyairtable.Api`. + - `PR #386 `_ +* Changed the behavior of :meth:`Model.save ` + to no longer send unmodified field values to the API. + - `PR #381 `_ +* Added command line utility and ORM module generator. See :doc:`cli`. + - `PR #376 `_ +* Added `Enterprise.grant_access ` + and `Enterprise.revoke_access `. + - `PR #373 `_ +* Added support for :ref:`memoization of ORM models `. + - `PR #369 `_ +* Refactored methods for accessing ORM model configuration. + - `PR #366 `_ +* Added ORM fields that :ref:`require a non-null value `. + - `PR #363 `_ +* Renamed ``return_fields_by_field_id=`` to ``use_field_ids=``. + - `PR #362 `_ +* Removed the ``pyairtable.metadata`` module. + - `PR #360 `_ +* Support ``use_field_ids`` in the :ref:`ORM`. + - `PR #355 `_ +* Added ORM field type :class:`~pyairtable.orm.fields.SingleLinkField` + for record links that should only contain one record. + - `PR #354 `_ +* Changed the type of :data:`~pyairtable.orm.Model.created_time` + from ``str`` to ``datetime``, along with all other timestamp fields + used in :ref:`API: pyairtable.models`. + - `PR #352 `_ +* :class:`~pyairtable.orm.fields.TextField` and + :class:`~pyairtable.orm.fields.CheckboxField` return ``""`` + or ``False`` instead of ``None``. + - `PR #347 `_ +* Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. + - `PR #329 `_ + +2.3.6 (2024-11-11) +------------------------ + +* Fix for `#404 `_ + related to `enterprise endpoint changes `__. + - `PR #405 `_, + `PR #406 `_ + +2.3.5 (2024-10-29) +------------------------ + +* Fix for environment variables not getting passed to the ``requests`` + library (`#398 `_). + - `PR #401 `_ 2.3.4 (2024-10-21) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index d57c2249..8847f334 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0a3" +__version__ = "3.0.0" from .api import Api, Base, Table from .api.enterprise import Enterprise From 0a3c92a72bec62ca854d7dd841d8791ec5c49d09 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Wed, 27 Nov 2024 20:28:16 -0800 Subject: [PATCH 230/272] Remove all relative imports --- pyairtable/__init__.py | 8 ++++---- pyairtable/api/__init__.py | 6 +++--- pyairtable/models/__init__.py | 8 ++++---- pyairtable/models/collaborator.py | 2 +- pyairtable/models/comment.py | 9 +++++++-- pyairtable/models/schema.py | 3 +-- pyairtable/models/webhook.py | 3 +-- pyairtable/orm/__init__.py | 4 ++-- 8 files changed, 23 insertions(+), 20 deletions(-) diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 8847f334..da712f33 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,9 +1,9 @@ __version__ = "3.0.0" -from .api import Api, Base, Table -from .api.enterprise import Enterprise -from .api.retrying import retry_strategy -from .api.workspace import Workspace +from pyairtable.api import Api, Base, Table +from pyairtable.api.enterprise import Enterprise +from pyairtable.api.retrying import retry_strategy +from pyairtable.api.workspace import Workspace __all__ = [ "Api", diff --git a/pyairtable/api/__init__.py b/pyairtable/api/__init__.py index 724aacb8..151feda3 100644 --- a/pyairtable/api/__init__.py +++ b/pyairtable/api/__init__.py @@ -1,6 +1,6 @@ -from .api import Api -from .base import Base -from .table import Table +from pyairtable.api.api import Api +from pyairtable.api.base import Base +from pyairtable.api.table import Table __all__ = [ "Api", diff --git a/pyairtable/models/__init__.py b/pyairtable/models/__init__.py index 765a613f..ebf6a764 100644 --- a/pyairtable/models/__init__.py +++ b/pyairtable/models/__init__.py @@ -12,10 +12,10 @@ documented separately, and none of its classes are exposed here. """ -from .audit import AuditLogEvent, AuditLogResponse -from .collaborator import Collaborator -from .comment import Comment -from .webhook import Webhook, WebhookNotification, WebhookPayload +from pyairtable.models.audit import AuditLogEvent, AuditLogResponse +from pyairtable.models.collaborator import Collaborator +from pyairtable.models.comment import Comment +from pyairtable.models.webhook import Webhook, WebhookNotification, WebhookPayload __all__ = [ "AuditLogResponse", diff --git a/pyairtable/models/collaborator.py b/pyairtable/models/collaborator.py index ab0eb550..3e4e9051 100644 --- a/pyairtable/models/collaborator.py +++ b/pyairtable/models/collaborator.py @@ -2,7 +2,7 @@ from typing_extensions import TypeAlias -from ._base import AirtableModel +from pyairtable.models._base import AirtableModel UserId: TypeAlias = str diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py index 8cd15238..2f2cc688 100644 --- a/pyairtable/models/comment.py +++ b/pyairtable/models/comment.py @@ -3,8 +3,13 @@ import pydantic -from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, rebuild_models -from .collaborator import Collaborator +from pyairtable.models._base import ( + AirtableModel, + CanDeleteModel, + CanUpdateModel, + rebuild_models, +) +from pyairtable.models.collaborator import Collaborator class Comment( diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index c7155ba7..1cc8ca22 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -7,8 +7,7 @@ from typing_extensions import TypeAlias from pyairtable.api.types import AddCollaboratorDict - -from ._base import ( +from pyairtable.models._base import ( AirtableModel, CanDeleteModel, CanUpdateModel, diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index e457d2c0..833ad3c6 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -8,8 +8,7 @@ from typing_extensions import Self as SelfType from pyairtable.api.types import RecordId - -from ._base import AirtableModel, CanDeleteModel, rebuild_models +from pyairtable.models._base import AirtableModel, CanDeleteModel, rebuild_models # Shortcuts to avoid lots of line wrapping FD: Callable[[], Any] = partial(pydantic.Field, default_factory=dict) diff --git a/pyairtable/orm/__init__.py b/pyairtable/orm/__init__.py index ab7ad1d5..f6d86cd7 100644 --- a/pyairtable/orm/__init__.py +++ b/pyairtable/orm/__init__.py @@ -1,5 +1,5 @@ -from . import fields -from .model import Model, SaveResult +from pyairtable.orm import fields +from pyairtable.orm.model import Model, SaveResult __all__ = [ "Model", From fe40c62ef677a6da48398655288b2f2de01fa039 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 29 Nov 2024 11:26:26 -0800 Subject: [PATCH 231/272] Remove circular import in pyairtable.models._base --- pyairtable/models/_base.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index 0db55ffb..fefdb28a 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -1,6 +1,17 @@ from datetime import datetime from functools import partial -from typing import Any, ClassVar, Dict, Iterable, Mapping, Optional, Set, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Dict, + Iterable, + Mapping, + Optional, + Set, + Type, + Union, +) import inflection import pydantic @@ -12,6 +23,9 @@ datetime_to_iso_str, ) +if TYPE_CHECKING: + from pyairtable.api.api import Api + class AirtableModel(pydantic.BaseModel): """ @@ -46,7 +60,7 @@ def __init__(self, **data: Any) -> None: def from_api( cls, obj: Dict[str, Any], - api: "pyairtable.api.api.Api", + api: "Api", *, context: Optional[Any] = None, ) -> SelfType: @@ -73,7 +87,7 @@ def _context_name(obj: Any) -> str: def cascade_api( obj: Any, - api: "pyairtable.api.api.Api", + api: "Api", *, context: Optional[Any] = None, ) -> None: @@ -132,7 +146,7 @@ class RestfulModel(AirtableModel): __url_pattern: ClassVar[str] = "" - _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr() + _api: "Api" = pydantic.PrivateAttr() _url: str = pydantic.PrivateAttr(default="") _url_context: Any = pydantic.PrivateAttr(default=None) @@ -140,7 +154,7 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.__url_pattern = kwargs.pop("url", cls.__url_pattern) super().__init_subclass__() - def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None: + def _set_api(self, api: "Api", context: Dict[str, Any]) -> None: """ Set a link to the API and build the REST URL used for this resource. """ @@ -305,6 +319,3 @@ def rebuild_models( for value in obj.values(): if isinstance(value, type) and issubclass(value, AirtableModel): rebuild_models(value, memo=memo) - - -import pyairtable.api.api # noqa From 6bbad32707eb760d7106550f5a500adc8ff55967 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 29 Nov 2024 11:40:35 -0800 Subject: [PATCH 232/272] Clean up import-related noqa directives --- pyairtable/api/api.py | 22 ++++++++++----------- pyairtable/api/base.py | 15 ++++++++++----- pyairtable/api/enterprise.py | 22 ++++++++++++++------- pyairtable/api/table.py | 37 ++++++++++++++++++++++-------------- pyairtable/api/workspace.py | 19 +++++++++--------- pyairtable/orm/fields.py | 10 +++------- tests/test_api_api.py | 2 +- 7 files changed, 71 insertions(+), 56 deletions(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index c8924890..ac1d8633 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -6,8 +6,10 @@ from typing_extensions import TypeAlias from pyairtable.api import retrying +from pyairtable.api.base import Base from pyairtable.api.enterprise import Enterprise from pyairtable.api.params import options_to_json_and_params, options_to_params +from pyairtable.api.table import Table from pyairtable.api.types import UserAndScopesDict, assert_typed_dict from pyairtable.api.workspace import Workspace from pyairtable.models.schema import Bases @@ -44,7 +46,7 @@ class Api: MAX_URL_LENGTH = 16000 # Cached metadata to reduce API calls - _bases: Optional[Dict[str, "pyairtable.api.base.Base"]] = None + _bases: Optional[Dict[str, "Base"]] = None endpoint_url: Url session: Session @@ -126,7 +128,7 @@ def base( *, validate: bool = False, force: bool = False, - ) -> "pyairtable.api.base.Base": + ) -> "Base": """ Return a new :class:`Base` instance that uses this instance of :class:`Api`. @@ -141,7 +143,7 @@ def base( if validate: info = self._base_info(force=force).base(base_id) return self._base_from_info(info) - return pyairtable.api.base.Base(self, base_id) + return Base(self, base_id) @cache_unless_forced def _base_info(self) -> Bases: @@ -158,15 +160,15 @@ def _base_info(self) -> Bases: } return Bases.from_api(data, self) - def _base_from_info(self, base_info: Bases.Info) -> "pyairtable.api.base.Base": - return pyairtable.api.base.Base( + def _base_from_info(self, base_info: Bases.Info) -> "Base": + return Base( self, base_info.id, name=base_info.name, permission_level=base_info.permission_level, ) - def bases(self, *, force: bool = False) -> List["pyairtable.api.base.Base"]: + def bases(self, *, force: bool = False) -> List["Base"]: """ Retrieve the base's schema and return a list of :class:`Base` instances. @@ -189,7 +191,7 @@ def create_base( workspace_id: str, name: str, tables: Sequence[Dict[str, Any]], - ) -> "pyairtable.api.base.Base": + ) -> "Base": """ Create a base in the given workspace. @@ -210,7 +212,7 @@ def table( *, validate: bool = False, force: bool = False, - ) -> "pyairtable.api.table.Table": + ) -> "Table": """ Build a new :class:`Table` instance that uses this instance of :class:`Api`. @@ -407,7 +409,3 @@ def enterprise(self, enterprise_account_id: str) -> Enterprise: Build an object representing an enterprise account. """ return Enterprise(self, enterprise_account_id) - - -import pyairtable.api.base # noqa -import pyairtable.api.table # noqa diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 317bf365..2efd8ef2 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -1,8 +1,7 @@ import warnings from functools import cached_property -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union -import pyairtable.api.api import pyairtable.api.table from pyairtable.models.schema import BaseCollaborators, BaseSchema, BaseShares from pyairtable.models.webhook import ( @@ -13,6 +12,9 @@ ) from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only +if TYPE_CHECKING: + from pyairtable.api.api import Api + class Base: """ @@ -25,7 +27,7 @@ class Base: """ #: The connection to the Airtable API. - api: "pyairtable.api.api.Api" + api: "Api" #: The base ID, in the format ``appXXXXXXXXXXXXXX`` id: str @@ -67,7 +69,7 @@ def interface(self, interface_id: str) -> Url: def __init__( self, - api: Union["pyairtable.api.api.Api", str], + api: Union["Api", str], base_id: str, *, name: Optional[str] = None, @@ -99,7 +101,10 @@ def __init__( category=DeprecationWarning, stacklevel=2, ) - api = pyairtable.api.api.Api(api) + + from pyairtable import Api + + api = Api(api) self.api = api self.id = base_id diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 557097e0..8183a55f 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,6 +1,16 @@ from datetime import date, datetime from functools import cached_property, partialmethod -from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + Iterator, + List, + Literal, + Optional, + Union, +) import pydantic from typing_extensions import Self @@ -17,6 +27,9 @@ enterprise_only, ) +if TYPE_CHECKING: + from pyairtable.api.api import Api + @enterprise_only class Enterprise: @@ -85,7 +98,7 @@ def remove_user(self, user_id: str) -> Url: urls = cached_property(_urls) - def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): + def __init__(self, api: "Api", workspace_id: str): self.api = api self.id = workspace_id self._info: Optional[EnterpriseInfo] = None @@ -612,8 +625,3 @@ class MoveWorkspacesResponse(AirtableModel): rebuild_models(vars()) - - -# These are at the bottom of the module to avoid circular imports -import pyairtable.api.api # noqa -import pyairtable.api.base # noqa diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 235b5989..96b2f0a4 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -5,10 +5,19 @@ import warnings from functools import cached_property from pathlib import Path -from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + Iterator, + List, + Optional, + Union, + overload, +) import pyairtable.models -from pyairtable.api.retrying import Retry from pyairtable.api.types import ( FieldName, RecordDeletedDict, @@ -25,6 +34,11 @@ from pyairtable.models.schema import FieldSchema, TableSchema, parse_field_schema from pyairtable.utils import Url, UrlBuilder, is_table_id +if TYPE_CHECKING: + from pyairtable.api.api import Api, TimeoutTuple + from pyairtable.api.base import Base + from pyairtable.api.retrying import Retry + class Table: """ @@ -37,7 +51,7 @@ class Table: """ #: The base that this table belongs to. - base: "pyairtable.api.base.Base" + base: "Base" #: Can be either the table name or the table ID (``tblXXXXXXXXXXXXXX``). name: str @@ -82,8 +96,8 @@ def __init__( base_id: str, table_name: str, *, - timeout: Optional["pyairtable.api.api.TimeoutTuple"] = None, - retry_strategy: Optional[Retry] = None, + timeout: Optional["TimeoutTuple"] = None, + retry_strategy: Optional["Retry"] = None, endpoint_url: str = "https://api.airtable.com", ): ... @@ -91,7 +105,7 @@ def __init__( def __init__( self, api_key: None, - base_id: "pyairtable.api.base.Base", + base_id: "Base", table_name: str, ): ... @@ -99,14 +113,14 @@ def __init__( def __init__( self, api_key: None, - base_id: "pyairtable.api.base.Base", + base_id: "Base", table_name: TableSchema, ): ... def __init__( self, api_key: Union[None, str], - base_id: Union["pyairtable.api.base.Base", str], + base_id: Union["Base", str], table_name: Union[str, TableSchema], **kwargs: Any, ): @@ -210,7 +224,7 @@ def id_or_name(self, quoted: bool = True) -> str: return value @property - def api(self) -> "pyairtable.api.api.Api": + def api(self) -> "Api": """ The API connection used by the table's :class:`~pyairtable.Base`. """ @@ -801,8 +815,3 @@ def upload_attachment( } 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 -import pyairtable.api.base # noqa diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py index 5293d4eb..7a63763c 100644 --- a/pyairtable/api/workspace.py +++ b/pyairtable/api/workspace.py @@ -1,9 +1,13 @@ from functools import cached_property -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union from pyairtable.models.schema import WorkspaceCollaborators from pyairtable.utils import Url, UrlBuilder, cache_unless_forced, enterprise_only +if TYPE_CHECKING: + from pyairtable.api.api import Api + from pyairtable.api.base import Base + class Workspace: """ @@ -33,7 +37,7 @@ class _urls(UrlBuilder): urls = cached_property(_urls) - def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): + def __init__(self, api: "Api", workspace_id: str): self.api = api self.id = workspace_id @@ -41,7 +45,7 @@ def create_base( self, name: str, tables: Sequence[Dict[str, Any]], - ) -> "pyairtable.api.base.Base": + ) -> "Base": """ Create a base in the given workspace. @@ -73,7 +77,7 @@ def collaborators(self) -> WorkspaceCollaborators: return WorkspaceCollaborators.from_api(payload, self.api, context=self) @enterprise_only - def bases(self) -> List["pyairtable.api.base.Base"]: + def bases(self) -> List["Base"]: """ Retrieve all bases within the workspace. """ @@ -103,7 +107,7 @@ def delete(self) -> None: @enterprise_only def move_base( self, - base: Union[str, "pyairtable.api.base.Base"], + base: Union[str, "Base"], target: Union[str, "Workspace"], index: Optional[int] = None, ) -> None: @@ -123,8 +127,3 @@ def move_base( if index is not None: payload["targetIndex"] = index self.api.post(self.urls.move_base, json=payload) - - -# These are at the bottom of the module to avoid circular imports -import pyairtable.api.api # noqa -import pyairtable.api.base # noqa diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 27e00d9e..f2ac64b9 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -50,7 +50,7 @@ from typing_extensions import Self as SelfType from typing_extensions import TypeAlias -from pyairtable import utils +from pyairtable import formulas, utils from pyairtable.api.types import ( AITextDict, AttachmentDict, @@ -69,7 +69,7 @@ from pyairtable.orm.lists import AttachmentsList, ChangeTrackingList if TYPE_CHECKING: - from pyairtable.orm import Model # noqa + from pyairtable.orm import Model _ClassInfo: TypeAlias = Union[type, Tuple["_ClassInfo", ...]] @@ -610,7 +610,7 @@ def __init__( lazy: If ``True``, this field will return empty objects with only IDs; call :meth:`~pyairtable.orm.Model.fetch` to retrieve values. """ - from pyairtable.orm import Model # noqa, avoid circular import + from pyairtable.orm import Model if not ( model is _LinkFieldOptions.LinkSelf @@ -1588,7 +1588,3 @@ class CreatedTimeField(RequiredDatetimeField): "UrlField", ] # [[[end]]] (checksum: 87b0a100c9e30523d9aab8cc935c7960) - - -# Delayed import to avoid circular dependency -from pyairtable import formulas # noqa diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 6583f4b9..458497b2 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -2,7 +2,7 @@ import pytest -from pyairtable import Api, Base, Table # noqa +from pyairtable import Api, Base, Table @pytest.fixture From 4fdcc4520677720be9d4c92f22e74aa354f055d3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Dec 2024 10:55:58 -0800 Subject: [PATCH 233/272] Test for #415 --- tests/test_api_api.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_api_api.py b/tests/test_api_api.py index 458497b2..541804dc 100644 --- a/tests/test_api_api.py +++ b/tests/test_api_api.py @@ -116,6 +116,42 @@ def test_iterate_requests(api: Api, requests_mock): assert responses == [response["json"] for response in response_list] +def test_iterate_requests__post(api: Api, requests_mock): + url = "https://example.com" + # prepare a few pages of dummy responses + response_list = [ + {"json": {"page": 0, "offset": 1}}, + {"json": {"page": 1, "offset": 2}}, + {"json": {"page": 2}}, + ] + m = requests_mock.post(url, response_list=response_list) + # construct a request that will get converted from GET to POST + formula = "X" * (api.MAX_URL_LENGTH + 1) + pages = list( + api.iterate_requests( + "GET", + url=url, + fallback=("POST", url), + options={"formula": formula}, + ) + ) + # ensure we got the responses we expected + assert pages == [ + {"page": 0, "offset": 1}, + {"page": 1, "offset": 2}, + {"page": 2}, + ] + # ensure we made three POST requests + assert m.call_count == 3 + assert len(pages) == 3 + requests = [r.json() for r in m.request_history] + assert requests == [ + {"filterByFormula": formula}, + {"filterByFormula": formula, "offset": "1"}, + {"filterByFormula": formula, "offset": "2"}, + ] + + def test_iterate_requests__invalid_type(api: Api, requests_mock): url = "https://example.com" response_list = [{"json": {"page": n, "offset": n + 1}} for n in range(1, 3)] From 07a50ffa6c4f3284cbcc2e5cb9492902d42c056a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Dec 2024 10:27:45 -0800 Subject: [PATCH 234/272] Fixes #415 --- pyairtable/api/api.py | 2 +- pyairtable/api/params.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py index ac1d8633..3dae473c 100644 --- a/pyairtable/api/api.py +++ b/pyairtable/api/api.py @@ -394,7 +394,7 @@ def _get_offset_field(response: Dict[str, Any]) -> Optional[str]: return if not (offset := _get_offset_field(response)): return - params = {**params, offset_field: offset} + options = {**options, offset_field: offset} def chunked(self, iterable: Sequence[T]) -> Iterator[Sequence[T]]: """ diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 07c7a2bc..48aab066 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -74,6 +74,9 @@ def field_names_to_sorting_dict(field_names: List[str]) -> List[Dict[str, str]]: # get webhook payloads "limit": "limit", "cursor": "cursor", + # get audit log events + "next": "next", + "previous": "previous", } From c43dbb47ae83ba35296e3219647b3faa547e8dae Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Dec 2024 10:32:42 -0800 Subject: [PATCH 235/272] Add integration test for #415 --- tests/integration/conftest.py | 27 +++++----- tests/integration/test_integration_api.py | 63 ++++++++++++++++++----- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b9b3e818..68db160d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -10,20 +10,21 @@ def valid_img_url(): return "https://github.com/gtalarico/pyairtable/raw/9f243cb0935ad7112859f990434612efdaf49c67/docs/source/_static/logo.png" +class Columns: + # Table should have these Columns + TEXT = "text" # Text + TEXT_ID = "fldzbVdWW4xJdZ1em" # for returnFieldsByFieldId + NUM = "number" # Number, float + NUM_ID = "fldFLyuxGuWobyMV2" # for returnFieldsByFieldId + BOOL = "boolean" # Boolean + DATETIME = "datetime" # Datetime + ATTACHMENT = "attachment" # attachment + ATTACHMENT_ID = "fld5VP9oPeCpvIumr" # for upload_attachment + + @pytest.fixture -def cols(): - class Columns: - # Table should have these Columns - TEXT = "text" # Text - TEXT_ID = "fldzbVdWW4xJdZ1em" # for returnFieldsByFieldId - NUM = "number" # Number, float - NUM_ID = "fldFLyuxGuWobyMV2" # for returnFieldsByFieldId - BOOL = "boolean" # Boolean - DATETIME = "datetime" # Datetime - ATTACHMENT = "attachment" # attachment - ATTACHMENT_ID = "fld5VP9oPeCpvIumr" # for upload_attachment - - return Columns +def cols() -> Columns: + return Columns() @pytest.fixture diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 66656f46..b5ee0e62 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -6,7 +6,7 @@ import requests from pyairtable import Table -from pyairtable import formulas as fo +from pyairtable.formulas import AND, EQ, FIND, OR, RECORD_ID, Field, match from pyairtable.utils import date_to_iso_str, datetime_to_iso_str pytestmark = [pytest.mark.integration] @@ -150,17 +150,17 @@ def test_integration_field_equals(table: Table, cols): rv_create = table.create(values) # match all - finds - rv_first = table.first(formula=fo.match(values)) + rv_first = table.first(formula=match(values)) assert rv_first and rv_first["id"] == rv_create["id"] # match all - does not find values = {cols.TEXT: TEXT_VALUE, cols.NUM: 0} - rv_first = table.first(formula=fo.match(values)) + rv_first = table.first(formula=match(values)) assert rv_first is None # match all w/ match_any=True - does not find values = {cols.TEXT: TEXT_VALUE, cols.NUM: 0} - rv_first = table.first(formula=fo.match(values, match_any=True)) + rv_first = table.first(formula=match(values, match_any=True)) assert rv_first and rv_first["id"] == rv_create["id"] @@ -226,7 +226,7 @@ def test_batch_upsert(table: Table, cols): def test_integration_formula_datetime(table: Table, cols): now = datetime.now(timezone.utc) - formula = fo.match({cols.DATETIME: now}) + formula = match({cols.DATETIME: now}) rv_create = table.create({cols.DATETIME: datetime_to_iso_str(now)}) rv_first = table.first(formula=formula) assert rv_first and rv_first["id"] == rv_create["id"] @@ -243,7 +243,7 @@ def test_integration_formula_date_filter(table: Table, cols): rec = table.create({cols.DATETIME: dt_str}) created.append(rec) - formula = fo.FIND(date_str, fo.Field(cols.DATETIME)) + formula = FIND(date_str, Field(cols.DATETIME)) rv_all = table.all(formula=formula) print("repr", repr(formula), "\nstr", str(formula)) assert rv_all @@ -253,12 +253,12 @@ def test_integration_formula_date_filter(table: Table, cols): def test_integration_field_equals_with_quotes(table: Table, cols): VALUE = "Contact's Name {}".format(uuid4()) rv_create = table.create({cols.TEXT: VALUE}) - rv_first = table.first(formula=fo.match({cols.TEXT: VALUE})) + rv_first = table.first(formula=match({cols.TEXT: VALUE})) assert rv_first and rv_first["id"] == rv_create["id"] VALUE = 'Some "Quote" {}'.format(uuid4()) rv_create = table.create({cols.TEXT: VALUE}) - rv_first = table.first(formula=fo.match({cols.TEXT: VALUE})) + rv_first = table.first(formula=match({cols.TEXT: VALUE})) assert rv_first and rv_first["id"] == rv_create["id"] @@ -268,10 +268,10 @@ def test_integration_formula_composition(table: Table, cols): bool_ = True rv_create = table.create({cols.TEXT: text, cols.NUM: num, cols.BOOL: bool_}) - formula = fo.AND( - fo.EQ(fo.Field(cols.TEXT), text), - fo.EQ(fo.Field(cols.NUM), num), - fo.EQ(fo.Field(cols.BOOL), bool_), # not needs to be int() + formula = AND( + EQ(Field(cols.TEXT), text), + EQ(Field(cols.NUM), num), + EQ(Field(cols.BOOL), bool_), # not needs to be int() ) rv_first = table.first(formula=formula) @@ -362,3 +362,42 @@ def test_integration_comments(api, table: Table, cols): # Test that we can delete the comment comments[0].delete() + + +def test_pagination(cols, api, table): + """ + Test that we can paginate through records as expected. + """ + # Start by creating 500 unique records + created = table.batch_create([{cols.TEXT: f"Record {i}"} for i in range(500)]) + formula = OR(RECORD_ID().eq(record["id"]) for record in created[:-1]) + + # The formula ought to be longer than the maximum URL length, + # so we know we'll convert the request to a POST. + assert len(str(formula)) > api.MAX_URL_LENGTH + assert created[-1]["id"] not in str(formula) + + for page_size in [10, 50]: + paginator = table.iterate(formula=formula, page_size=page_size) + + # Test that each page is the expected size + assert len(page1 := next(paginator)) == page_size + assert len(page2 := next(paginator)) == page_size + + # Test that we don't keep getting the same records + page1_ids = {record["id"] for record in page1} + page2_ids = {record["id"] for record in page2} + assert page1_ids != page2_ids + + for max_records in [10, 50]: + # Test that max_records actually limits the number of records returned, + # not just the size of each page of records. + records = table.all(formula=formula, max_records=max_records) + assert len(records) == max_records + + # Test the combination of each. + paginator = table.iterate(formula=formula, page_size=10, max_records=25) + pages = list(paginator) + ids = {record["id"] for page in pages for record in page} + assert [len(page) for page in pages] == [10, 10, 5] + assert len(ids) == 25 From e300874ab71c163aaba3683c76a675422896e915 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 6 Dec 2024 11:09:14 -0800 Subject: [PATCH 236/272] Release 3.0.1 --- docs/source/changelog.rst | 16 ++++++++++++++++ pyairtable/__init__.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 3d934c9a..38f7a540 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,14 @@ Changelog ========= +3.0.1 (2024-12-06) +------------------------ + +* Fix for `#415 `_ + which caused an endless loop when making a request via `POST /listRecords`. + - `PR #416 `_, + `PR #417 `_ + 3.0 (2024-11-15) ------------------------ @@ -58,6 +66,14 @@ Changelog * Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. - `PR #329 `_ +2.3.7 (2024-12-06) +------------------------ + +* Fix for `#415 `_ + which caused an endless loop when making a request via `POST /listRecords`. + - `PR #416 `_, + `PR #417 `_ + 2.3.6 (2024-11-11) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index da712f33..00aa9574 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From dff7582917ef3a94d8c296e7ce8ea9e3f13a2e9a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 14 Feb 2025 23:16:59 -0800 Subject: [PATCH 237/272] Allow pickling/unpickling of ChangeTrackingLists --- pyairtable/orm/fields.py | 20 ++++++++++++++---- pyairtable/orm/lists.py | 9 ++++++-- tests/test_orm_fields.py | 2 ++ tests/test_orm_lists.py | 28 +++++++++++++++++++++++++ tests/test_orm_model.py | 45 ++++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 22 ++++++++++++++++++++ 6 files changed, 120 insertions(+), 6 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index f2ac64b9..8dd17f47 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -521,6 +521,13 @@ def __get__( return self return self._get_list_value(instance) + def __set__(self, instance: "Model", value: Optional[List[T_ORM]]) -> None: + if isinstance(value, list) and not isinstance(value, self.list_class): + assert isinstance(self.list_class, type) + assert issubclass(self.list_class, ChangeTrackingList) + value = self.list_class(value, field=self, model=instance) + super().__set__(instance, value) + 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. @@ -712,10 +719,15 @@ class Meta: ... # If the list contains record IDs, replace the contents with instances. # Other code may already have references to this specific list, so # we replace the existing list's values. - records[: self._max_retrieve] = [ - new_records[cast(RecordId, value)] if isinstance(value, RecordId) else value - for value in records[: self._max_retrieve] - ] + with records.disable_tracking(): + records[: self._max_retrieve] = [ + ( + new_records[cast(RecordId, value)] + if isinstance(value, RecordId) + else value + ) + for value in records[: self._max_retrieve] + ] def _get_list_value(self, instance: "Model") -> ChangeTrackingList[T_Linked]: """ diff --git a/pyairtable/orm/lists.py b/pyairtable/orm/lists.py index f0360632..31fcb344 100644 --- a/pyairtable/orm/lists.py +++ b/pyairtable/orm/lists.py @@ -50,8 +50,13 @@ def disable_tracking(self) -> Iterator[Self]: self._tracking_enabled = prev def _on_change(self) -> None: - if self._tracking_enabled: - self._model._changed[self._field.field_name] = True + try: + if not self._tracking_enabled: + return + except AttributeError: + # This means we're being unpickled and won't call __init__. + return + self._model._changed[self._field.field_name] = True @overload def __setitem__(self, index: SupportsIndex, value: T, /) -> None: ... diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index d291b89d..736a17f6 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -632,6 +632,8 @@ class Author(Model): collection = [Book(), Book(), Book()] author = Author() author.books = collection + assert isinstance(author._fields["Books"], f.ChangeTrackingList) + assert author.books == collection with pytest.raises(TypeError): diff --git a/tests/test_orm_lists.py b/tests/test_orm_lists.py index 90d11b34..5700177c 100644 --- a/tests/test_orm_lists.py +++ b/tests/test_orm_lists.py @@ -15,6 +15,7 @@ class Fake(Model): Meta = fake_meta() attachments = F.AttachmentsField("Files") readonly_attachments = F.AttachmentsField("Other Files", readonly=True) + others = F.LinkField["Fake"]("Others", F.LinkSelf) @pytest.fixture @@ -115,3 +116,30 @@ def test_attachment_upload__unsaved_value(mock_upload): mock_upload.assert_called_once() assert len(instance.attachments) == 1 assert instance.attachments[0]["url"] != unsaved_url + + +@pytest.mark.parametrize( + "op,retval,new_value", + [ + (mock.call.append(4), None, [1, 2, 3, 4]), + (mock.call.insert(1, 4), None, [1, 4, 2, 3]), + (mock.call.remove(2), None, [1, 3]), + (mock.call.clear(), None, []), + (mock.call.extend([4, 5]), None, [1, 2, 3, 4, 5]), + (mock.call.pop(), 3, [1, 2]), + ], +) +def test_change_tracking_list(op, retval, new_value): + """ + Test that ChangeTrackingList performs operations normally + and records (on the model instance) that the field changed. + """ + instance = Fake.from_record(fake_record()) + ctl = F.ChangeTrackingList[int]([1, 2, 3], field=Fake.others, model=instance) + assert not instance._changed.get("Others") + + fn = getattr(ctl, op._mock_parent._mock_name) + result = fn(*op.args, **op.kwargs) + assert result == retval + assert ctl == new_value + assert instance._changed["Others"] is True diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index ff4763f0..ff77278e 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -1,3 +1,4 @@ +import pickle from datetime import datetime, timezone from functools import partial from unittest import mock @@ -463,3 +464,47 @@ def test_save_bool_deprecated(): with pytest.deprecated_call(): assert bool(SaveResult(fake_id(), created=True)) is True + + +def test_pickling(): + """ + Test that a model instance can be pickled and unpickled. + """ + instance = FakeModel.from_record(fake_record(one="one", two="two")) + pickled = pickle.dumps(instance) + unpickled = pickle.loads(pickled) + assert isinstance(unpickled, FakeModel) + assert unpickled is not instance + assert unpickled.id == instance.id + assert unpickled.created_time == instance.created_time + assert unpickled._fields == instance._fields + + +class LinkedModel(Model): + Meta = fake_meta() + name = f.TextField("Name") + links = f.LinkField("Link", FakeModel) + + +def test_pickling_with_change_tracking_list(): + """ + Test that a model with a ChangeTrackingList can be pickled and unpickled. + """ + fake_models = [FakeModel.from_record(fake_record()) for _ in range(5)] + instance = LinkedModel.from_record(fake_record()) + instance.links = fake_models + instance._changed.clear() # Don't want to pickle that part. + + # Now we need to be able to pickle and unpickle the model instance. + # We can't pickle/unpickle the list itself on its own, because it needs + # to retain references to the field and model. + pickled = pickle.dumps(instance) + unpickled = pickle.loads(pickled) + assert isinstance(unpickled, LinkedModel) + unpickled_link_ids = [link.id for link in unpickled.links] + assert unpickled_link_ids == [link.id for link in fake_models] + + # Make sure change tracking still works. + assert "Link" not in unpickled._changed + unpickled.links.append(FakeModel.from_record(fake_record())) + assert unpickled._changed["Link"] is True diff --git a/tests/test_utils.py b/tests/test_utils.py index 427628a5..30c734eb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -233,3 +233,25 @@ def test_url_cannot_append_after_params(): v / "foo" with pytest.raises(ValueError): v // ["foo", "bar"] + + +@pytest.mark.parametrize( + "docstring,expected", + [ + ("", ""), + ( + "This is a\ndocstring.", + "|enterprise_only|\n\nThis is a\ndocstring.", + ), + ( + "\t This is a\n\t docstring.", + "\t |enterprise_only|\n\n\t This is a\n\t docstring.", + ), + ], +) +def test_enterprise_docstring(docstring, expected): + @utils.enterprise_only + class Foo: + __doc__ = docstring + + assert Foo.__doc__ == expected From 608f855f218ff93f1a49ae595c990941c03d1b84 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 20 Feb 2025 00:20:04 -0800 Subject: [PATCH 238/272] Model.from_ids should respect Meta.use_field_ids Fixes #421 --- pyairtable/orm/model.py | 5 +---- tests/test_orm_fields.py | 33 ++++++++++++++++++++++++++++++++- tests/test_orm_model.py | 12 +++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 5284a1c0..188c9b60 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -449,10 +449,7 @@ def from_ids( # Only retrieve records that aren't already memoized formula = OR(EQ(RECORD_ID(), record_id) for record_id in sorted(remaining)) by_id.update( - { - record["id"]: cls.from_record(record, memoize=memoize) - for record in cls.meta.table.all(formula=formula) - } + {obj.id: obj for obj in cls.all(formula=formula, memoize=memoize)} ) # Ensure we return records in the same order, and raise KeyError if any are missing diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 736a17f6..4c505ecf 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -877,7 +877,10 @@ class Book(Model): book = Book.from_record(fake_record(Author=[a1, a2, a3])) with mock.patch("pyairtable.Table.all", return_value=records) as m: book.author - m.assert_called_once_with(formula=OR(RECORD_ID().eq(records[0]["id"]))) + m.assert_called_once_with( + **Book.meta.request_kwargs, + formula=OR(RECORD_ID().eq(records[0]["id"])), + ) assert book.author.id == a1 assert book.author.name == "Author 1" @@ -956,6 +959,34 @@ class T(Model): T.link.populate(Linked()) +@pytest.mark.parametrize("field_type", (f.LinkField, f.SingleLinkField)) +def test_link_field__populate_with_field_ids(field_type, requests_mock): + """ + Test that implementers can use Model.link_field.populate(instance) + when the linked model uses field IDs rather than field names. + """ + field_id = fake_id("fld") + record_ids = [fake_id("rec", n) for n in range(3)] + records = [ + fake_record(id=record_id, Name=f"link{n}") + for n, record_id in enumerate(record_ids) + ] + + class Linked(Model): + Meta = fake_meta(use_field_ids=True) + name = f.TextField(field_id) + + class T(Model): + Meta = fake_meta() + link = field_type("Link", Linked) + + m = requests_mock.get(Linked.meta.table.urls.records, json={"records": records}) + obj = T.from_record(fake_record(Link=record_ids)) + obj.link + assert m.call_count == 1 + assert m.last_request.qs.get("returnFieldsByFieldId") == ["1"] + + def test_lookup_field(): class T: items = f.LookupField("Items") diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index ff77278e..a5d79770 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -227,9 +227,10 @@ def test_from_ids(mock_api): url=FakeModel.meta.table.urls.records, fallback=("post", FakeModel.meta.table.urls.records_post), options={ + **FakeModel.meta.request_kwargs, "formula": ( "OR(%s)" % ", ".join(f"RECORD_ID()='{id}'" for id in sorted(fake_ids)) - ) + ), }, ) assert len(contacts) == len(fake_records) @@ -253,6 +254,15 @@ def test_from_ids__no_fetch(mock_all): assert set(contact.id for contact in contacts) == set(fake_ids) +@mock.patch("pyairtable.Table.all") +def test_from_ids__use_field_ids(mock_all): + fake_ids = [fake_id() for _ in range(10)] + mock_all.return_value = [fake_record(id=id) for id in fake_ids] + FakeModelByIds.from_ids(fake_ids) + assert mock_all.call_count == 1 + assert mock_all.mock_calls[-1].kwargs["use_field_ids"] is True + + @pytest.mark.parametrize( "methodname,returns", ( From cc8e45f0188c57b62752ac73b496b16482c3317e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 24 Feb 2025 23:16:18 -0800 Subject: [PATCH 239/272] Fix docs search (and a `make docs` error) --- docs/source/changelog.rst | 9 +++------ requirements-dev.txt | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 38f7a540..3f3120b9 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -7,8 +7,7 @@ Changelog * Fix for `#415 `_ which caused an endless loop when making a request via `POST /listRecords`. - - `PR #416 `_, - `PR #417 `_ + - `PR #416 `_, `PR #417 `_ 3.0 (2024-11-15) ------------------------ @@ -71,16 +70,14 @@ Changelog * Fix for `#415 `_ which caused an endless loop when making a request via `POST /listRecords`. - - `PR #416 `_, - `PR #417 `_ + - `PR #416 `_, `PR #417 `_ 2.3.6 (2024-11-11) ------------------------ * Fix for `#404 `_ related to `enterprise endpoint changes `__. - - `PR #405 `_, - `PR #406 `_ + - `PR #405 `_, `PR #406 `_ 2.3.5 (2024-10-29) ------------------------ diff --git a/requirements-dev.txt b/requirements-dev.txt index 67a5783a..7c5bc7d0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ cogapp Sphinx==4.5.0 sphinx-autoapi sphinxext-opengraph -revitron-sphinx-theme @ git+https://github.com/gtalarico/revitron-sphinx-theme.git@40f4b09fa5c199e3844153ef973a1155a56981dd +revitron-sphinx-theme @ git+https://github.com/mesozoic/revitron-sphinx-theme.git@7ee572e9e4255c9aaa6b383656ff807fdac1011b sphinx-autodoc-typehints autodoc-pydantic>=2 sphinxcontrib-applehelp==1.0.4 From 91ba838c947c44b1264d79b19dbdd64b9cdffadf Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 25 Feb 2025 22:28:14 -0800 Subject: [PATCH 240/272] Release 3.0.2 --- docs/source/changelog.rst | 10 ++++++++++ pyairtable/__init__.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 3f3120b9..c6100835 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,16 @@ Changelog ========= +3.0.2 (2025-02-25) +------------------------ + +* Fixed broken search feature in the library docs. + - `PR #423 `_ +* Fix for `#421 `_ + which prevented ORM link fields from fetching records of models + that used field IDs instead of field names. + - `PR #422 `_ + 3.0.1 (2024-12-06) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 00aa9574..c3f748dd 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.1" +__version__ = "3.0.2" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From 2fd2b98119563220e0ffcb1d1032605b6defa3db Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 16 Mar 2025 14:14:02 -0700 Subject: [PATCH 241/272] Add type-annotated `field_schema()` to ORM fields --- docs/source/changelog.rst | 5 + pyairtable/orm/fields.py | 368 ++++++++++++---------- tests/conftest.py | 18 ++ tests/integration/test_integration_orm.py | 5 + tests/test_api_table.py | 10 +- tests/test_orm_fields.py | 51 ++- tests/test_typing.py | 57 +++- 7 files changed, 343 insertions(+), 171 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index c6100835..f5a1308c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +3.1.0 (TBD) +------------------------ + +* Added ``Field.field_schema()`` to type-annotated ORM fields. + 3.0.2 (2025-02-25) ------------------------ diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 8dd17f47..2d34e70f 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -66,6 +66,7 @@ MultipleValuesError, UnsavedRecordError, ) +from pyairtable.models import schema as S from pyairtable.orm.lists import AttachmentsList, ChangeTrackingList if TYPE_CHECKING: @@ -270,7 +271,7 @@ def lte(self, value: Any) -> "formulas.Comparison": return formulas.LTE(self, value) -class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, T_ORM]): +class _Requires_API_ORM(Generic[T_API, T_ORM], Field[T_API, T_ORM, T_ORM]): """ A mix-in for a Field class which indicates two things: @@ -300,18 +301,53 @@ def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None: super().__set__(instance, value) +T_FieldSchema = TypeVar("T_FieldSchema", bound=S.FieldSchema) + + +class _FieldSchema(Generic[T_FieldSchema]): + """ + A mix-in for a Field class which indicates that its field has a particular schema. + """ + + def field_schema(self) -> T_FieldSchema: + """ + Retrieve the schema for the given field. + """ + if not isinstance(self, Field): + raise RuntimeError("field_schema() must be called on a Field instance") + if not self._model: + raise RuntimeError(f"{self._description} was not defined on a Model") + return cast( + T_FieldSchema, self._model.meta.table.schema().field(self.field_name) + ) + + #: A generic Field with internal and API representations that are the same type. _BasicField: TypeAlias = Field[T, T, None] _BasicFieldWithMissingValue: TypeAlias = Field[T, T, T] -_BasicFieldWithRequiredValue: TypeAlias = _FieldWithRequiredValue[T, T] +_Requires: TypeAlias = _Requires_API_ORM[T, T] #: An alias for any type of Field. AnyField: TypeAlias = Field[Any, Any, Any] -class TextField(_BasicFieldWithMissingValue[str]): +class _StringField(_BasicFieldWithMissingValue[str]): + """ + Base class for fields that return Unicode strings. """ + + missing_value = "" + valid_types = str + + +class TextField( + _StringField, + _FieldSchema[Union[S.SingleLineTextFieldSchema, S.MultilineTextFieldSchema]], +): + """ + Deprecated; use :class:`~SingleLineTextField` or :class:`~MultilineTextField`. + Accepts ``str``. Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. @@ -319,11 +355,26 @@ class TextField(_BasicFieldWithMissingValue[str]): and `Long text `__. """ - missing_value = "" - valid_types = str + +class SingleLineTextField(_StringField, _FieldSchema[S.SingleLineTextFieldSchema]): + """ + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + + See `Single line text `__ + """ + + +class MultilineTextField(_StringField, _FieldSchema[S.MultilineTextFieldSchema]): + """ + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + + See `Long text `__. + """ -class _NumericField(Generic[T], _BasicField[T]): +class _NumericField(_BasicField[T]): """ Base class for Number, Float, and Integer. Shares a common validation rule. """ @@ -334,44 +385,50 @@ def valid_or_raise(self, value: Any) -> None: raise TypeError( f"{self.__class__.__name__} value must be {self.valid_types}; got {type(value)}" ) - return super().valid_or_raise(value) + super().valid_or_raise(value) -class NumberField(_NumericField[Union[int, float]]): +class _NumberField(_NumericField[Union[int, float]]): + valid_types = (int, float) + + +class _IntegerField(_NumericField[int]): + valid_types = int + + +class _FloatField(_NumericField[float]): + valid_types = float + + +class NumberField(_NumberField, _FieldSchema[S.NumberFieldSchema]): """ Number field with unspecified precision. Accepts either ``int`` or ``float``. See `Number `__. """ - valid_types = (int, float) - # This cannot inherit from NumberField because valid_types would be more restrictive # in the subclass than what is defined in the parent class. -class IntegerField(_NumericField[int]): +class IntegerField(_IntegerField, _FieldSchema[S.NumberFieldSchema]): """ Number field with integer precision. Accepts only ``int`` values. See `Number `__. """ - valid_types = int - # This cannot inherit from NumberField because valid_types would be more restrictive # in the subclass than what is defined in the parent class. -class FloatField(_NumericField[float]): +class FloatField(_FloatField, _FieldSchema[S.NumberFieldSchema]): """ Number field with decimal precision. Accepts only ``float`` values. See `Number `__. """ - valid_types = float - -class RatingField(IntegerField): +class RatingField(_IntegerField, _FieldSchema[S.RatingFieldSchema]): """ Accepts ``int`` values that are greater than zero. @@ -384,7 +441,7 @@ def valid_or_raise(self, value: int) -> None: raise ValueError("rating cannot be below 1") -class CheckboxField(_BasicFieldWithMissingValue[bool]): +class CheckboxField(Field[bool, bool, bool], _FieldSchema[S.CheckboxFieldSchema]): """ Accepts ``bool``. Returns ``False`` instead of ``None`` if the field is empty on the Airtable base. @@ -396,13 +453,7 @@ class CheckboxField(_BasicFieldWithMissingValue[bool]): valid_types = bool -class DatetimeField(Field[str, datetime, None]): - """ - DateTime field. Accepts only `datetime `_ values. - - See `Date and time `__. - """ - +class _DatetimeField(Field[str, datetime, None]): valid_types = datetime def to_record_value(self, value: datetime) -> str: @@ -418,7 +469,15 @@ def to_internal_value(self, value: str) -> datetime: return utils.datetime_from_iso_str(value) -class DateField(Field[str, date, None]): +class DatetimeField(_DatetimeField, _FieldSchema[S.DateTimeFieldSchema]): + """ + DateTime field. Accepts only `datetime `_ values. + + See `Date and time `__. + """ + + +class DateField(Field[str, date, None], _FieldSchema[S.DateFieldSchema]): """ Date field. Accepts only `date `_ values. @@ -440,7 +499,7 @@ def to_internal_value(self, value: str) -> date: return utils.date_from_iso_str(value) -class DurationField(Field[int, timedelta, None]): +class DurationField(Field[int, timedelta, None], _FieldSchema[S.DurationFieldSchema]): """ Duration field. Accepts only `timedelta `_ values. @@ -463,7 +522,7 @@ def to_internal_value(self, value: Union[int, float]) -> timedelta: return timedelta(seconds=value) -class _DictField(Generic[T], _BasicField[T]): +class _DictField(_BasicField[T]): """ Generic field type that stores a single dict. Not for use via API; should be subclassed by concrete field types (below). @@ -556,7 +615,7 @@ def valid_or_raise(self, value: Any) -> None: raise TypeError(f"expected {self.contains_type}; got {type(obj)}") -class _ListField(Generic[T], _ListFieldBase[T, T, ChangeTrackingList[T]]): +class _ListField(_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). @@ -573,7 +632,12 @@ class _LinkFieldOptions(Enum): class LinkField( Generic[T_Linked], - _ListFieldBase[RecordId, T_Linked, ChangeTrackingList[T_Linked]], + _ListFieldBase[ + RecordId, + T_Linked, + ChangeTrackingList[T_Linked], + ], + _FieldSchema[S.MultipleRecordLinksFieldSchema], ): """ Represents a MultipleRecordLinks field. Returns and accepts lists of Models. @@ -769,7 +833,11 @@ def valid_or_raise(self, value: Any) -> None: raise TypeError(f"expected {self.linked_model}; got {type(obj)}") -class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]): +class SingleLinkField( + Generic[T_Linked], + Field[List[str], T_Linked, None], + _FieldSchema[S.MultipleRecordLinksFieldSchema], +): """ Represents a MultipleRecordLinks field which we assume will only ever contain one link. Returns and accepts a single instance of the linked model, which will be converted to/from @@ -912,7 +980,7 @@ def linked_model(self) -> Type[T_Linked]: # get some extra functionality for free in the future. -class AITextField(_DictField[AITextDict]): +class AITextField(_DictField[AITextDict], _FieldSchema[S.AITextFieldSchema]): """ Read-only field that returns a `dict`. For more information, read the `AI Text `_ @@ -924,10 +992,9 @@ class AITextField(_DictField[AITextDict]): class AttachmentsField( _ListFieldBase[ - AttachmentDict, - Union[AttachmentDict, CreateAttachmentDict], - AttachmentsList, + AttachmentDict, Union[AttachmentDict, CreateAttachmentDict], AttachmentsList ], + _FieldSchema[S.MultipleAttachmentsFieldSchema], list_class=AttachmentsList, contains_type=dict, ): @@ -937,7 +1004,7 @@ class AttachmentsField( """ -class BarcodeField(_DictField[BarcodeDict]): +class BarcodeField(_DictField[BarcodeDict], _FieldSchema[S.BarcodeFieldSchema]): """ Accepts a `dict` that should conform to the format detailed in the `Barcode `_ @@ -945,7 +1012,10 @@ class BarcodeField(_DictField[BarcodeDict]): """ -class CollaboratorField(_DictField[Union[CollaboratorDict, CollaboratorEmailDict]]): +class CollaboratorField( + _DictField[Union[CollaboratorDict, CollaboratorEmailDict]], + _FieldSchema[S.SingleCollaboratorFieldSchema], +): """ Accepts a `dict` that should conform to the format detailed in the `Collaborator `_ @@ -953,25 +1023,26 @@ class CollaboratorField(_DictField[Union[CollaboratorDict, CollaboratorEmailDict """ -class CountField(IntegerField): +class CountField(_IntegerField, _FieldSchema[S.CountFieldSchema]): """ Equivalent to :class:`IntegerField(readonly=True) `. See `Count `__. """ + valid_types = int readonly = True -class CurrencyField(NumberField): +class CurrencyField(_NumberField, _FieldSchema[S.CurrencyFieldSchema]): """ - Equivalent to :class:`~NumberField`. + Accepts either ``int`` or ``float``. See `Currency `__. """ -class EmailField(TextField): +class EmailField(_StringField, _FieldSchema[S.EmailFieldSchema]): """ Equivalent to :class:`~TextField`. @@ -979,7 +1050,9 @@ class EmailField(TextField): """ -class ExternalSyncSourceField(TextField): +class ExternalSyncSourceField( + _StringField, _FieldSchema[S.ExternalSyncSourceFieldSchema] +): """ Equivalent to :class:`TextField(readonly=True) `. @@ -989,7 +1062,9 @@ class ExternalSyncSourceField(TextField): readonly = True -class LastModifiedByField(_DictField[CollaboratorDict]): +class LastModifiedByField( + _DictField[CollaboratorDict], _FieldSchema[S.LastModifiedByFieldSchema] +): """ See `Last modified by `__. """ @@ -997,7 +1072,9 @@ class LastModifiedByField(_DictField[CollaboratorDict]): readonly = True -class LastModifiedTimeField(DatetimeField): +class LastModifiedTimeField( + _DatetimeField, _FieldSchema[S.LastModifiedTimeFieldSchema] +): """ Equivalent to :class:`DatetimeField(readonly=True) `. @@ -1007,7 +1084,7 @@ class LastModifiedTimeField(DatetimeField): readonly = True -class LookupField(Generic[T], _ListField[T]): +class LookupField(_ListField[T], _FieldSchema[S.MultipleLookupValuesFieldSchema]): """ Generic field class for a lookup, which returns a list of values. @@ -1029,7 +1106,9 @@ class LookupField(Generic[T], _ListField[T]): readonly = True -class ManualSortField(TextField): +class ManualSortField( + _BasicFieldWithMissingValue[str], _FieldSchema[S.ManualSortFieldSchema] +): """ Field configuration for ``manualSort`` field type (not documented). """ @@ -1038,7 +1117,9 @@ class ManualSortField(TextField): class MultipleCollaboratorsField( - _ListField[Union[CollaboratorDict, CollaboratorEmailDict]], contains_type=dict + _ListField[Union[CollaboratorDict, CollaboratorEmailDict]], + _FieldSchema[S.MultipleCollaboratorsFieldSchema], + contains_type=dict, ): """ Accepts a list of dicts in the format detailed in @@ -1046,7 +1127,9 @@ class MultipleCollaboratorsField( """ -class MultipleSelectField(_ListField[str], contains_type=str): +class MultipleSelectField( + _ListField[str], _FieldSchema[S.MultipleSelectsFieldSchema], contains_type=str +): """ Accepts a list of ``str``. @@ -1054,7 +1137,7 @@ class MultipleSelectField(_ListField[str], contains_type=str): """ -class PercentField(NumberField): +class PercentField(_NumberField, _FieldSchema[S.PercentFieldSchema]): """ Equivalent to :class:`~NumberField`. @@ -1062,7 +1145,7 @@ class PercentField(NumberField): """ -class PhoneNumberField(TextField): +class PhoneNumberField(_StringField, _FieldSchema[S.PhoneNumberFieldSchema]): """ Equivalent to :class:`~TextField`. @@ -1070,7 +1153,7 @@ class PhoneNumberField(TextField): """ -class RichTextField(TextField): +class RichTextField(_StringField, _FieldSchema[S.RichTextFieldSchema]): """ Equivalent to :class:`~TextField`. @@ -1078,7 +1161,7 @@ class RichTextField(TextField): """ -class SelectField(Field[str, str, None]): +class SelectField(Field[str, str, None], _FieldSchema[S.SingleSelectFieldSchema]): """ Represents a single select dropdown field. This will return ``None`` if no value is set, and will only return ``""`` if an empty dropdown option is available and selected. @@ -1089,7 +1172,7 @@ class SelectField(Field[str, str, None]): valid_types = str -class UrlField(TextField): +class UrlField(_StringField, _FieldSchema[S.UrlFieldSchema]): """ Equivalent to :class:`~TextField`. @@ -1097,87 +1180,7 @@ class UrlField(TextField): """ -# Auto-generate Required*Field classes for anything above this line -# fmt: off -r"""[[[cog]]] -import re -from collections import namedtuple - -with open(cog.inFile) as fp: - src = "".join(fp.readlines()[:cog.firstLineNum]) - -Match = namedtuple('Match', 'cls generic bases annotation cls_kwargs doc readonly') -expr = ( - r'(?m)' - r'^class ([A-Z]\w+Field)' - r'\(' - # This particular regex will not pick up Field subclasses that have - # multiple inheritance, which excludes anything using _NotNullField. - r'(?:(Generic\[.+?\]), )?' - r'([_A-Z][_A-Za-z]+)(?:\[(.+?)\])?' - r'((?:, [a-z_]+=.+)+)?' - r'\):\n' - r' \"\"\"\n ((?:.|\n)+?) \"\"\"(?:\n| (?!readonly =).*)*' - r'( readonly = True)?' -) -classes = { - match.cls: match - for group in re.findall(expr, src) - if (match := Match(*group)) -} - -for cls, match in sorted(classes.items()): - if cls in { - # checkbox values are either `True` or missing - "CheckboxField", - - # null value will be converted to an empty list - "AttachmentsField", - "LinkField", - "LookupField", - "MultipleCollaboratorsField", - "MultipleSelectField", - - # unsupported - "SingleLinkField", - - # illogical - "LastModifiedByField", - "LastModifiedTimeField", - "ExternalSyncSourceField", - "ManualSortField", - }: - continue - - # skip if we've already included Required - if cls.startswith("Required") or "Required" in match.bases: - continue - - base, typn = match.cls, match.annotation - typn_bases = match.bases - while not typn: - typn = classes[typn_bases].annotation - typn_bases = classes[typn_bases].bases - - if typn.endswith(", None"): - typn = typn[:-len(", None")] - - mixin = ("_" if re.match(r"^[A-Za-z_.]+(\[\w+(, \w+)*\])?,", typn) else "_Basic") + "FieldWithRequiredValue" - base = base if not match.generic else f"{base}, {match.generic}" - cog.outl(f"\n\nclass Required{cls}({base}, {mixin}[{typn}]):") - cog.outl(' \"\"\"') - cog.outl(' ' + match.doc) - cog.out( ' If the Airtable API returns ``null``, ') - if not match.readonly: - cog.out('or if a caller sets this field to ``None``,\n ') - cog.outl('this field raises :class:`~pyairtable.orm.fields.MissingValue`.') - cog.outl(' \"\"\"') - -cog.outl('\n') -[[[out]]]""" - - -class RequiredAITextField(AITextField, _BasicFieldWithRequiredValue[AITextDict]): +class RequiredAITextField(AITextField, _Requires[AITextDict]): """ Read-only field that returns a `dict`. For more information, read the `AI Text `_ @@ -1187,7 +1190,7 @@ class RequiredAITextField(AITextField, _BasicFieldWithRequiredValue[AITextDict]) """ -class RequiredBarcodeField(BarcodeField, _BasicFieldWithRequiredValue[BarcodeDict]): +class RequiredBarcodeField(BarcodeField, _Requires[BarcodeDict]): """ Accepts a `dict` that should conform to the format detailed in the `Barcode `_ @@ -1198,7 +1201,10 @@ class RequiredBarcodeField(BarcodeField, _BasicFieldWithRequiredValue[BarcodeDic """ -class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[Union[CollaboratorDict, CollaboratorEmailDict]]): +class RequiredCollaboratorField( + CollaboratorField, + _Requires[Union[CollaboratorDict, CollaboratorEmailDict]], +): """ Accepts a `dict` that should conform to the format detailed in the `Collaborator `_ @@ -1209,7 +1215,7 @@ class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[ """ -class RequiredCountField(CountField, _BasicFieldWithRequiredValue[int]): +class RequiredCountField(CountField, _Requires[int]): """ Equivalent to :class:`IntegerField(readonly=True) `. @@ -1219,7 +1225,7 @@ class RequiredCountField(CountField, _BasicFieldWithRequiredValue[int]): """ -class RequiredCurrencyField(CurrencyField, _BasicFieldWithRequiredValue[Union[int, float]]): +class RequiredCurrencyField(CurrencyField, _Requires[Union[int, float]]): """ Equivalent to :class:`~NumberField`. @@ -1230,7 +1236,7 @@ class RequiredCurrencyField(CurrencyField, _BasicFieldWithRequiredValue[Union[in """ -class RequiredDateField(DateField, _FieldWithRequiredValue[str, date]): +class RequiredDateField(DateField, _Requires_API_ORM[str, date]): """ Date field. Accepts only `date `_ values. @@ -1241,7 +1247,7 @@ class RequiredDateField(DateField, _FieldWithRequiredValue[str, date]): """ -class RequiredDatetimeField(DatetimeField, _FieldWithRequiredValue[str, datetime]): +class RequiredDatetimeField(DatetimeField, _Requires_API_ORM[str, datetime]): """ DateTime field. Accepts only `datetime `_ values. @@ -1252,7 +1258,7 @@ class RequiredDatetimeField(DatetimeField, _FieldWithRequiredValue[str, datetime """ -class RequiredDurationField(DurationField, _FieldWithRequiredValue[int, timedelta]): +class RequiredDurationField(DurationField, _Requires_API_ORM[int, timedelta]): """ Duration field. Accepts only `timedelta `_ values. @@ -1264,7 +1270,7 @@ class RequiredDurationField(DurationField, _FieldWithRequiredValue[int, timedelt """ -class RequiredEmailField(EmailField, _BasicFieldWithRequiredValue[str]): +class RequiredEmailField(EmailField, _Requires[str]): """ Equivalent to :class:`~TextField`. @@ -1275,7 +1281,7 @@ class RequiredEmailField(EmailField, _BasicFieldWithRequiredValue[str]): """ -class RequiredFloatField(FloatField, _BasicFieldWithRequiredValue[float]): +class RequiredFloatField(FloatField, _Requires[float]): """ Number field with decimal precision. Accepts only ``float`` values. @@ -1286,7 +1292,7 @@ class RequiredFloatField(FloatField, _BasicFieldWithRequiredValue[float]): """ -class RequiredIntegerField(IntegerField, _BasicFieldWithRequiredValue[int]): +class RequiredIntegerField(IntegerField, _Requires[int]): """ Number field with integer precision. Accepts only ``int`` values. @@ -1297,7 +1303,7 @@ class RequiredIntegerField(IntegerField, _BasicFieldWithRequiredValue[int]): """ -class RequiredNumberField(NumberField, _BasicFieldWithRequiredValue[Union[int, float]]): +class RequiredNumberField(NumberField, _Requires[Union[int, float]]): """ Number field with unspecified precision. Accepts either ``int`` or ``float``. @@ -1308,7 +1314,7 @@ class RequiredNumberField(NumberField, _BasicFieldWithRequiredValue[Union[int, f """ -class RequiredPercentField(PercentField, _BasicFieldWithRequiredValue[Union[int, float]]): +class RequiredPercentField(PercentField, _Requires[Union[int, float]]): """ Equivalent to :class:`~NumberField`. @@ -1319,7 +1325,7 @@ class RequiredPercentField(PercentField, _BasicFieldWithRequiredValue[Union[int, """ -class RequiredPhoneNumberField(PhoneNumberField, _BasicFieldWithRequiredValue[str]): +class RequiredPhoneNumberField(PhoneNumberField, _Requires[str]): """ Equivalent to :class:`~TextField`. @@ -1330,7 +1336,7 @@ class RequiredPhoneNumberField(PhoneNumberField, _BasicFieldWithRequiredValue[st """ -class RequiredRatingField(RatingField, _BasicFieldWithRequiredValue[int]): +class RequiredRatingField(RatingField, _Requires[int]): """ Accepts ``int`` values that are greater than zero. @@ -1341,7 +1347,7 @@ class RequiredRatingField(RatingField, _BasicFieldWithRequiredValue[int]): """ -class RequiredRichTextField(RichTextField, _BasicFieldWithRequiredValue[str]): +class RequiredRichTextField(RichTextField, _Requires[str]): """ Equivalent to :class:`~TextField`. @@ -1352,7 +1358,7 @@ class RequiredRichTextField(RichTextField, _BasicFieldWithRequiredValue[str]): """ -class RequiredSelectField(SelectField, _FieldWithRequiredValue[str, str]): +class RequiredSelectField(SelectField, _Requires_API_ORM[str, str]): """ Represents a single select dropdown field. This will return ``None`` if no value is set, and will only return ``""`` if an empty dropdown option is available and selected. @@ -1364,7 +1370,7 @@ class RequiredSelectField(SelectField, _FieldWithRequiredValue[str, str]): """ -class RequiredTextField(TextField, _BasicFieldWithRequiredValue[str]): +class RequiredTextField(TextField, _Requires[str]): """ Accepts ``str``. Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. @@ -1377,22 +1383,46 @@ class RequiredTextField(TextField, _BasicFieldWithRequiredValue[str]): """ -class RequiredUrlField(UrlField, _BasicFieldWithRequiredValue[str]): +class RequiredSingleLineTextField(SingleLineTextField, _Requires[str]): """ - Equivalent to :class:`~TextField`. + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. - See `Url `__. + See `Single line text `__. + + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class RequiredMultilineTextField(MultilineTextField, _Requires[str]): + """ + Accepts ``str``. + Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + + See `Long text `__. If the Airtable API returns ``null``, or if a caller sets this field to ``None``, this field raises :class:`~pyairtable.orm.fields.MissingValue`. """ -# [[[end]]] (checksum: 5078434bb8fd65fa8f0be48de6915c2d) -# fmt: on +class RequiredUrlField(UrlField, _Requires[str]): + """ + Equivalent to :class:`~TextField`. + See `Url `__. -class AutoNumberField(RequiredIntegerField): + If the Airtable API returns ``null``, or if a caller sets this field to ``None``, + this field raises :class:`~pyairtable.orm.fields.MissingValue`. + """ + + +class AutoNumberField( + _IntegerField, + _Requires[int], + _FieldSchema[S.AutoNumberFieldSchema], +): """ Equivalent to :class:`IntegerField(readonly=True) `. @@ -1404,7 +1434,11 @@ class AutoNumberField(RequiredIntegerField): readonly = True -class ButtonField(_DictField[ButtonDict], _BasicFieldWithRequiredValue[ButtonDict]): +class ButtonField( + _DictField[ButtonDict], + _Requires[ButtonDict], + _FieldSchema[S.ButtonFieldSchema], +): """ Read-only field that returns a `dict`. For more information, read the `Button `_ @@ -1416,7 +1450,11 @@ class ButtonField(_DictField[ButtonDict], _BasicFieldWithRequiredValue[ButtonDic readonly = True -class CreatedByField(_BasicFieldWithRequiredValue[CollaboratorDict]): +class CreatedByField( + _DictField[CollaboratorDict], + _Requires[CollaboratorDict], + _FieldSchema[S.CreatedByFieldSchema], +): """ See `Created by `__. @@ -1426,7 +1464,11 @@ class CreatedByField(_BasicFieldWithRequiredValue[CollaboratorDict]): readonly = True -class CreatedTimeField(RequiredDatetimeField): +class CreatedTimeField( + _DatetimeField, + _Requires_API_ORM[str, datetime], + _FieldSchema[S.CreatedTimeFieldSchema], +): """ Equivalent to :class:`DatetimeField(readonly=True) `. @@ -1568,6 +1610,7 @@ class CreatedTimeField(RequiredDatetimeField): "LinkSelf", "LookupField", "ManualSortField", + "MultilineTextField", "MultipleCollaboratorsField", "MultipleSelectField", "NumberField", @@ -1585,18 +1628,21 @@ class CreatedTimeField(RequiredDatetimeField): "RequiredEmailField", "RequiredFloatField", "RequiredIntegerField", + "RequiredMultilineTextField", "RequiredNumberField", "RequiredPercentField", "RequiredPhoneNumberField", "RequiredRatingField", "RequiredRichTextField", "RequiredSelectField", + "RequiredSingleLineTextField", "RequiredTextField", "RequiredUrlField", "RichTextField", "SelectField", + "SingleLineTextField", "SingleLinkField", "TextField", "UrlField", ] -# [[[end]]] (checksum: 87b0a100c9e30523d9aab8cc935c7960) +# [[[end]]] (checksum: 5a8ecad26031895bfaac551e751b7278) diff --git a/tests/conftest.py b/tests/conftest.py index b2790f7c..b27bfe75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,9 +10,12 @@ import pytest from mock import Mock from requests import HTTPError +from requests_mock import Mocker from pyairtable import Api, Base, Table, Workspace from pyairtable.api.enterprise import Enterprise +from pyairtable.models.schema import TableSchema +from pyairtable.testing import fake_id @pytest.fixture @@ -59,6 +62,21 @@ def table(base: Base, constants) -> Table: return base.table(constants["TABLE_NAME"]) +@pytest.fixture() +def table_schema(sample_json, api, base) -> TableSchema: + return TableSchema.model_validate(sample_json("TableSchema")) + + +@pytest.fixture +def mock_table_schema(table, requests_mock, sample_json) -> Mocker: + table_schema = sample_json("TableSchema") + table_schema["id"] = table.name = fake_id("tbl") + return requests_mock.get( + table.base.urls.tables + "?include=visibleFieldIds", + json={"tables": [table_schema]}, + ) + + @pytest.fixture def workspace_id() -> str: return "wspmhESAta6clCCwF" # see WorkspaceCollaborators.json diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py index 0114b047..605f217e 100644 --- a/tests/integration/test_integration_orm.py +++ b/tests/integration/test_integration_orm.py @@ -202,6 +202,11 @@ def test_every_field(Everything): f.AITextField, f.RequiredAITextField, f.ManualSortField, + # These are so similar to TextField we don't need to integration test them + f.SingleLineTextField, + f.MultilineTextField, + f.RequiredSingleLineTextField, + f.RequiredMultilineTextField, }: continue assert field_class in classes_used diff --git a/tests/test_api_table.py b/tests/test_api_table.py index 1de9e5ba..e9f1757a 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -20,7 +20,7 @@ def table_schema(sample_json, api, base) -> TableSchema: @pytest.fixture -def mock_schema(table, requests_mock, sample_json): +def mock_table_schema(table, requests_mock, sample_json): table_schema = sample_json("TableSchema") table_schema["id"] = table.name = fake_id("tbl") return requests_mock.get( @@ -443,7 +443,7 @@ def test_batch_delete(table: Table, container, mock_records): assert resp == expected -def test_create_field(table, mock_schema, requests_mock, sample_json): +def test_create_field(table, mock_table_schema, requests_mock, sample_json): """ Tests the API for creating a field (but without actually performing the operation). """ @@ -454,7 +454,7 @@ def test_create_field(table, mock_schema, requests_mock, sample_json): # Ensure we have pre-loaded our schema table.schema() - assert mock_schema.call_count == 1 + assert mock_table_schema.call_count == 1 # Create the field choices = ["Todo", "In progress", "Done"] @@ -482,10 +482,10 @@ def test_create_field(table, mock_schema, requests_mock, sample_json): # Test that the schema has been updated without a second API call assert table._schema.field(fld.id).name == "Status" - assert mock_schema.call_count == 1 + assert mock_table_schema.call_count == 1 -def test_delete_view(table, mock_schema, requests_mock): +def test_delete_view(table, mock_table_schema, requests_mock): view = table.schema().view("Grid view") m = requests_mock.delete(view._url) view.delete() diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 4c505ecf..bc318737 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -8,6 +8,7 @@ import pyairtable.exceptions from pyairtable.formulas import OR, RECORD_ID +from pyairtable.models import schema from pyairtable.orm import fields as f from pyairtable.orm.lists import AttachmentsList from pyairtable.orm.model import Model @@ -157,6 +158,7 @@ class T(Model): (f.EmailField, str), (f.FloatField, float), (f.IntegerField, int), + (f.MultilineTextField, str), (f.MultipleCollaboratorsField, list), (f.MultipleSelectField, list), (f.NumberField, (int, float)), @@ -165,7 +167,7 @@ class T(Model): (f.RatingField, int), (f.RichTextField, str), (f.SelectField, str), - (f.TextField, str), + (f.SingleLineTextField, str), (f.TextField, str), (f.UrlField, str), (f.RequiredBarcodeField, dict), @@ -174,15 +176,17 @@ class T(Model): (f.RequiredDateField, (datetime.date, datetime.datetime)), (f.RequiredDatetimeField, datetime.datetime), (f.RequiredDurationField, datetime.timedelta), + (f.RequiredEmailField, str), (f.RequiredFloatField, float), (f.RequiredIntegerField, int), + (f.RequiredMultilineTextField, str), (f.RequiredNumberField, (int, float)), (f.RequiredPercentField, (int, float)), - (f.RequiredRatingField, int), - (f.RequiredSelectField, str), - (f.RequiredEmailField, str), (f.RequiredPhoneNumberField, str), + (f.RequiredRatingField, int), (f.RequiredRichTextField, str), + (f.RequiredSelectField, str), + (f.RequiredSingleLineTextField, str), (f.RequiredTextField, str), (f.RequiredUrlField, str), ], @@ -311,6 +315,8 @@ class T(Model): # If a 2-tuple, the API and ORM values should be identical. (f.Field, object()), # accepts any value, but Airtable API *will* complain (f.TextField, "name"), + (f.SingleLineTextField, "name"), + (f.MultilineTextField, "some\nthing\nbig"), (f.EmailField, "x@y.com"), (f.NumberField, 1), (f.NumberField, 1.5), @@ -344,6 +350,8 @@ class T(Model): (f.RequiredPhoneNumberField, "any value"), (f.RequiredRichTextField, "any value"), (f.RequiredTextField, "any value"), + (f.RequiredSingleLineTextField, "any value"), + (f.RequiredMultilineTextField, "any value"), (f.RequiredUrlField, "any value"), # If a 3-tuple, we should be able to convert API -> ORM values. (f.DateField, DATE_S, DATE_V), @@ -415,6 +423,8 @@ class T(Model): f.RichTextField, f.SelectField, f.TextField, + f.SingleLineTextField, + f.MultilineTextField, f.UrlField, ], ) @@ -449,12 +459,14 @@ class T(Model): f.RequiredEmailField, f.RequiredFloatField, f.RequiredIntegerField, + f.RequiredMultilineTextField, f.RequiredNumberField, f.RequiredPercentField, f.RequiredPhoneNumberField, f.RequiredRatingField, f.RequiredRichTextField, f.RequiredSelectField, + f.RequiredSingleLineTextField, f.RequiredTextField, f.RequiredUrlField, ], @@ -1167,3 +1179,34 @@ class T(Model): with pytest.raises(TypeError): T().attachments = [1, 2, 3] + + +def test_field_schema(table, mock_table_schema): + """ + Test that an ORM field can retrieve its own field schema. + """ + + class Apartment(Model): + class Meta: + api_key = fake_id("pat") + base_id = table.base.id + table_name = "Apartments" + + name = f.TextField("Name") + pictures = f.AttachmentsField("Pictures") + + name = Apartment.name.field_schema() + assert isinstance(name, schema.SingleLineTextFieldSchema) + assert name.id == "fld1VnoyuotSTyxW1" + + pictures = Apartment.pictures.field_schema() + assert isinstance(pictures, schema.MultipleAttachmentsFieldSchema) + assert pictures.id == "fldoaIqdn5szURHpw" + assert pictures.options.is_reversed is False + + +def test_field_schema__detached(table, requests_mock): + with pytest.raises(RuntimeError): + f.TextField("Detached Field").field_schema() + with pytest.raises(RuntimeError): + f._FieldSchema().field_schema() diff --git a/tests/test_typing.py b/tests/test_typing.py index 41724789..77f020c1 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -12,6 +12,7 @@ import pyairtable.orm.lists as L import pyairtable.utils from pyairtable import orm +from pyairtable.models import schema if TYPE_CHECKING: # This section does not actually get executed; it is only parsed by mypy. @@ -70,8 +71,9 @@ # Test type annotations for the ORM class Actor(orm.Model): - name = orm.fields.TextField("Name") + name = orm.fields.SingleLineTextField("Name") logins = orm.fields.MultipleCollaboratorsField("Logins") + bio = orm.fields.MultilineTextField("Bio") assert_type(Actor().name, str) assert_type( @@ -147,6 +149,7 @@ class EveryField(orm.Model): required_select = orm.fields.RequiredSelectField("Status") required_url = orm.fields.RequiredUrlField("URL") + # Check the types of values returned from these fields # fmt: off record = EveryField() assert_type(record.aitext, Optional[T.AITextDict]) @@ -199,6 +202,58 @@ class EveryField(orm.Model): assert_type(record.required_rich_text, str) assert_type(record.required_select, str) assert_type(record.required_url, str) + + # Check the types of each field schema + assert_type(Movie.name.field_schema(), Union[schema.SingleLineTextFieldSchema, schema.MultilineTextFieldSchema]) + assert_type(Actor.name.field_schema(), schema.SingleLineTextFieldSchema) + assert_type(Actor.bio.field_schema(), schema.MultilineTextFieldSchema) + assert_type(EveryField.aitext.field_schema(), schema.AITextFieldSchema) + assert_type(EveryField.attachments.field_schema(), schema.MultipleAttachmentsFieldSchema) + assert_type(EveryField.autonumber.field_schema(), schema.AutoNumberFieldSchema) + assert_type(EveryField.barcode.field_schema(), schema.BarcodeFieldSchema) + assert_type(EveryField.button.field_schema(), schema.ButtonFieldSchema) + assert_type(EveryField.checkbox.field_schema(), schema.CheckboxFieldSchema) + assert_type(EveryField.collaborator.field_schema(), schema.SingleCollaboratorFieldSchema) + assert_type(EveryField.count.field_schema(), schema.CountFieldSchema) + assert_type(EveryField.created_by.field_schema(), schema.CreatedByFieldSchema) + assert_type(EveryField.created.field_schema(), schema.CreatedTimeFieldSchema) + assert_type(EveryField.currency.field_schema(), schema.CurrencyFieldSchema) + assert_type(EveryField.date.field_schema(), schema.DateFieldSchema) + assert_type(EveryField.datetime.field_schema(), schema.DateTimeFieldSchema) + assert_type(EveryField.duration.field_schema(), schema.DurationFieldSchema) + assert_type(EveryField.email.field_schema(), schema.EmailFieldSchema) + assert_type(EveryField.float.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.integer.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.last_modified_by.field_schema(), schema.LastModifiedByFieldSchema) + assert_type(EveryField.last_modified.field_schema(), schema.LastModifiedTimeFieldSchema) + assert_type(EveryField.multi_user.field_schema(), schema.MultipleCollaboratorsFieldSchema) + assert_type(EveryField.multi_select.field_schema(), schema.MultipleSelectsFieldSchema) + assert_type(EveryField.number.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.percent.field_schema(), schema.PercentFieldSchema) + assert_type(EveryField.phone.field_schema(), schema.PhoneNumberFieldSchema) + assert_type(EveryField.rating.field_schema(), schema.RatingFieldSchema) + assert_type(EveryField.rich_text.field_schema(), schema.RichTextFieldSchema) + assert_type(EveryField.select.field_schema(), schema.SingleSelectFieldSchema) + assert_type(EveryField.url.field_schema(), schema.UrlFieldSchema) + assert_type(EveryField.required_aitext.field_schema(), schema.AITextFieldSchema) + assert_type(EveryField.required_barcode.field_schema(), schema.BarcodeFieldSchema) + assert_type(EveryField.required_collaborator.field_schema(), schema.SingleCollaboratorFieldSchema) + assert_type(EveryField.required_count.field_schema(), schema.CountFieldSchema) + assert_type(EveryField.required_currency.field_schema(), schema.CurrencyFieldSchema) + assert_type(EveryField.required_date.field_schema(), schema.DateFieldSchema) + assert_type(EveryField.required_datetime.field_schema(), schema.DateTimeFieldSchema) + assert_type(EveryField.required_duration.field_schema(), schema.DurationFieldSchema) + assert_type(EveryField.required_email.field_schema(), schema.EmailFieldSchema) + assert_type(EveryField.required_float.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.required_integer.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.required_number.field_schema(), schema.NumberFieldSchema) + assert_type(EveryField.required_percent.field_schema(), schema.PercentFieldSchema) + assert_type(EveryField.required_phone.field_schema(), schema.PhoneNumberFieldSchema) + assert_type(EveryField.required_rating.field_schema(), schema.RatingFieldSchema) + assert_type(EveryField.required_rich_text.field_schema(), schema.RichTextFieldSchema) + assert_type(EveryField.required_select.field_schema(), schema.SingleSelectFieldSchema) + assert_type(EveryField.required_url.field_schema(), schema.UrlFieldSchema) + assert_type(EveryField.meta.table.schema().field("Anything"), schema.FieldSchema) # fmt: on # Check that the type system allows create-style dicts in all places From 5cdb1338a673d4b999cb025f241077c27ca2364d Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 16 Mar 2025 14:14:04 -0700 Subject: [PATCH 242/272] Update documentation for ORM fields --- docs/source/orm.rst | 92 +++++++++++++++++++++++++++++++--------- tests/test_orm_fields.py | 4 +- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 63ac1b31..2c896258 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -187,6 +187,8 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.ManualSortField` ๐Ÿ”’ - (undocumented) + * - :class:`~pyairtable.orm.fields.MultilineTextField` + - `Long text `__ * - :class:`~pyairtable.orm.fields.MultipleCollaboratorsField` - `Multiple Collaborators `__ * - :class:`~pyairtable.orm.fields.MultipleSelectField` @@ -203,6 +205,8 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.SelectField` - `Single select `__ + * - :class:`~pyairtable.orm.fields.SingleLineTextField` + - `Single line text `__ * - :class:`~pyairtable.orm.fields.SingleLinkField` - `Link to another record `__ * - :class:`~pyairtable.orm.fields.TextField` @@ -243,6 +247,8 @@ See :ref:`Required Values` for more details. - `Number `__ * - :class:`~pyairtable.orm.fields.RequiredIntegerField` - `Number `__ + * - :class:`~pyairtable.orm.fields.RequiredMultilineTextField` + - `Long text `__ * - :class:`~pyairtable.orm.fields.RequiredNumberField` - `Number `__ * - :class:`~pyairtable.orm.fields.RequiredPercentField` @@ -255,11 +261,41 @@ See :ref:`Required Values` for more details. - `Rich text `__ * - :class:`~pyairtable.orm.fields.RequiredSelectField` - `Single select `__ + * - :class:`~pyairtable.orm.fields.RequiredSingleLineTextField` + - `Single line text `__ * - :class:`~pyairtable.orm.fields.RequiredTextField` - `Single line text `__, `Long text `__ * - :class:`~pyairtable.orm.fields.RequiredUrlField` - `Url `__ -.. [[[end]]] (checksum: 43c56200ca513d3a0603bb5a6ddbb1ef) +.. [[[end]]] (checksum: 658b792ee9eb180dc5600d76663c8a7e) + + +Type Annotations +------------------ + +pyAirtable uses type annotations to provide hints to type checkers like mypy. +Type annotations improve code readability and help catch errors during development. + +Basic field types like :class:`~pyairtable.orm.fields.TextField` and :class:`~pyairtable.orm.fields.IntegerField` +will have their types inferred from the field's configuration. For example: + +.. code-block:: python + + from pyairtable.orm import Model, fields as F + + class Person(Model): + class Meta: ... + + name = F.TextField("Name") + account_id = F.IntegerField("Account ID") + edited_by = F.LastModifiedByField("Last Modified By") + + record = Person() + reveal_type(record.name) # Revealed type is 'builtins.str*' + reveal_type(record.account_id) # Revealed type is 'builtins.int*' + reveal_type(record.edited_by) # Revealed type is 'pyairtable.api.types.CollaboratorDict' + +You may need to provide type hints to complex fields that involve lists. See below for examples. Formula, Rollup, and Lookup Fields @@ -364,6 +400,7 @@ null-handling checks all over your code, if you are confident that the workflows around your Airtable base will not produce an empty value (or that an empty value is enough of a problem that your code should raise an exception). + Linked Records ---------------- @@ -508,14 +545,8 @@ This code will perform a series of API calls at the beginning to fetch all records from the Books and Authors tables, so that ``author.books`` does not need to request linked records one at a time during the loop. -.. note:: - Memoization does not affect whether pyAirtable will make an API call. - It only affects whether pyAirtable will reuse a model instance that - was already created, or create a new one. For example, calling - ``model.all(memoize=True)`` N times will still result in N calls to the API. - -You can also set ``memoize = True`` in the ``Meta`` configuration for your model, -which indicates that you always want to memoize models retrieved from the API: +If you always want to memoize models retrieved from the API, you can set +``memoize = True`` in the ``Meta`` configuration for your model: .. code-block:: python @@ -532,17 +563,38 @@ which indicates that you always want to memoize models retrieved from the API: Author.first().books # this will memoize all books created Book.all(memoize=False) # this will skip memoization -The following methods support the ``memoize=`` keyword argument. -You can pass ``memoize=False`` to override memoization that is -enabled on the model configuration. - - * :meth:`Model.all ` - * :meth:`Model.first ` - * :meth:`Model.from_record ` - * :meth:`Model.from_id ` - * :meth:`Model.from_ids ` - * :meth:`LinkField.populate ` - * :meth:`SingleLinkField.populate ` + +The following methods support the ``memoize=`` keyword argument to control +whether the ORM saves the models it creates for later reuse. If a model is +configured to memoize by default, pass ``memoize=False`` to override it. + +.. list-table:: + :header-rows: 1 + + * - Retrieval function + - Will it reuse saved models? + - Will it call the API? + * - :meth:`Model.all ` + - Never + - Always + * - :meth:`Model.first ` + - Never + - Always + * - :meth:`Model.from_record ` + - Never + - Never + * - :meth:`Model.from_id ` + - Yes + - Yes, unless ``fetch=True`` + * - :meth:`Model.from_ids ` + - Yes + - Yes, unless ``fetch=True`` + * - :meth:`LinkField.populate ` + - Yes + - Yes, unless ``lazy=True`` + * - :meth:`SingleLinkField.populate ` + - Yes + - Yes, unless ``lazy=True`` Comments diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index bc318737..8702fbdc 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -413,6 +413,7 @@ class T(Model): f.LastModifiedTimeField, f.LookupField, f.ManualSortField, + f.MultilineTextField, f.MultipleCollaboratorsField, f.MultipleSelectField, f.NumberField, @@ -422,9 +423,8 @@ class T(Model): f.RatingField, f.RichTextField, f.SelectField, - f.TextField, f.SingleLineTextField, - f.MultilineTextField, + f.TextField, f.UrlField, ], ) From 7228f228d4206a4f831d8db5f156bb247f11d260 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 16 Mar 2025 14:14:05 -0700 Subject: [PATCH 243/272] Reorganize orm.fields and make docs more consistent --- docs/source/orm.rst | 12 +- pyairtable/orm/fields.py | 726 +++++++++++++++++---------------------- pyairtable/utils.py | 4 +- 3 files changed, 323 insertions(+), 419 deletions(-) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 2c896258..cb7ee72a 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -170,7 +170,7 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.EmailField` - - `Email `__ + - `Email `__ * - :class:`~pyairtable.orm.fields.ExternalSyncSourceField` ๐Ÿ”’ - `Sync source `__ * - :class:`~pyairtable.orm.fields.FloatField` @@ -190,7 +190,7 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.MultipleCollaboratorsField` - - `Multiple Collaborators `__ + - `Multiple collaborators `__ * - :class:`~pyairtable.orm.fields.MultipleSelectField` - `Multiple select `__ * - :class:`~pyairtable.orm.fields.NumberField` @@ -202,7 +202,7 @@ read `Field types and cell values `__ * - :class:`~pyairtable.orm.fields.RichTextField` - - `Rich text `__ + - `Rich text `__ * - :class:`~pyairtable.orm.fields.SelectField` - `Single select `__ * - :class:`~pyairtable.orm.fields.SingleLineTextField` @@ -242,7 +242,7 @@ See :ref:`Required Values` for more details. * - :class:`~pyairtable.orm.fields.RequiredDurationField` - `Duration `__ * - :class:`~pyairtable.orm.fields.RequiredEmailField` - - `Email `__ + - `Email `__ * - :class:`~pyairtable.orm.fields.RequiredFloatField` - `Number `__ * - :class:`~pyairtable.orm.fields.RequiredIntegerField` @@ -258,7 +258,7 @@ See :ref:`Required Values` for more details. * - :class:`~pyairtable.orm.fields.RequiredRatingField` - `Rating `__ * - :class:`~pyairtable.orm.fields.RequiredRichTextField` - - `Rich text `__ + - `Rich text `__ * - :class:`~pyairtable.orm.fields.RequiredSelectField` - `Single select `__ * - :class:`~pyairtable.orm.fields.RequiredSingleLineTextField` @@ -267,7 +267,7 @@ See :ref:`Required Values` for more details. - `Single line text `__, `Long text `__ * - :class:`~pyairtable.orm.fields.RequiredUrlField` - `Url `__ -.. [[[end]]] (checksum: 658b792ee9eb180dc5600d76663c8a7e) +.. [[[end]]] (checksum: 3ed2090cb24140caa19860a20b0f5a33) Type Annotations diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 2d34e70f..4be0be40 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -27,11 +27,13 @@ import abc import importlib +import re from datetime import date, datetime, timedelta from enum import Enum from typing import ( TYPE_CHECKING, Any, + Callable, ClassVar, Dict, Generic, @@ -332,49 +334,168 @@ def field_schema(self) -> T_FieldSchema: AnyField: TypeAlias = Field[Any, Any, Any] -class _StringField(_BasicFieldWithMissingValue[str]): +# ====================================================== +# Helpers for field class documentation +# ====================================================== + + +T_Field = TypeVar("T_Field", bound=Type[Field[Any, Any, Any]]) + + +def _use_inherited_docstring(cls: T_Field) -> T_Field: """ - Base class for fields that return Unicode strings. + Reuses the class's first parent class's docstring if it's undocumented. """ + from_cls: Type[Any] = cls + while len(from_cls.__mro__) > 1 and not from_cls.__doc__: + from_cls = from_cls.__mro__[1] + if from_cls is Field: + raise RuntimeError(f"{cls} needs a docstring") # pragma: no cover + cls.__doc__ = from_cls.__doc__ + return cls - missing_value = "" - valid_types = str +def _maybe_readonly_docstring(cls: T_Field) -> T_Field: + """ + Modifies the class docstring to indicate read-only vs. writable. + """ + _use_inherited_docstring(cls) + if cls.readonly: + cls.__doc__ = re.sub( + r"Accepts (?:only )?((?:[^.`]+|`[^`]+`)+)\.", + r"Read only. Returns \1.", + cls.__doc__ or "", + ) + return cls -class TextField( - _StringField, - _FieldSchema[Union[S.SingleLineTextFieldSchema, S.MultilineTextFieldSchema]], -): + +def _field_api_docstring(*refs_tags: str) -> Callable[[T_Field], T_Field]: """ - Deprecated; use :class:`~SingleLineTextField` or :class:`~MultilineTextField`. + Appends the class docstring with a link to the Airtable API documentation for the field type. + If the class is undocumented, reuses the class's first parent class's docstring. + """ + if len(refs_tags) == 1: + refs_tags = (refs_tags[0], refs_tags[0].lower().replace(" ", "")) + joiner = " and " if len(refs_tags) == 4 else ", " + link_text = "For more about this field type, see %s." % joiner.join( + f"`{ref} `__" + for (ref, tag) in zip(refs_tags[::2], refs_tags[1::2]) + ) - Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + def _wrapper(cls: T_Field) -> T_Field: + _use_inherited_docstring(cls) + _maybe_readonly_docstring(cls) + utils._append_docstring_text(cls, link_text) + return cls - See `Single line text `__ - and `Long text `__. + return _wrapper + + +def _required_value_docstring(cls: T_Field) -> T_Field: + """ + Appends the class's docstring so that it includes a note about required values. + If the class is undocumented, reuses the class's first parent class's docstring. """ + _use_inherited_docstring(cls) + _maybe_readonly_docstring(cls) + append = "If the Airtable API returns ``null``, " + if not cls.readonly: + append += "or if a caller sets this field to ``None`` or ``''``, " + append += "this field raises :class:`~pyairtable.exceptions.MissingValueError`." + utils._append_docstring_text(cls, append) + return cls -class SingleLineTextField(_StringField, _FieldSchema[S.SingleLineTextFieldSchema]): +# ====================================================== +# Field types that contain text values +# ====================================================== + + +class _StringField(_BasicFieldWithMissingValue[str]): """ Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + Returns ``""`` instead of ``None`` if the field is empty. + """ + + missing_value = "" + valid_types = str - See `Single line text `__ + +@_field_api_docstring("Email", "emailtext") +class EmailField(_StringField, _FieldSchema[S.EmailFieldSchema]): + pass + + +@_field_api_docstring("Sync source") +class ExternalSyncSourceField( + _StringField, _FieldSchema[S.ExternalSyncSourceFieldSchema] +): + + readonly = True + + +class ManualSortField(_StringField, _FieldSchema[S.ManualSortFieldSchema]): """ + Read-only. Returns ``""`` instead of ``None`` if the field is empty. + The ``manualSort`` field type is used to define a manual sort order for a list view. + Its use or behavior via the API is not documented. + """ + readonly = True + + +@_field_api_docstring("Long text", "multilinetext") class MultilineTextField(_StringField, _FieldSchema[S.MultilineTextFieldSchema]): + pass + + +@_field_api_docstring("Phone") +class PhoneNumberField(_StringField, _FieldSchema[S.PhoneNumberFieldSchema]): + pass + + +@_field_api_docstring("Rich text") +class RichTextField(_StringField, _FieldSchema[S.RichTextFieldSchema]): + pass + + +@_field_api_docstring("Single select", "select") +class SelectField(Field[str, str, None], _FieldSchema[S.SingleSelectFieldSchema]): """ - Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. + Represents a single select dropdown field. Accepts ``str`` or ``None``. - See `Long text `__. + This will return ``None`` if no value is set, and will only return ``""`` + if an empty dropdown option is available and selected. """ + valid_types = str + + +@_field_api_docstring("Single line text", "simpletext") +class SingleLineTextField(_StringField, _FieldSchema[S.SingleLineTextFieldSchema]): + pass + + +@_field_api_docstring("Single line text", "simpletext", "Long text", "multilinetext") +class TextField( + _StringField, + _FieldSchema[Union[S.SingleLineTextFieldSchema, S.MultilineTextFieldSchema]], +): + pass + + +@_field_api_docstring("Url", "urltext") +class UrlField(_StringField, _FieldSchema[S.UrlFieldSchema]): + pass + + +# ====================================================== +# Field types that contain numeric values +# ====================================================== -class _NumericField(_BasicField[T]): + +class _NumericFieldBase(_BasicField[T]): """ Base class for Number, Float, and Integer. Shares a common validation rule. """ @@ -388,51 +509,79 @@ def valid_or_raise(self, value: Any) -> None: super().valid_or_raise(value) -class _NumberField(_NumericField[Union[int, float]]): +class _NumberField(_NumericFieldBase[Union[int, float]]): + """ + Number field with unspecified precision. Accepts either ``int`` or ``float``. + """ + valid_types = (int, float) -class _IntegerField(_NumericField[int]): +class _IntegerField(_NumericFieldBase[int]): + """ + Number field with integer precision. Accepts only ``int`` values. + """ + valid_types = int -class _FloatField(_NumericField[float]): +class _FloatField(_NumericFieldBase[float]): + """ + Number field with decimal precision. Accepts only ``float`` values. + """ + valid_types = float +@_field_api_docstring("Number", "decimalorintegernumber") class NumberField(_NumberField, _FieldSchema[S.NumberFieldSchema]): - """ - Number field with unspecified precision. Accepts either ``int`` or ``float``. - - See `Number `__. - """ + pass # This cannot inherit from NumberField because valid_types would be more restrictive # in the subclass than what is defined in the parent class. +@_field_api_docstring("Number", "decimalorintegernumber") class IntegerField(_IntegerField, _FieldSchema[S.NumberFieldSchema]): - """ - Number field with integer precision. Accepts only ``int`` values. - - See `Number `__. - """ + pass # This cannot inherit from NumberField because valid_types would be more restrictive # in the subclass than what is defined in the parent class. +@_field_api_docstring("Number", "decimalorintegernumber") class FloatField(_FloatField, _FieldSchema[S.NumberFieldSchema]): - """ - Number field with decimal precision. Accepts only ``float`` values. + pass - See `Number `__. + +@_field_api_docstring("Checkbox") +class CheckboxField(Field[bool, bool, bool], _FieldSchema[S.CheckboxFieldSchema]): + """ + Accepts ``bool``. + Returns ``False`` instead of ``None`` if the field is empty. """ + missing_value = False + valid_types = bool + + +@_field_api_docstring("Count", "count") +class CountField(_IntegerField, _FieldSchema[S.CountFieldSchema]): + readonly = True + +@_field_api_docstring("Currency", "currencynumber") +class CurrencyField(_NumberField, _FieldSchema[S.CurrencyFieldSchema]): + pass + + +@_field_api_docstring("Percent", "percentnumber") +class PercentField(_NumberField, _FieldSchema[S.PercentFieldSchema]): + pass + + +@_field_api_docstring("Rating") class RatingField(_IntegerField, _FieldSchema[S.RatingFieldSchema]): """ Accepts ``int`` values that are greater than zero. - - See `Rating `__. """ def valid_or_raise(self, value: int) -> None: @@ -441,19 +590,16 @@ def valid_or_raise(self, value: int) -> None: raise ValueError("rating cannot be below 1") -class CheckboxField(Field[bool, bool, bool], _FieldSchema[S.CheckboxFieldSchema]): - """ - Accepts ``bool``. - Returns ``False`` instead of ``None`` if the field is empty on the Airtable base. - - See `Checkbox `__. - """ - - missing_value = False - valid_types = bool +# ====================================================== +# Field types that contain dates or datetimes +# ====================================================== class _DatetimeField(Field[str, datetime, None]): + """ + Accepts only `datetime `_ values. + """ + valid_types = datetime def to_record_value(self, value: datetime) -> str: @@ -469,19 +615,15 @@ def to_internal_value(self, value: str) -> datetime: return utils.datetime_from_iso_str(value) +@_field_api_docstring("Date and time") class DatetimeField(_DatetimeField, _FieldSchema[S.DateTimeFieldSchema]): - """ - DateTime field. Accepts only `datetime `_ values. - - See `Date and time `__. - """ + pass +@_field_api_docstring("Date", "dateonly") class DateField(Field[str, date, None], _FieldSchema[S.DateFieldSchema]): """ - Date field. Accepts only `date `_ values. - - See `Date `__. + Accepts only `date `_ values. """ valid_types = date @@ -499,12 +641,10 @@ def to_internal_value(self, value: str) -> date: return utils.date_from_iso_str(value) +@_field_api_docstring("Duration", "durationnumber") class DurationField(Field[int, timedelta, None], _FieldSchema[S.DurationFieldSchema]): """ Duration field. Accepts only `timedelta `_ values. - - See `Duration `__. - Airtable's API returns this as a number of seconds. """ valid_types = timedelta @@ -522,13 +662,9 @@ def to_internal_value(self, value: Union[int, float]) -> timedelta: return timedelta(seconds=value) -class _DictField(_BasicField[T]): - """ - Generic field type that stores a single dict. Not for use via API; - should be subclassed by concrete field types (below). - """ - - valid_types = dict +# ====================================================== +# Field types that contain complex values (dicts, lists) +# ====================================================== class _ListFieldBase( @@ -630,6 +766,7 @@ class _LinkFieldOptions(Enum): LinkSelf = _LinkFieldOptions.LinkSelf +@_field_api_docstring("Link to another record", "foreignkey") class LinkField( Generic[T_Linked], _ListFieldBase[ @@ -644,8 +781,6 @@ class LinkField( Can also be used with a lookup field that pulls from a MultipleRecordLinks field, provided the field is created with ``readonly=True``. - - See `Link to another record `__. """ _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]] @@ -888,11 +1023,11 @@ class Meta: ... @utils.docstring_from( LinkField.__init__, - append=""" - raise_if_many: If ``True``, this field will raise a - :class:`~pyairtable.orm.fields.MultipleValues` exception upon - being accessed if the underlying field contains multiple values. - """, + append=( + " raise_if_many: If ``True``, this field will raise a" + " :class:`~pyairtable.orm.fields.MultipleValues` exception upon" + " being accessed if the underlying field contains multiple values." + ), ) def __init__( self, @@ -972,24 +1107,25 @@ def linked_model(self) -> Type[T_Linked]: return self._link_field.linked_model -# Many of these are "passthrough" subclasses for now. E.g. there is no real -# difference between `field = TextField()` and `field = PhoneNumberField()`. -# -# But we might choose to add more type-specific functionality later, so -# we'll allow implementers to get as specific as they care to and they might -# get some extra functionality for free in the future. +class _DictField(_BasicField[T]): + """ + Generic field type that stores a single dict. Not for use via API; + should be subclassed by concrete field types (below). + """ + + valid_types = dict +@_field_api_docstring("AI Text") class AITextField(_DictField[AITextDict], _FieldSchema[S.AITextFieldSchema]): """ - Read-only field that returns a `dict`. For more information, read the - `AI Text `_ - documentation. + Read-only field that returns a ``dict``. """ readonly = True +@_field_api_docstring("Attachments", "multipleattachment") class AttachmentsField( _ListFieldBase[ AttachmentDict, Union[AttachmentDict, CreateAttachmentDict], AttachmentsList @@ -999,91 +1135,89 @@ class AttachmentsField( contains_type=dict, ): """ - Accepts a list of dicts in the format detailed in - `Attachments `_. + Accepts a list of :class:`~pyairtable.api.types.AttachmentDict` or + :class:`~pyairtable.api.types.CreateAttachmentDict`. """ +@_field_api_docstring("Barcode") class BarcodeField(_DictField[BarcodeDict], _FieldSchema[S.BarcodeFieldSchema]): """ - Accepts a `dict` that should conform to the format detailed in the - `Barcode `_ - documentation. + Accepts a list of :class:`~pyairtable.api.types.BarcodeDict`. """ -class CollaboratorField( - _DictField[Union[CollaboratorDict, CollaboratorEmailDict]], - _FieldSchema[S.SingleCollaboratorFieldSchema], +@_field_api_docstring("Button") +@_required_value_docstring +class ButtonField( + _DictField[ButtonDict], + _Requires[ButtonDict], + _FieldSchema[S.ButtonFieldSchema], ): """ - Accepts a `dict` that should conform to the format detailed in the - `Collaborator `_ - documentation. + Read-only field that returns a :class:`~pyairtable.api.types.ButtonDict`. """ - -class CountField(_IntegerField, _FieldSchema[S.CountFieldSchema]): - """ - Equivalent to :class:`IntegerField(readonly=True) `. - - See `Count `__. - """ - - valid_types = int readonly = True -class CurrencyField(_NumberField, _FieldSchema[S.CurrencyFieldSchema]): +@_field_api_docstring("Collaborator") +class CollaboratorField( + _DictField[Union[CollaboratorDict, CollaboratorEmailDict]], + _FieldSchema[S.SingleCollaboratorFieldSchema], +): """ - Accepts either ``int`` or ``float``. - - See `Currency `__. + Accepts a :class:`~pyairtable.api.types.CollaboratorDict` or + :class:`~pyairtable.api.types.CollaboratorEmailDict`. """ -class EmailField(_StringField, _FieldSchema[S.EmailFieldSchema]): +@_field_api_docstring("Created by") +@_required_value_docstring +class CreatedByField( + _DictField[CollaboratorDict], + _Requires[CollaboratorDict], + _FieldSchema[S.CreatedByFieldSchema], +): """ - Equivalent to :class:`~TextField`. - - See `Email `__. + Returns a :class:`~pyairtable.api.types.CollaboratorDict`. """ + readonly = True -class ExternalSyncSourceField( - _StringField, _FieldSchema[S.ExternalSyncSourceFieldSchema] -): - """ - Equivalent to :class:`TextField(readonly=True) `. - See `Sync source `__. - """ +@_field_api_docstring("Created time") +@_required_value_docstring +class CreatedTimeField( + _DatetimeField, + _Requires_API_ORM[str, datetime], + _FieldSchema[S.CreatedTimeFieldSchema], +): readonly = True +@_field_api_docstring("Last modified by") +@_required_value_docstring class LastModifiedByField( _DictField[CollaboratorDict], _FieldSchema[S.LastModifiedByFieldSchema] ): """ - See `Last modified by `__. + Read-only. Returns a :class:`~pyairtable.api.types.CollaboratorDict`. """ readonly = True +@_field_api_docstring("Last modified time") class LastModifiedTimeField( _DatetimeField, _FieldSchema[S.LastModifiedTimeFieldSchema] ): - """ - Equivalent to :class:`DatetimeField(readonly=True) `. - - See `Last modified time `__. - """ readonly = True +@_field_api_docstring("Lookup") class LookupField(_ListField[T], _FieldSchema[S.MultipleLookupValuesFieldSchema]): """ Generic field class for a lookup, which returns a list of values. @@ -1099,385 +1233,153 @@ class LookupField(_ListField[T], _FieldSchema[S.MultipleLookupValuesFieldSchema] >>> rec = MyTable.first() >>> rec.lookup ["First value", "Second value", ...] - - See `Lookup `__. - """ - - readonly = True - - -class ManualSortField( - _BasicFieldWithMissingValue[str], _FieldSchema[S.ManualSortFieldSchema] -): - """ - Field configuration for ``manualSort`` field type (not documented). """ readonly = True +@_field_api_docstring("Multiple collaborators", "multicollaborator") class MultipleCollaboratorsField( _ListField[Union[CollaboratorDict, CollaboratorEmailDict]], _FieldSchema[S.MultipleCollaboratorsFieldSchema], contains_type=dict, ): """ - Accepts a list of dicts in the format detailed in - `Multiple Collaborators `_. + Accepts a list of :class:`~pyairtable.api.types.CollaboratorDict` or + :class:`~pyairtable.api.types.CollaboratorEmailDict`. """ +@_field_api_docstring("Multiple select", "multiselect") class MultipleSelectField( _ListField[str], _FieldSchema[S.MultipleSelectsFieldSchema], contains_type=str ): """ Accepts a list of ``str``. - - See `Multiple select `__. - """ - - -class PercentField(_NumberField, _FieldSchema[S.PercentFieldSchema]): - """ - Equivalent to :class:`~NumberField`. - - See `Percent `__. - """ - - -class PhoneNumberField(_StringField, _FieldSchema[S.PhoneNumberFieldSchema]): - """ - Equivalent to :class:`~TextField`. - - See `Phone `__. - """ - - -class RichTextField(_StringField, _FieldSchema[S.RichTextFieldSchema]): - """ - Equivalent to :class:`~TextField`. - - See `Rich text `__. - """ - - -class SelectField(Field[str, str, None], _FieldSchema[S.SingleSelectFieldSchema]): """ - Represents a single select dropdown field. This will return ``None`` if no value is set, - and will only return ``""`` if an empty dropdown option is available and selected. - See `Single select `__. - """ - valid_types = str +# ====================================================== +# Derived field types that disallow None or empty string +# ====================================================== -class UrlField(_StringField, _FieldSchema[S.UrlFieldSchema]): - """ - Equivalent to :class:`~TextField`. - - See `Url `__. - """ +@_field_api_docstring("Auto number", "autonumber") +@_required_value_docstring +class AutoNumberField( + _IntegerField, + _Requires[int], + _FieldSchema[S.AutoNumberFieldSchema], +): + readonly = True +@_required_value_docstring class RequiredAITextField(AITextField, _Requires[AITextDict]): - """ - Read-only field that returns a `dict`. For more information, read the - `AI Text `_ - documentation. - - If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredBarcodeField(BarcodeField, _Requires[BarcodeDict]): - """ - Accepts a `dict` that should conform to the format detailed in the - `Barcode `_ - documentation. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredCollaboratorField( CollaboratorField, _Requires[Union[CollaboratorDict, CollaboratorEmailDict]], ): - """ - Accepts a `dict` that should conform to the format detailed in the - `Collaborator `_ - documentation. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredCountField(CountField, _Requires[int]): - """ - Equivalent to :class:`IntegerField(readonly=True) `. - - See `Count `__. - - If the Airtable API returns ``null``, this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredCurrencyField(CurrencyField, _Requires[Union[int, float]]): - """ - Equivalent to :class:`~NumberField`. - - See `Currency `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredDateField(DateField, _Requires_API_ORM[str, date]): - """ - Date field. Accepts only `date `_ values. - - See `Date `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredDatetimeField(DatetimeField, _Requires_API_ORM[str, datetime]): - """ - DateTime field. Accepts only `datetime `_ values. - - See `Date and time `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredDurationField(DurationField, _Requires_API_ORM[int, timedelta]): - """ - Duration field. Accepts only `timedelta `_ values. - - See `Duration `__. - Airtable's API returns this as a number of seconds. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredEmailField(EmailField, _Requires[str]): - """ - Equivalent to :class:`~TextField`. - - See `Email `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredFloatField(FloatField, _Requires[float]): - """ - Number field with decimal precision. Accepts only ``float`` values. - - See `Number `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredIntegerField(IntegerField, _Requires[int]): - """ - Number field with integer precision. Accepts only ``int`` values. - - See `Number `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredNumberField(NumberField, _Requires[Union[int, float]]): - """ - Number field with unspecified precision. Accepts either ``int`` or ``float``. - - See `Number `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredPercentField(PercentField, _Requires[Union[int, float]]): - """ - Equivalent to :class:`~NumberField`. - - See `Percent `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredPhoneNumberField(PhoneNumberField, _Requires[str]): - """ - Equivalent to :class:`~TextField`. - - See `Phone `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredRatingField(RatingField, _Requires[int]): - """ - Accepts ``int`` values that are greater than zero. - - See `Rating `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredRichTextField(RichTextField, _Requires[str]): - """ - Equivalent to :class:`~TextField`. - - See `Rich text `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredSelectField(SelectField, _Requires_API_ORM[str, str]): - """ - Represents a single select dropdown field. This will return ``None`` if no value is set, - and will only return ``""`` if an empty dropdown option is available and selected. - - See `Single select `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredTextField(TextField, _Requires[str]): - """ - Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. - - See `Single line text `__ - and `Long text `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredSingleLineTextField(SingleLineTextField, _Requires[str]): - """ - Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. - - See `Single line text `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredMultilineTextField(MultilineTextField, _Requires[str]): - """ - Accepts ``str``. - Returns ``""`` instead of ``None`` if the field is empty on the Airtable base. - - See `Long text `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ + pass +@_required_value_docstring class RequiredUrlField(UrlField, _Requires[str]): - """ - Equivalent to :class:`~TextField`. - - See `Url `__. - - If the Airtable API returns ``null``, or if a caller sets this field to ``None``, - this field raises :class:`~pyairtable.orm.fields.MissingValue`. - """ - - -class AutoNumberField( - _IntegerField, - _Requires[int], - _FieldSchema[S.AutoNumberFieldSchema], -): - """ - Equivalent to :class:`IntegerField(readonly=True) `. - - See `Auto number `__. - - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. - """ - - readonly = True - - -class ButtonField( - _DictField[ButtonDict], - _Requires[ButtonDict], - _FieldSchema[S.ButtonFieldSchema], -): - """ - Read-only field that returns a `dict`. For more information, read the - `Button `_ - documentation. - - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. - """ - - readonly = True - - -class CreatedByField( - _DictField[CollaboratorDict], - _Requires[CollaboratorDict], - _FieldSchema[S.CreatedByFieldSchema], -): - """ - See `Created by `__. - - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. - """ - - readonly = True - - -class CreatedTimeField( - _DatetimeField, - _Requires_API_ORM[str, datetime], - _FieldSchema[S.CreatedTimeFieldSchema], -): - """ - Equivalent to :class:`DatetimeField(readonly=True) `. - - See `Created time `__. - - If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`. - """ - - readonly = True + pass #: Set of all Field subclasses exposed by the library. diff --git a/pyairtable/utils.py b/pyairtable/utils.py index a08d0259..1973a32b 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -209,7 +209,9 @@ def _append_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> N def docstring_from(obj: Any, append: str = "") -> Callable[[F], F]: def _wrapper(func: F) -> F: - func.__doc__ = obj.__doc__ + append + func.__doc__ = obj.__doc__ + if append: + _append_docstring_text(func, append) return func return _wrapper From f5985131c8db72afe7e98d7c80a5bd00131b038e Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 16 Mar 2025 14:14:06 -0700 Subject: [PATCH 244/272] Add warning to UrlBuilder --- pyairtable/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 1973a32b..41702180 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -442,6 +442,12 @@ class _urls(UrlBuilder): ...which ensures the URLs are built only once and are accessible via ``.urls``, and have the ``SomeObject`` instance available as context, and build readable docstrings for the ``SomeObject`` class documentation. + + .. warning:: + + This class is intended for use within pyAirtable only, and is tailored + to the type of documentation this library produces. Its behavior may + change in the future in ways that are not suitable for other projects. """ context: Any From 3e91a5730ee6b8722e517a22c7fb0c9b7b8f98d2 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 17 Mar 2025 13:48:03 -0700 Subject: [PATCH 245/272] Document ORM metadata --- docs/source/orm.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index cb7ee72a..00494a9c 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -666,6 +666,30 @@ directly upload content to Airtable: .. automethod:: pyairtable.orm.lists.AttachmentsList.upload +ORM Metadata +------------------ + +Access to the configuration of a model and the schema of its underlying base/table +are available through the :attr:`~pyairtable.orm.Model.meta` attribute: + +.. code-block:: python + + >>> model = YourModel() + >>> model.meta.base_id + 'appaPqizdsNHDvlEm' + >>> model.meta.table_name + 'YourModel' + >>> model.meta.table.schema() + TableSchema(id='appaPqizdsNHDvlEm', name='YourModel', ...) + +For convenience, the schema of ORM-defined fields can be accessed via those field definitions: + +.. code-block:: python + + >>> YourModel.name.field_schema() + FieldSchema(id='fldMNxslc6jG0XedV', name='Name', type='singleLineText', ...) + + ORM Limitations ------------------ From 8daf01896ac049df9c6b2d341a70a315e7e45233 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 27 Mar 2025 23:11:47 -0700 Subject: [PATCH 246/272] Fix flaky integration test --- tests/integration/test_integration_api.py | 38 +++++------------------ 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index b5ee0e62..72291ff5 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -1,5 +1,4 @@ from datetime import datetime, timezone -from unittest.mock import ANY from uuid import uuid4 import pytest @@ -309,36 +308,13 @@ 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, - }, - ] - }, - } + attached = response["fields"][cols.ATTACHMENT_ID] + assert attached[0]["filename"] == "a.png" + assert attached[0]["type"] == "image/png" + assert attached[0]["size"] == 7297 + assert attached[1]["filename"] == "b.png" + assert attached[1]["type"] == "image/png" + assert attached[1]["size"] == 7297 def test_integration_comments(api, table: Table, cols): From 16efaa7952fb4047b599e0b5b22f5e09f46db9ed Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 27 Mar 2025 23:12:19 -0700 Subject: [PATCH 247/272] Release 3.1.0 --- docs/source/changelog.rst | 15 +++++++-------- pyairtable/__init__.py | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f5a1308c..02ad3e3b 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,10 +2,11 @@ Changelog ========= -3.1.0 (TBD) +3.1.0 (2025-03-27) ------------------------ * Added ``Field.field_schema()`` to type-annotated ORM fields. + - `PR #426 `_ 3.0.2 (2025-02-25) ------------------------ @@ -24,6 +25,11 @@ Changelog which caused an endless loop when making a request via `POST /listRecords`. - `PR #416 `_, `PR #417 `_ +2.3.7 (2024-12-06) +------------------------ + +* Fix for `#415 `_ (see above). + 3.0 (2024-11-15) ------------------------ @@ -80,13 +86,6 @@ Changelog * Rewrite of :mod:`pyairtable.formulas` module. See :ref:`Building Formulas`. - `PR #329 `_ -2.3.7 (2024-12-06) ------------------------- - -* Fix for `#415 `_ - which caused an endless loop when making a request via `POST /listRecords`. - - `PR #416 `_, `PR #417 `_ - 2.3.6 (2024-11-11) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index c3f748dd..d090541e 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.0.2" +__version__ = "3.1.0" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From 44ec5aa59023845151c21540e2fff5e7f4e579e6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Fri, 4 Apr 2025 12:13:26 -0700 Subject: [PATCH 248/272] Clarify webhooks documentation, fixes #425 --- docs/source/webhooks.rst | 51 ++++++++++++++++++++++++++++++++++-- pyairtable/models/webhook.py | 34 +++++------------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/source/webhooks.rst b/docs/source/webhooks.rst index a3ce77eb..5157a215 100644 --- a/docs/source/webhooks.rst +++ b/docs/source/webhooks.rst @@ -8,8 +8,52 @@ Airtable's `Webhooks API ` to create a webhook. +2. Airtable will ``POST`` notifications to the webhook URL you provided. +3. Use :meth:`WebhookNotification.from_request ` to validate each notification. +4. Use :meth:`Webhook.payloads ` to retrieve new payloads after the notification. + +This means it is technically possible to ignore webhook notifications altogether and to simply +poll a webhook periodically for new payloads. However, this increases the likelihood of running into +`Airtable's API rate limits `__. + +When using webhooks, you need some way to persist the ``cursor`` of the webhook +payload, so that you do not retrieve the same payloads again on subsequent calls, +even if your job is interrupted in the middle of processing a list of payloads. + +For example: + + .. code-block:: python + + from flask import Flask, request + from pyairtable import Api + from pyairtable.models import WebhookNotification + + app = Flask(__name__) + + @app.route("/airtable-webhook", methods=["POST"]) + def airtable_webhook(): + body = request.data + header = request.headers["X-Airtable-Content-MAC"] + secret = app.config["AIRTABLE_WEBHOOK_SECRET"] + event = WebhookNotification.from_request(body, header, secret) + airtable = Api(app.config["AIRTABLE_API_KEY"]) + webhook = airtable.base(event.base.id).webhook(event.webhook.id) + cursor = int(your_database.get(event.webhook, 0)) + 1 + + for payload in webhook.payloads(cursor=cursor): + process_payload(payload) # probably enqueue a background job + your_database.set(event.webhook, payload.cursor + 1) + + return ("", 204) # intentionally empty response + +Methods +------- + +The following methods will be most commonly used for working with payloads. +You can read the full documentation at :mod:`pyairtable.models.webhook`. .. automethod:: pyairtable.Base.add_webhook :noindex: @@ -20,5 +64,8 @@ using a straightforward API within the :class:`~pyairtable.Base` class. .. automethod:: pyairtable.Base.webhook :noindex: +.. automethod:: pyairtable.models.WebhookNotification.from_request + :noindex: + .. automethod:: pyairtable.models.Webhook.payloads :noindex: diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py index 833ad3c6..b1e0734c 100644 --- a/pyairtable/models/webhook.py +++ b/pyairtable/models/webhook.py @@ -172,32 +172,6 @@ class WebhookNotification(AirtableModel): use :meth:`Webhook.payloads ` to retrieve the actual payloads describing the change(s) which triggered the webhook. - You will also need some way to persist the ``cursor`` of the webhook payload, - so that on subsequent calls you do not retrieve the same payloads again. - - Usage: - .. code-block:: python - - from flask import Flask, request - from pyairtable import Api - from pyairtable.models import WebhookNotification - - app = Flask(__name__) - - @app.route("/airtable-webhook", methods=["POST"]) - def airtable_webhook(): - body = request.data - header = request.headers["X-Airtable-Content-MAC"] - secret = app.config["AIRTABLE_WEBHOOK_SECRET"] - event = WebhookNotification.from_request(body, header, secret) - airtable = Api(app.config["AIRTABLE_API_KEY"]) - webhook = airtable.base(event.base.id).webhook(event.webhook.id) - cursor = int(your_db.get(f"cursor_{event.webhook}", 0)) + 1 - for payload in webhook.payloads(cursor=cursor): - # ...do stuff... - your_db.set(f"cursor_{event.webhook}", payload.cursor) - return ("", 204) # intentionally empty response - See `Webhook notification delivery `_ for more information on how these payloads are structured. """ @@ -323,8 +297,12 @@ class WebhookPayload(AirtableModel): error: Optional[bool] = None error_code: Optional[str] = pydantic.Field(alias="code", default=None) - #: This is not a part of Airtable's webhook payload specification. - #: This indicates the cursor field in the response which provided this payload. + #: The payload transaction number, as described in + #: `List webhook payloads - Response format `__. + #: If passed to :meth:`Webhook.payloads` it will return the same payload again, + #: along with any more payloads recorded after it. + #: + #: This field is specific to pyAirtable, and is not part of Airtable's webhook payload specification. cursor: Optional[int] = None class ActionMetadata(AirtableModel): From 9b7fb44cc12658e372cb147984c85fa4675e7ef3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 7 Apr 2025 08:59:11 -0700 Subject: [PATCH 249/272] Fix typing bug for formulas.FunctionCall; resolves #429 --- docs/source/changelog.rst | 2 + pyairtable/formulas.py | 160 +++++++++++++++++++++----------------- tests/test_typing.py | 17 ++++ 3 files changed, 106 insertions(+), 73 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 02ad3e3b..257f1c1d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -7,6 +7,8 @@ Changelog * Added ``Field.field_schema()`` to type-annotated ORM fields. - `PR #426 `_ +* Fix for incorrect type annotations on :class:`~pyairtable.formulas.FunctionCall`. + - `PR #429 `_ 3.0.2 (2025-02-25) ------------------------ diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 5a7865d9..5e69fc76 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -13,6 +13,7 @@ from typing import Any, ClassVar, Iterable, List, Optional, Set, Union from typing_extensions import Self as SelfType +from typing_extensions import TypeAlias from pyairtable.api.types import Fields from pyairtable.exceptions import CircularFormulaError @@ -510,6 +511,19 @@ def field_name(name: str) -> str: return "{%s}" % name.replace("}", r"\}") +FunctionArg: TypeAlias = Union[ + str, + int, + float, + bool, + Decimal, + Fraction, + Formula, + datetime.date, + datetime.datetime, +] + + class FunctionCall(Formula): """ Represents a function call in an Airtable formula, and converts @@ -524,7 +538,7 @@ class FunctionCall(Formula): for all formula functions known at time of publishing. """ - def __init__(self, name: str, *args: List[Any]): + def __init__(self, name: str, *args: FunctionArg): self.name = name self.args = args @@ -577,19 +591,19 @@ def __repr__(self) -> str: required = [arg for arg in args if arg and not arg.startswith("[")] optional = [arg.strip("[]") for arg in args if arg.startswith("[") and arg.endswith("]")] - signature = [f"{arg}: Any" for arg in required] + signature = [f"{arg}: FunctionArg" for arg in required] params = [*required] splat = optional.pop().rstrip(".") if optional and optional[-1].endswith("...") else None if optional: - signature += [f"{arg}: Optional[Any] = None" for arg in optional] + signature += [f"{arg}: Optional[FunctionArg] = None" for arg in optional] params += ["*(v for v in [" + ", ".join(optional) + "] if v is not None)"] if required or optional: signature += ["/"] if splat: - signature += [f"*{splat}: Any"] + signature += [f"*{splat}: FunctionArg"] params += [f"*{splat}"] joined_signature = ", ".join(signature) @@ -608,14 +622,14 @@ def __repr__(self) -> str: [[[out]]]""" -def ABS(value: Any, /) -> FunctionCall: +def ABS(value: FunctionArg, /) -> FunctionCall: """ Returns the absolute value. """ return FunctionCall('ABS', value) -def AVERAGE(number: Any, /, *numbers: Any) -> FunctionCall: +def AVERAGE(number: FunctionArg, /, *numbers: FunctionArg) -> FunctionCall: """ Returns the average of the numbers. """ @@ -629,35 +643,35 @@ def BLANK() -> FunctionCall: return FunctionCall('BLANK') -def CEILING(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: +def CEILING(value: FunctionArg, significance: Optional[FunctionArg] = None, /) -> FunctionCall: """ Returns the nearest integer multiple of significance that is greater than or equal to the value. If no significance is provided, a significance of 1 is assumed. """ return FunctionCall('CEILING', value, *(v for v in [significance] if v is not None)) -def CONCATENATE(text: Any, /, *texts: Any) -> FunctionCall: +def CONCATENATE(text: FunctionArg, /, *texts: FunctionArg) -> FunctionCall: """ Joins together the text arguments into a single text value. """ return FunctionCall('CONCATENATE', text, *texts) -def COUNT(number: Any, /, *numbers: Any) -> FunctionCall: +def COUNT(number: FunctionArg, /, *numbers: FunctionArg) -> FunctionCall: """ Count the number of numeric items. """ return FunctionCall('COUNT', number, *numbers) -def COUNTA(value: Any, /, *values: Any) -> FunctionCall: +def COUNTA(value: FunctionArg, /, *values: FunctionArg) -> FunctionCall: """ Count the number of non-empty values. This function counts both numeric and text values. """ return FunctionCall('COUNTA', value, *values) -def COUNTALL(value: Any, /, *values: Any) -> FunctionCall: +def COUNTALL(value: FunctionArg, /, *values: FunctionArg) -> FunctionCall: """ Count the number of all elements including text and blanks. """ @@ -671,49 +685,49 @@ def CREATED_TIME() -> FunctionCall: return FunctionCall('CREATED_TIME') -def DATEADD(date: Any, number: Any, units: Any, /) -> FunctionCall: +def DATEADD(date: FunctionArg, number: FunctionArg, units: FunctionArg, /) -> FunctionCall: """ Adds specified "count" units to a datetime. (See `list of shared unit specifiers `__. For this function we recommend using the full unit specifier for your desired unit.) """ return FunctionCall('DATEADD', date, number, units) -def DATESTR(date: Any, /) -> FunctionCall: +def DATESTR(date: FunctionArg, /) -> FunctionCall: """ Formats a datetime into a string (YYYY-MM-DD). """ return FunctionCall('DATESTR', date) -def DATETIME_DIFF(date1: Any, date2: Any, units: Any, /) -> FunctionCall: +def DATETIME_DIFF(date1: FunctionArg, date2: FunctionArg, units: FunctionArg, /) -> FunctionCall: """ Returns the difference between datetimes in specified units. The difference between datetimes is determined by subtracting [date2] from [date1]. This means that if [date2] is later than [date1], the resulting value will be negative. """ return FunctionCall('DATETIME_DIFF', date1, date2, units) -def DATETIME_FORMAT(date: Any, output_format: Optional[Any] = None, /) -> FunctionCall: +def DATETIME_FORMAT(date: FunctionArg, output_format: Optional[FunctionArg] = None, /) -> FunctionCall: """ Formats a datetime into a specified string. See an `explanation of how to use this function with date fields `__ or a list of `supported format specifiers `__. """ return FunctionCall('DATETIME_FORMAT', date, *(v for v in [output_format] if v is not None)) -def DATETIME_PARSE(date: Any, input_format: Optional[Any] = None, locale: Optional[Any] = None, /) -> FunctionCall: +def DATETIME_PARSE(date: FunctionArg, input_format: Optional[FunctionArg] = None, locale: Optional[FunctionArg] = None, /) -> FunctionCall: """ Interprets a text string as a structured date, with optional input format and locale parameters. The output format will always be formatted 'M/D/YYYY h:mm a'. """ return FunctionCall('DATETIME_PARSE', date, *(v for v in [input_format, locale] if v is not None)) -def DAY(date: Any, /) -> FunctionCall: +def DAY(date: FunctionArg, /) -> FunctionCall: """ Returns the day of the month of a datetime in the form of a number between 1-31. """ return FunctionCall('DAY', date) -def ENCODE_URL_COMPONENT(component_string: Any, /) -> FunctionCall: +def ENCODE_URL_COMPONENT(component_string: FunctionArg, /) -> FunctionCall: """ Replaces certain characters with encoded equivalents for use in constructing URLs or URIs. Does not encode the following characters: ``-_.~`` """ @@ -727,14 +741,14 @@ def ERROR() -> FunctionCall: return FunctionCall('ERROR') -def EVEN(value: Any, /) -> FunctionCall: +def EVEN(value: FunctionArg, /) -> FunctionCall: """ Returns the smallest even integer that is greater than or equal to the specified value. """ return FunctionCall('EVEN', value) -def EXP(power: Any, /) -> FunctionCall: +def EXP(power: FunctionArg, /) -> FunctionCall: """ Computes **Euler's number** (e) to the specified power. """ @@ -748,147 +762,147 @@ def FALSE() -> FunctionCall: return FunctionCall('FALSE') -def FIND(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: +def FIND(string_to_find: FunctionArg, where_to_search: FunctionArg, start_from_position: Optional[FunctionArg] = None, /) -> FunctionCall: """ Finds an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition.(startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be 0. """ return FunctionCall('FIND', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) -def FLOOR(value: Any, significance: Optional[Any] = None, /) -> FunctionCall: +def FLOOR(value: FunctionArg, significance: Optional[FunctionArg] = None, /) -> FunctionCall: """ Returns the nearest integer multiple of significance that is less than or equal to the value. If no significance is provided, a significance of 1 is assumed. """ return FunctionCall('FLOOR', value, *(v for v in [significance] if v is not None)) -def FROMNOW(date: Any, /) -> FunctionCall: +def FROMNOW(date: FunctionArg, /) -> FunctionCall: """ Calculates the number of days between the current date and another date. """ return FunctionCall('FROMNOW', date) -def HOUR(datetime: Any, /) -> FunctionCall: +def HOUR(datetime: FunctionArg, /) -> FunctionCall: """ Returns the hour of a datetime as a number between 0 (12:00am) and 23 (11:00pm). """ return FunctionCall('HOUR', datetime) -def IF(expression: Any, if_true: Any, if_false: Any, /) -> FunctionCall: +def IF(expression: FunctionArg, if_true: FunctionArg, if_false: FunctionArg, /) -> FunctionCall: """ Returns value1 if the logical argument is true, otherwise it returns value2. Can also be used to make `nested IF statements `__. """ return FunctionCall('IF', expression, if_true, if_false) -def INT(value: Any, /) -> FunctionCall: +def INT(value: FunctionArg, /) -> FunctionCall: """ Returns the greatest integer that is less than or equal to the specified value. """ return FunctionCall('INT', value) -def ISERROR(expr: Any, /) -> FunctionCall: +def ISERROR(expr: FunctionArg, /) -> FunctionCall: """ Returns true if the expression causes an error. """ return FunctionCall('ISERROR', expr) -def IS_AFTER(date1: Any, date2: Any, /) -> FunctionCall: +def IS_AFTER(date1: FunctionArg, date2: FunctionArg, /) -> FunctionCall: """ Determines if [date1] is later than [date2]. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_AFTER', date1, date2) -def IS_BEFORE(date1: Any, date2: Any, /) -> FunctionCall: +def IS_BEFORE(date1: FunctionArg, date2: FunctionArg, /) -> FunctionCall: """ Determines if [date1] is earlier than [date2]. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_BEFORE', date1, date2) -def IS_SAME(date1: Any, date2: Any, unit: Any, /) -> FunctionCall: +def IS_SAME(date1: FunctionArg, date2: FunctionArg, unit: FunctionArg, /) -> FunctionCall: """ Compares two dates up to a unit and determines whether they are identical. Returns 1 if yes, 0 if no. """ return FunctionCall('IS_SAME', date1, date2, unit) -def LAST_MODIFIED_TIME(*fields: Any) -> FunctionCall: +def LAST_MODIFIED_TIME(*fields: FunctionArg) -> FunctionCall: """ Returns the date and time of the most recent modification made by a user in a non-computed field in the table. """ return FunctionCall('LAST_MODIFIED_TIME', *fields) -def LEFT(string: Any, how_many: Any, /) -> FunctionCall: +def LEFT(string: FunctionArg, how_many: FunctionArg, /) -> FunctionCall: """ Extract how many characters from the beginning of the string. """ return FunctionCall('LEFT', string, how_many) -def LEN(string: Any, /) -> FunctionCall: +def LEN(string: FunctionArg, /) -> FunctionCall: """ Returns the length of a string. """ return FunctionCall('LEN', string) -def LOG(number: Any, base: Optional[Any] = None, /) -> FunctionCall: +def LOG(number: FunctionArg, base: Optional[FunctionArg] = None, /) -> FunctionCall: """ Computes the logarithm of the value in provided base. The base defaults to 10 if not specified. """ return FunctionCall('LOG', number, *(v for v in [base] if v is not None)) -def LOWER(string: Any, /) -> FunctionCall: +def LOWER(string: FunctionArg, /) -> FunctionCall: """ Makes a string lowercase. """ return FunctionCall('LOWER', string) -def MAX(number: Any, /, *numbers: Any) -> FunctionCall: +def MAX(number: FunctionArg, /, *numbers: FunctionArg) -> FunctionCall: """ Returns the largest of the given numbers. """ return FunctionCall('MAX', number, *numbers) -def MID(string: Any, where_to_start: Any, count: Any, /) -> FunctionCall: +def MID(string: FunctionArg, where_to_start: FunctionArg, count: FunctionArg, /) -> FunctionCall: """ Extract a substring of count characters starting at whereToStart. """ return FunctionCall('MID', string, where_to_start, count) -def MIN(number: Any, /, *numbers: Any) -> FunctionCall: +def MIN(number: FunctionArg, /, *numbers: FunctionArg) -> FunctionCall: """ Returns the smallest of the given numbers. """ return FunctionCall('MIN', number, *numbers) -def MINUTE(datetime: Any, /) -> FunctionCall: +def MINUTE(datetime: FunctionArg, /) -> FunctionCall: """ Returns the minute of a datetime as an integer between 0 and 59. """ return FunctionCall('MINUTE', datetime) -def MOD(value: Any, divisor: Any, /) -> FunctionCall: +def MOD(value: FunctionArg, divisor: FunctionArg, /) -> FunctionCall: """ Returns the remainder after dividing the first argument by the second. """ return FunctionCall('MOD', value, divisor) -def MONTH(date: Any, /) -> FunctionCall: +def MONTH(date: FunctionArg, /) -> FunctionCall: """ Returns the month of a datetime as a number between 1 (January) and 12 (December). """ @@ -902,14 +916,14 @@ def NOW() -> FunctionCall: return FunctionCall('NOW') -def ODD(value: Any, /) -> FunctionCall: +def ODD(value: FunctionArg, /) -> FunctionCall: """ Rounds positive value up the the nearest odd number and negative value down to the nearest odd number. """ return FunctionCall('ODD', value) -def POWER(base: Any, power: Any, /) -> FunctionCall: +def POWER(base: FunctionArg, power: FunctionArg, /) -> FunctionCall: """ Computes the specified base to the specified power. """ @@ -923,133 +937,133 @@ def RECORD_ID() -> FunctionCall: return FunctionCall('RECORD_ID') -def REGEX_EXTRACT(string: Any, regex: Any, /) -> FunctionCall: +def REGEX_EXTRACT(string: FunctionArg, regex: FunctionArg, /) -> FunctionCall: """ Returns the first substring that matches a regular expression. """ return FunctionCall('REGEX_EXTRACT', string, regex) -def REGEX_MATCH(string: Any, regex: Any, /) -> FunctionCall: +def REGEX_MATCH(string: FunctionArg, regex: FunctionArg, /) -> FunctionCall: """ Returns whether the input text matches a regular expression. """ return FunctionCall('REGEX_MATCH', string, regex) -def REGEX_REPLACE(string: Any, regex: Any, replacement: Any, /) -> FunctionCall: +def REGEX_REPLACE(string: FunctionArg, regex: FunctionArg, replacement: FunctionArg, /) -> FunctionCall: """ Substitutes all matching substrings with a replacement string value. """ return FunctionCall('REGEX_REPLACE', string, regex, replacement) -def REPLACE(string: Any, start_character: Any, number_of_characters: Any, replacement: Any, /) -> FunctionCall: +def REPLACE(string: FunctionArg, start_character: FunctionArg, number_of_characters: FunctionArg, replacement: FunctionArg, /) -> FunctionCall: """ Replaces the number of characters beginning with the start character with the replacement text. """ return FunctionCall('REPLACE', string, start_character, number_of_characters, replacement) -def REPT(string: Any, number: Any, /) -> FunctionCall: +def REPT(string: FunctionArg, number: FunctionArg, /) -> FunctionCall: """ Repeats string by the specified number of times. """ return FunctionCall('REPT', string, number) -def RIGHT(string: Any, how_many: Any, /) -> FunctionCall: +def RIGHT(string: FunctionArg, how_many: FunctionArg, /) -> FunctionCall: """ Extract howMany characters from the end of the string. """ return FunctionCall('RIGHT', string, how_many) -def ROUND(value: Any, precision: Any, /) -> FunctionCall: +def ROUND(value: FunctionArg, precision: FunctionArg, /) -> FunctionCall: """ Rounds the value to the number of decimal places given by "precision." (Specifically, ROUND will round to the nearest integer at the specified precision, with ties broken by `rounding half up toward positive infinity `__.) """ return FunctionCall('ROUND', value, precision) -def ROUNDDOWN(value: Any, precision: Any, /) -> FunctionCall: +def ROUNDDOWN(value: FunctionArg, precision: FunctionArg, /) -> FunctionCall: """ Rounds the value to the number of decimal places given by "precision," always `rounding down `__. """ return FunctionCall('ROUNDDOWN', value, precision) -def ROUNDUP(value: Any, precision: Any, /) -> FunctionCall: +def ROUNDUP(value: FunctionArg, precision: FunctionArg, /) -> FunctionCall: """ Rounds the value to the number of decimal places given by "precision," always `rounding up `__. """ return FunctionCall('ROUNDUP', value, precision) -def SEARCH(string_to_find: Any, where_to_search: Any, start_from_position: Optional[Any] = None, /) -> FunctionCall: +def SEARCH(string_to_find: FunctionArg, where_to_search: FunctionArg, start_from_position: Optional[FunctionArg] = None, /) -> FunctionCall: """ Searches for an occurrence of stringToFind in whereToSearch string starting from an optional startFromPosition. (startFromPosition is 0 by default.) If no occurrence of stringToFind is found, the result will be empty. """ return FunctionCall('SEARCH', string_to_find, where_to_search, *(v for v in [start_from_position] if v is not None)) -def SECOND(datetime: Any, /) -> FunctionCall: +def SECOND(datetime: FunctionArg, /) -> FunctionCall: """ Returns the second of a datetime as an integer between 0 and 59. """ return FunctionCall('SECOND', datetime) -def SET_LOCALE(date: Any, locale_modifier: Any, /) -> FunctionCall: +def SET_LOCALE(date: FunctionArg, locale_modifier: FunctionArg, /) -> FunctionCall: """ Sets a specific locale for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported locale modifiers can be found `here `__. """ return FunctionCall('SET_LOCALE', date, locale_modifier) -def SET_TIMEZONE(date: Any, tz_identifier: Any, /) -> FunctionCall: +def SET_TIMEZONE(date: FunctionArg, tz_identifier: FunctionArg, /) -> FunctionCall: """ Sets a specific timezone for a datetime. **Must be used in conjunction with DATETIME_FORMAT.** A list of supported timezone identifiers can be found `here `__. """ return FunctionCall('SET_TIMEZONE', date, tz_identifier) -def SQRT(value: Any, /) -> FunctionCall: +def SQRT(value: FunctionArg, /) -> FunctionCall: """ Returns the square root of a nonnegative number. """ return FunctionCall('SQRT', value) -def SUBSTITUTE(string: Any, old_text: Any, new_text: Any, index: Optional[Any] = None, /) -> FunctionCall: +def SUBSTITUTE(string: FunctionArg, old_text: FunctionArg, new_text: FunctionArg, index: Optional[FunctionArg] = None, /) -> FunctionCall: """ Replaces occurrences of old_text in string with new_text. """ return FunctionCall('SUBSTITUTE', string, old_text, new_text, *(v for v in [index] if v is not None)) -def SUM(number: Any, /, *numbers: Any) -> FunctionCall: +def SUM(number: FunctionArg, /, *numbers: FunctionArg) -> FunctionCall: """ Sum together the numbers. Equivalent to number1 + number2 + ... """ return FunctionCall('SUM', number, *numbers) -def SWITCH(expression: Any, pattern: Any, result: Any, /, *pattern_results: Any) -> FunctionCall: +def SWITCH(expression: FunctionArg, pattern: FunctionArg, result: FunctionArg, /, *pattern_results: FunctionArg) -> FunctionCall: """ Takes an expression, a list of possible values for that expression, and for each one, a value that the expression should take in that case. It can also take a default value if the expression input doesn't match any of the defined patterns. In many cases, SWITCH() can be used instead `of a nested IF() formula `__. """ return FunctionCall('SWITCH', expression, pattern, result, *pattern_results) -def T(value: Any, /) -> FunctionCall: +def T(value: FunctionArg, /) -> FunctionCall: """ Returns the argument if it is text and blank otherwise. """ return FunctionCall('T', value) -def TIMESTR(timestamp: Any, /) -> FunctionCall: +def TIMESTR(timestamp: FunctionArg, /) -> FunctionCall: """ Formats a datetime into a time-only string (HH:mm:ss). """ @@ -1063,14 +1077,14 @@ def TODAY() -> FunctionCall: return FunctionCall('TODAY') -def TONOW(date: Any, /) -> FunctionCall: +def TONOW(date: FunctionArg, /) -> FunctionCall: """ Calculates the number of days between the current date and another date. """ return FunctionCall('TONOW', date) -def TRIM(string: Any, /) -> FunctionCall: +def TRIM(string: FunctionArg, /) -> FunctionCall: """ Removes whitespace at the beginning and end of string. """ @@ -1084,61 +1098,61 @@ def TRUE() -> FunctionCall: return FunctionCall('TRUE') -def UPPER(string: Any, /) -> FunctionCall: +def UPPER(string: FunctionArg, /) -> FunctionCall: """ Makes string uppercase. """ return FunctionCall('UPPER', string) -def VALUE(text: Any, /) -> FunctionCall: +def VALUE(text: FunctionArg, /) -> FunctionCall: """ Converts the text string to a number. Some exceptions applyโ€”if the string contains certain mathematical operators(-,%) the result may not return as expected. In these scenarios we recommend using a combination of VALUE and REGEX_REPLACE to remove non-digit values from the string: """ return FunctionCall('VALUE', text) -def WEEKDAY(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: +def WEEKDAY(date: FunctionArg, start_day_of_week: Optional[FunctionArg] = None, /) -> FunctionCall: """ Returns the day of the week as an integer between 0 (Sunday) and 6 (Saturday). You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. """ return FunctionCall('WEEKDAY', date, *(v for v in [start_day_of_week] if v is not None)) -def WEEKNUM(date: Any, start_day_of_week: Optional[Any] = None, /) -> FunctionCall: +def WEEKNUM(date: FunctionArg, start_day_of_week: Optional[FunctionArg] = None, /) -> FunctionCall: """ Returns the week number in a year. You may optionally provide a second argument (either ``"Sunday"`` or ``"Monday"``) to start weeks on that day. If omitted, weeks start on Sunday by default. """ return FunctionCall('WEEKNUM', date, *(v for v in [start_day_of_week] if v is not None)) -def WORKDAY(start_date: Any, num_days: Any, holidays: Optional[Any] = None, /) -> FunctionCall: +def WORKDAY(start_date: FunctionArg, num_days: FunctionArg, holidays: Optional[FunctionArg] = None, /) -> FunctionCall: """ Returns a date that is numDays working days after startDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. """ return FunctionCall('WORKDAY', start_date, num_days, *(v for v in [holidays] if v is not None)) -def WORKDAY_DIFF(start_date: Any, end_date: Any, holidays: Optional[Any] = None, /) -> FunctionCall: +def WORKDAY_DIFF(start_date: FunctionArg, end_date: FunctionArg, holidays: Optional[FunctionArg] = None, /) -> FunctionCall: """ Counts the number of working days between startDate and endDate. Working days exclude weekends and an optional list of holidays, formatted as a comma-separated string of ISO-formatted dates. """ return FunctionCall('WORKDAY_DIFF', start_date, end_date, *(v for v in [holidays] if v is not None)) -def XOR(expression: Any, /, *expressions: Any) -> FunctionCall: +def XOR(expression: FunctionArg, /, *expressions: FunctionArg) -> FunctionCall: """ Returns true if an **odd** number of arguments are true. """ return FunctionCall('XOR', expression, *expressions) -def YEAR(date: Any, /) -> FunctionCall: +def YEAR(date: FunctionArg, /) -> FunctionCall: """ Returns the four-digit year of a datetime. """ return FunctionCall('YEAR', date) -# [[[end]]] (checksum: 6d21fb2dafa8810cefa1caad266e1453) +# [[[end]]] (checksum: e89fb729872c20bdff0bf57c061dae96) # fmt: on diff --git a/tests/test_typing.py b/tests/test_typing.py index 77f020c1..0e2234d3 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.formulas as F import pyairtable.orm.lists as L import pyairtable.utils from pyairtable import orm @@ -269,3 +270,19 @@ class EveryField(orm.Model): record.required_collaborator = {"email": "alice@example.com"} record.multi_user.append({"id": "usr123"}) record.multi_user.append({"email": "alice@example.com"}) + + # Test type annotations for the formulas module + formula = F.Formula("{Name} = 'Bob'") + assert_type(formula & formula, F.Formula) + assert_type(formula | formula, F.Formula) + assert_type(~formula, F.Formula) + assert_type(formula ^ formula, F.Formula) + assert_type(formula & True, F.Formula) + assert_type(formula | False, F.Formula) + assert_type(formula ^ "literal", F.Formula) + assert_type(F.match({"Name": "Bob"}), F.Formula) + assert_type(F.to_formula(formula), F.Formula) + assert_type(F.to_formula(1), F.Formula) + assert_type(F.to_formula(True), F.Formula) + assert_type(F.to_formula("Bob"), F.Formula) + assert_type(F.CONCATENATE(1, 2, 3), F.FunctionCall) From ead9c9cf8a0d3cdec2e618b2c1135748337ff250 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 7 Apr 2025 09:15:59 -0700 Subject: [PATCH 250/272] Update release date of 3.1.0 --- docs/source/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 257f1c1d..bb4cd36d 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,7 +2,7 @@ Changelog ========= -3.1.0 (2025-03-27) +3.1.0 (2025-04-07) ------------------------ * Added ``Field.field_schema()`` to type-annotated ORM fields. From 27c8969ec48260127b98e5ebf5762f365769f01a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 7 Apr 2025 15:42:54 -0700 Subject: [PATCH 251/272] Pass returnFieldsByFieldId everywhere in the ORM; fixes #430 --- docs/source/changelog.rst | 6 ++ pyairtable/orm/model.py | 36 ++++++-- tests/integration/test_integration_api.py | 12 +++ tests/test_orm.py | 22 +++++ tests/test_orm_fields.py | 42 +++++++-- tests/test_orm_model.py | 100 +++++++++++++++++----- 6 files changed, 180 insertions(+), 38 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index bb4cd36d..f77b470f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,12 @@ Changelog ========= +3.1.1 (2025-04-07) +------------------------ + +* Fix a bug affecting :meth:`~pyairtable.orm.Model.from_id` when ``use_field_ids=True``. + - `PR #431 `_ + 3.1.0 (2025-04-07) ------------------------ diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index 188c9b60..c2651ada 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -245,7 +245,11 @@ def save(self, *, force: bool = False) -> "SaveResult": field_values = self.to_record(only_writable=True)["fields"] if not self.id: - record = self.meta.table.create(field_values, typecast=self.meta.typecast) + record = self.meta.table.create( + field_values, + typecast=self.meta.typecast, + use_field_ids=self.meta.use_field_ids, + ) self.id = record["id"] self.created_time = datetime_from_iso_str(record["createdTime"]) self._changed.clear() @@ -260,7 +264,12 @@ def save(self, *, force: bool = False) -> "SaveResult": if self._changed.get(field_name) } - self.meta.table.update(self.id, field_values, typecast=self.meta.typecast) + self.meta.table.update( + self.id, + field_values, + typecast=self.meta.typecast, + use_field_ids=self.meta.use_field_ids, + ) self._changed.clear() return SaveResult( self.id, forced=force, updated=True, field_names=set(field_values) @@ -407,7 +416,7 @@ def fetch(self) -> None: if not self.id: raise ValueError("cannot be fetched because instance does not have an id") - record = self.meta.table.get(self.id) + record = self.meta.table.get(self.id, **self.meta.request_kwargs) unused = self.from_record(record, memoize=False) self._fields = unused._fields self._changed.clear() @@ -478,12 +487,21 @@ def batch_save(cls, models: List[SelfType]) -> None: if (record := model.to_record(only_writable=True)) ] - table = cls.meta.table - table.batch_update(update_records, typecast=cls.meta.typecast) - created_records = table.batch_create(create_records, typecast=cls.meta.typecast) - for model, record in zip(create_models, created_records): - model.id = record["id"] - model.created_time = datetime_from_iso_str(record["createdTime"]) + if update_records: + cls.meta.table.batch_update( + update_records, + typecast=cls.meta.typecast, + use_field_ids=cls.meta.use_field_ids, + ) + if create_records: + created_records = cls.meta.table.batch_create( + create_records, + typecast=cls.meta.typecast, + use_field_ids=cls.meta.use_field_ids, + ) + for model, record in zip(create_models, created_records): + model.id = record["id"] + model.created_time = datetime_from_iso_str(record["createdTime"]) @classmethod def batch_delete(cls, models: List[SelfType]) -> None: diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py index 72291ff5..1f43ad64 100644 --- a/tests/integration/test_integration_api.py +++ b/tests/integration/test_integration_api.py @@ -56,11 +56,16 @@ def test_use_field_ids(table: Table, cols): Test that we can get, create, and update records by field ID vs. name. See https://github.com/gtalarico/pyairtable/issues/194 + and https://github.com/gtalarico/pyairtable/issues/430 """ # Create one record with use_field_ids=True record = table.create({cols.TEXT_ID: "Hello"}, use_field_ids=True) assert record["fields"][cols.TEXT_ID] == "Hello" + # Fetch one record with use_field_ids=True + fetched = table.get(record["id"], use_field_ids=True) + assert fetched["fields"][cols.TEXT_ID] == "Hello" + # Update one record with use_field_ids=True updated = table.update( record["id"], @@ -82,6 +87,13 @@ def test_use_field_ids(table: Table, cols): assert records[1]["fields"][cols.TEXT_ID] == "Bravo" assert records[2]["fields"][cols.TEXT_ID] == "Charlie" + # Fetch multiple records with use_field_ids=True + formula = OR(RECORD_ID().eq(record["id"]) for record in records) + fetched_many = {r["id"]: r for r in table.all(formula=formula, use_field_ids=True)} + assert fetched_many[records[0]["id"]]["fields"][cols.TEXT_ID] == "Alpha" + assert fetched_many[records[1]["id"]]["fields"][cols.TEXT_ID] == "Bravo" + assert fetched_many[records[2]["id"]]["fields"][cols.TEXT_ID] == "Charlie" + # Update multiple records with use_field_ids=True updates = [ dict( diff --git a/tests/test_orm.py b/tests/test_orm.py index 1ead27bb..1551201f 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -175,6 +175,7 @@ def test_unmodified_field_not_saved(contact_record): contact.id, {"Email": "john.doe@example.com"}, typecast=True, + use_field_ids=False, ) # Once saved, the field is no longer marked as changed @@ -195,6 +196,7 @@ def test_unmodified_field_not_saved(contact_record): "Birthday": "1970-01-01", }, typecast=True, + use_field_ids=False, ) @@ -339,6 +341,7 @@ def test_batch_save(mock_update, mock_create): {"Number": 456, "Street": "Fake St"}, ], typecast=True, + use_field_ids=False, ) mock_update.assert_called_once_with( [ @@ -348,9 +351,28 @@ def test_batch_save(mock_update, mock_create): }, ], typecast=True, + use_field_ids=False, ) +@mock.patch("pyairtable.Table.batch_create") +@mock.patch("pyairtable.Table.batch_update") +def test_batch_save__only_create(mock_update, mock_create): + Address.batch_save([Address(), Address()]) + assert mock_create.call_count == 1 + assert mock_update.call_count == 0 + + +@mock.patch("pyairtable.Table.batch_create") +@mock.patch("pyairtable.Table.batch_update") +def test_batch_save__only_update(mock_update, mock_create): + a1 = Address.from_record(fake_record()) + a2 = Address.from_record(fake_record()) + Address.batch_save([a1, a2]) + assert mock_create.call_count == 0 + assert mock_update.call_count == 1 + + @mock.patch("pyairtable.Table.batch_create") @mock.patch("pyairtable.Table.batch_update") def test_batch_save__invalid_class(mock_update, mock_create): diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py index 8702fbdc..4b0f1f1e 100644 --- a/tests/test_orm_fields.py +++ b/tests/test_orm_fields.py @@ -845,7 +845,7 @@ class Book(Model): with mock.patch("pyairtable.Table.get", return_value=alice.to_record()) as m: book.author.fetch() - m.assert_called_once_with(alice.id) + m.assert_called_once_with(alice.id, **Author.meta.request_kwargs) assert book.author.id == alice.id assert book.author.name == "Alice" @@ -856,16 +856,29 @@ class Book(Model): with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m: book.author.save() - m.assert_called_once_with({"Name": "Bob"}, typecast=True) + m.assert_called_once_with( + {"Name": "Bob"}, + typecast=True, + use_field_ids=False, + ) with mock.patch("pyairtable.Table.create", return_value=fake_record()) as m: book.save() - m.assert_called_once_with({"Author": [bob.id]}, typecast=True) + m.assert_called_once_with( + {"Author": [bob.id]}, + typecast=True, + use_field_ids=False, + ) with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: book.author = None book.save() - m.assert_called_once_with(book.id, {"Author": None}, typecast=True) + m.assert_called_once_with( + book.id, + {"Author": None}, + typecast=True, + use_field_ids=False, + ) def test_single_link_field__multiple_values(): @@ -901,13 +914,23 @@ class Book(Model): # if book.author.__set__ not called, the entire list will be sent back to the API with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: book.save(force=True) - m.assert_called_once_with(book.id, {"Author": [a1, a2, a3]}, typecast=True) + m.assert_called_once_with( + book.id, + {"Author": [a1, a2, a3]}, + typecast=True, + use_field_ids=False, + ) # if we modify the field value, it will drop items 2-N book.author = Author.from_record(fake_record()) with mock.patch("pyairtable.Table.update", return_value=book.to_record()) as m: book.save() - m.assert_called_once_with(book.id, {"Author": [book.author.id]}, typecast=True) + m.assert_called_once_with( + book.id, + {"Author": [book.author.id]}, + typecast=True, + use_field_ids=False, + ) def test_single_link_field__raise_if_many(): @@ -1114,7 +1137,12 @@ 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) + m.assert_called_once_with( + obj.id, + fields, + typecast=True, + use_field_ids=False, + ) @pytest.mark.parametrize( diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index a5d79770..6729d8fd 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -22,8 +22,8 @@ class FakeModel(Model): class FakeModelByIds(Model): Meta = fake_meta(use_field_ids=True, table_name="Apartments") - Name = f.TextField("fld1VnoyuotSTyxW1") - Age = f.NumberField("fld2VnoyuotSTy4g6") + name = f.TextField("fld1VnoyuotSTyxW1") + age = f.NumberField("fld2VnoyuotSTy4g6") @pytest.fixture(autouse=True) @@ -215,6 +215,17 @@ class Contact(Model): assert contact.name == "Alice" +@mock.patch("pyairtable.Table.get") +def test_from_id__use_field_ids(mock_get, fake_records_by_id): + # Use the FakeModelByIds class to test the use_field_ids option. + fake_contact = fake_records_by_id[0] + mock_get.return_value = fake_contact + model = FakeModelByIds.from_id(fake_contact["id"]) + assert model.name == "Alice" + assert mock_get.call_count == 1 + assert mock_get.mock_calls[-1].kwargs["use_field_ids"] is True + + @mock.patch("pyairtable.Api.iterate_requests") def test_from_ids(mock_api): fake_records = [fake_record() for _ in range(10)] @@ -257,10 +268,21 @@ def test_from_ids__no_fetch(mock_all): @mock.patch("pyairtable.Table.all") def test_from_ids__use_field_ids(mock_all): fake_ids = [fake_id() for _ in range(10)] - mock_all.return_value = [fake_record(id=id) for id in fake_ids] - FakeModelByIds.from_ids(fake_ids) + mock_all.return_value = [ + fake_record( + id=record_id, + fld1VnoyuotSTyxW1=f"Name {idx}", + fld2VnoyuotSTy4g6=(idx + 40), + ) + for idx, record_id in enumerate(fake_ids) + ] + models = FakeModelByIds.from_ids(fake_ids) assert mock_all.call_count == 1 assert mock_all.mock_calls[-1].kwargs["use_field_ids"] is True + assert models[0].name == "Name 0" + assert models[0].age == 40 + assert models[1].name == "Name 1" + assert models[1].age == 41 @pytest.mark.parametrize( @@ -292,12 +314,10 @@ def test_passthrough(methodname, returns): @pytest.fixture def fake_records_by_id(): - return { - "records": [ - fake_record(fld1VnoyuotSTyxW1="Alice", fld2VnoyuotSTy4g6=25), - fake_record(Name="Jack", Age=30), - ] - } + return [ + fake_record(fld1VnoyuotSTyxW1="Alice"), + fake_record(Name="Jack"), # values for negative test + ] def test_get_fields_by_id(fake_records_by_id): @@ -310,20 +330,14 @@ def test_get_fields_by_id(fake_records_by_id): returnFieldsByFieldId=1, cellFormat="json", ), - json=fake_records_by_id, + json={"records": fake_records_by_id}, complete_qs=True, status_code=200, ) fake_models = FakeModelByIds.all() - assert fake_models[0].Name == "Alice" - assert fake_models[0].Age == 25 - - assert fake_models[1].Name != "Jack" - assert fake_models[1].Age != 30 - - with pytest.raises(KeyError): - _ = getattr(fake_models[1], fake_records_by_id[0]["Age"]) + assert fake_models[0].name == "Alice" + assert fake_models[1].name == "" def test_meta_wrapper(): @@ -412,7 +426,11 @@ def test_save__create(mock_create): assert result.field_names == {"one", "two"} assert not result.updated assert not result.forced - mock_create.assert_called_once_with({"one": "ONE", "two": "TWO"}, typecast=True) + mock_create.assert_called_once_with( + {"one": "ONE", "two": "TWO"}, + typecast=True, + use_field_ids=False, + ) @mock.patch("pyairtable.Table.update") @@ -428,7 +446,12 @@ def test_save__update(mock_update): assert result.updated assert result.field_names == {"one"} assert not result.forced - mock_update.assert_called_once_with(obj.id, {"one": "new value"}, typecast=True) + mock_update.assert_called_once_with( + obj.id, + {"one": "new value"}, + typecast=True, + use_field_ids=False, + ) @mock.patch("pyairtable.Table.update") @@ -446,7 +469,7 @@ def test_save__update_force(mock_update): assert result.forced assert result.field_names == {"one", "two"} mock_update.assert_called_once_with( - obj.id, {"one": "new value", "two": "TWO"}, typecast=True + obj.id, {"one": "new value", "two": "TWO"}, typecast=True, use_field_ids=False ) @@ -465,6 +488,39 @@ def test_save__noop(mock_update): mock_update.assert_not_called() +@mock.patch("pyairtable.Table.create") +def test_save__use_field_ids__create(mock_create): + """ + Test that we can correctly save a model which uses field IDs. + """ + mock_create.return_value = fake_record(**{FakeModelByIds.name.field_name: "Alice"}) + obj = FakeModelByIds(name="Alice") + obj.save() + mock_create.assert_called_once_with( + {FakeModelByIds.name.field_name: "Alice"}, + typecast=True, + use_field_ids=True, + ) + + +@mock.patch("pyairtable.Table.update") +def test_save__use_field_ids__update(mock_update): + """ + Test that we can correctly save a model which uses field IDs. + """ + record = fake_record(**{FakeModelByIds.name.field_name: "Alice"}) + mock_update.return_value = record + obj = FakeModelByIds.from_record(record) + obj.name = "Bob" + obj.save() + mock_update.assert_called_once_with( + obj.id, + {FakeModelByIds.name.field_name: "Bob"}, + typecast=True, + use_field_ids=True, + ) + + def test_save_bool_deprecated(): """ Test that SaveResult instances can be used as booleans, but emit a deprecation warning. From 97d91d35268e19bff4e39f8012e39624e4893492 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 7 Apr 2025 15:46:50 -0700 Subject: [PATCH 252/272] Bump version to 3.1.1 --- pyairtable/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index d090541e..51c4acf3 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.1.0" +__version__ = "3.1.1" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From 0d83ffbd8c683bdebafe804570f5ba15c60cccd1 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 21 Jun 2025 00:01:24 -0700 Subject: [PATCH 253/272] Add restrictedToEnterpriseMembers, mappedUserLicenseType to schema --- pyairtable/models/schema.py | 2 ++ tests/sample_data/BaseShares.json | 3 +++ tests/sample_data/UserGroup.json | 1 + 3 files changed, 6 insertions(+) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 1cc8ca22..bdd19754 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -225,6 +225,7 @@ class Info( is_password_protected: bool block_installation_id: Optional[str] = None restricted_to_email_domains: List[str] = _FL() + restricted_to_enterprise_members: bool view_id: Optional[str] = None effective_email_domain_allow_list: List[str] = _FL() @@ -617,6 +618,7 @@ class UserGroup(AirtableModel): updated_time: datetime members: List["UserGroup.Member"] collaborations: "Collaborations" = _F("Collaborations") + mapped_user_license_type: Optional[str] = None class Member(AirtableModel): user_id: str diff --git a/tests/sample_data/BaseShares.json b/tests/sample_data/BaseShares.json index 20b67ebb..a52ee8af 100644 --- a/tests/sample_data/BaseShares.json +++ b/tests/sample_data/BaseShares.json @@ -7,6 +7,7 @@ "foobar.com" ], "isPasswordProtected": true, + "restrictedToEnterpriseMembers": false, "restrictedToEmailDomains": [ "foobar.com" ], @@ -19,6 +20,7 @@ "createdByUserId": "usrL2PNC5o3H4lBEi", "createdTime": "2019-01-01T00:00:00.000Z", "isPasswordProtected": false, + "restrictedToEnterpriseMembers": false, "restrictedToEmailDomains": [], "shareId": "shrMg5vs9SpczJvQp", "shareTokenPrefix": "shrMg5vs", @@ -31,6 +33,7 @@ "createdByUserId": "usrL2PNC5o3H4lBEi", "createdTime": "2019-01-01T00:00:00.000Z", "isPasswordProtected": false, + "restrictedToEnterpriseMembers": false, "restrictedToEmailDomains": [], "shareId": "shrjjKdhMg5vs9Spc", "shareTokenPrefix": "shrjjKdh", diff --git a/tests/sample_data/UserGroup.json b/tests/sample_data/UserGroup.json index 53518009..1559fd3a 100644 --- a/tests/sample_data/UserGroup.json +++ b/tests/sample_data/UserGroup.json @@ -29,6 +29,7 @@ "createdTime": "2021-06-02T07:37:19.000Z", "enterpriseAccountId": "entUBq2RGdihxl3vU", "id": "ugp1mKGb3KXUyQfOZ", + "mappedUserLicenseType": "contributor", "members": [ { "createdTime": "2021-06-02T07:37:19.000Z", From 39acf446b9a3ae832188d1b0cff3a86c19bc5d90 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 21 Jun 2025 00:02:10 -0700 Subject: [PATCH 254/272] Add TableSchema.date_dependency --- docs/source/metadata.rst | 11 +++- pyairtable/models/_base.py | 30 ++++++++-- pyairtable/models/schema.py | 110 +++++++++++++++++++++++++++++++++- pyairtable/utils.py | 7 ++- scripts/find_model_changes.py | 11 +++- tests/test_models_schema.py | 97 ++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 14 deletions(-) diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst index fd09fc96..9b501cd7 100644 --- a/docs/source/metadata.rst +++ b/docs/source/metadata.rst @@ -36,9 +36,11 @@ You'll find more detail in the API reference for :mod:`pyairtable.models.schema` Modifying existing schema ----------------------------- -To modify a table or field, you can modify its schema object directly and -call ``save()``, as shown below. You can only change names and descriptions; -the Airtable API does not permit changing any other options. +To modify a table or field, you can modify portions of its schema object directly +and call ``save()``, as shown below. The Airtable API only allows changing certain +properties; these are enumerated in the API reference for each schema class. +For example, :class:`~pyairtable.models.schema.TableSchema` allows changing the name, +description, and date dependency configuration. .. code-block:: python @@ -50,6 +52,9 @@ the Airtable API does not permit changing any other options. >>> field.description = "The primary field on the table" >>> field.save() +To add or replace the date dependency configuration on a table, you can use the shortcut method +:meth:`TableSchema.set_date_dependency `. + Creating schema elements ----------------------------- diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index fefdb28a..c51d9ea4 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -235,16 +235,16 @@ def __init_subclass__(cls, **kwargs: Any) -> None: kwargs.pop("reload_after_save", cls.__reload_after_save) ) if cls.__writable: - _append_docstring_text( + _append_docstring_refs( cls, - "The following fields can be modified and saved: " - + ", ".join(f"``{field}``" for field in cls.__writable), + "The following fields can be modified and saved", + cls.__writable, ) if cls.__readonly: - _append_docstring_text( + _append_docstring_refs( cls, - "The following fields are read-only and cannot be modified:\n" - + ", ".join(f"``{field}``" for field in cls.__readonly), + "The following fields are read-only and cannot be modified", + cls.__readonly, ) super().__init_subclass__(**kwargs) @@ -286,6 +286,24 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) +def _append_docstring_refs( + cls: Type[CanUpdateModel], + explanation: str, + field_names: Iterable[str], +) -> None: + """ + Used by CanUpdateModel to append a list of field names to the class docstring. + """ + field_refs = [ + f":attr:`~{cls.__module__}.{cls.__qualname__}.{field}`" for field in field_names + ] + _append_docstring_text( + cls, + f"{explanation}: " + ", ".join(field_refs), + before_re=r"^\s+Usage:", + ) + + def rebuild_models( obj: Union[Type[AirtableModel], Mapping[str, Any]], memo: Optional[Set[int]] = None, diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index bdd19754..5b5dc8ac 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,7 +1,18 @@ import importlib from datetime import datetime from functools import partial -from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + Literal, + Optional, + TypeVar, + Union, + cast, +) import pydantic from typing_extensions import TypeAlias @@ -15,6 +26,12 @@ rebuild_models, ) +if TYPE_CHECKING: + from pyairtable import orm + + +FieldSpecifier: TypeAlias = Union[str, "orm.fields.AnyField"] + _T = TypeVar("_T", bound=Any) _FL = partial(pydantic.Field, default_factory=list) _FD = partial(pydantic.Field, default_factory=dict) @@ -277,7 +294,7 @@ def table(self, id_or_name: str) -> "TableSchema": class TableSchema( CanUpdateModel, save_null_values=False, - writable=["name", "description"], + writable=["name", "description", "date_dependency"], url="meta/bases/{base.id}/tables/{self.id}", ): """ @@ -317,11 +334,18 @@ class TableSchema( description: Optional[str] = None fields: List["FieldSchema"] views: List["ViewSchema"] + date_dependency: Optional["DateDependency"] = pydantic.Field( + alias="dateDependencySettings", default=None + ) - def field(self, id_or_name: str) -> "FieldSchema": + def field(self, id_or_name: FieldSpecifier) -> "FieldSchema": """ Get the schema for the field with the given ID or name. """ + from pyairtable import orm + + if isinstance(id_or_name, orm.fields.Field): + id_or_name = id_or_name.field_name return _find(self.fields, id_or_name) def view(self, id_or_name: str) -> "ViewSchema": @@ -330,6 +354,86 @@ def view(self, id_or_name: str) -> "ViewSchema": """ return _find(self.views, id_or_name) + def set_date_dependency( + self, + start_date_field: FieldSpecifier, + end_date_field: FieldSpecifier, + duration_field: FieldSpecifier, + rescheduling_mode: str, + predecessor_field: Optional[FieldSpecifier] = None, + skip_weekends_and_holidays: bool = False, + holidays: Optional[List[str]] = None, + ) -> None: + """ + Create or replace the `date dependency settings `__ + for the table. You still need to call :meth:`~TableSchema.save` to persist the changes. + + Usage: + >>> table_schema = base.table("Table Name").schema() + >>> table_schema.set_date_dependency( + ... start_date_field="Start Date", + ... end_date_field="End Date", + ... duration_field="Duration", + ... rescheduling_mode="flexible", + ... skip_weekends_and_holidays=True, + ... holidays=["2026-01-01", "2026-12-25"], + ... predecessor_field="Depends On", + ... ) + >>> table_schema.save() + + This method also accepts ORM model fields as shorthand for those fields' IDs: + + >>> table_schema = SomeModel.meta.table.schema() + >>> table_schema.set_date_dependency( + ... start_date_field=SomeModel.start_date, + ... end_date_field=SomeModel.end_date, + ... duration_field=SomeModel.duration, + ... rescheduling_mode="flexible", + ... ) + >>> table_schema.save() + + Args: + start_date_field: The field ID or name for the start date. + end_date_field: The field ID or name for the end date. + duration_field: The field ID or name for the duration. + rescheduling_mode: Either "flexible", "fixed", or "none". + skip_weekends_and_holidays: Whether to skip weekends and holidays. + holidays: A list of holiday dates in ISO format (YYYY-MM-DD). + predecessor_field: Optional; the field ID or name for predecessor tasks. + """ + duration_field = self.field(duration_field).id + start_date_field = self.field(start_date_field).id + end_date_field = self.field(end_date_field).id + if predecessor_field is not None: + predecessor_field = self.field(predecessor_field).id + + self.date_dependency = TableSchema.DateDependency( + is_enabled=True, + duration_field_id=duration_field, + start_date_field_id=start_date_field, + end_date_field_id=end_date_field, + predecessor_field_id=predecessor_field, + rescheduling_mode=rescheduling_mode, + should_skip_weekends_and_holidays=skip_weekends_and_holidays, + holidays=holidays or [], + ) + + class DateDependency(AirtableModel): + """ + Settings for date dependencies in the table. + + See https://airtable.com/developers/web/api/model/date-dependency-settings + """ + + is_enabled: bool + duration_field_id: str + start_date_field_id: str + end_date_field_id: str + predecessor_field_id: Optional[str] = None + rescheduling_mode: str + should_skip_weekends_and_holidays: bool + holidays: List[str] = _FL() + class ViewSchema(CanDeleteModel, url="meta/bases/{base.id}/views/{self.id}"): """ diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 41702180..64039b2c 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -197,13 +197,18 @@ def _prepend_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> obj.__doc__ = f"{text}\n\n{doc}" -def _append_docstring_text(obj: Any, text: str, *, skip_empty: bool = True) -> None: +def _append_docstring_text( + obj: Any, text: str, *, skip_empty: bool = True, before_re: str = "" +) -> None: doc = obj.__doc__ or "" if skip_empty and not doc: return doc = doc.rstrip("\n") if has_leading_spaces := re.match(r"^\s+", doc): text = textwrap.indent(text, has_leading_spaces[0]) + if before_re and (match := re.search(before_re, doc, re.MULTILINE)): + text = text + "\n\n" + doc[match.start() :] + doc = doc[: match.start()].rstrip() obj.__doc__ = f"{doc}\n\n{text}" diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index 4c661357..5bf294c8 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -107,6 +107,7 @@ "pyairtable.models.webhook:WebhookSpecification.SourceOptions": "schemas:webhooks-specification:@filters:@sourceOptions", "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formSubmission", "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormPageSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formPageSubmission", + "pyairtable.models.schema:TableSchema.DateDependency": "schemas:date-dependency-settings", } IGNORED = [ @@ -125,6 +126,11 @@ def main() -> None: initdata = get_api_data() + identify_missing_fields(initdata) + identify_unscanned_classes(initdata) + + +def identify_missing_fields(initdata: "ApiData") -> None: issues: List[str] = [] # Find missing/extra fields @@ -142,8 +148,11 @@ def main() -> None: for issue in issues: print(issue) + +def identify_unscanned_classes(initdata: "ApiData") -> None: + issues: List[str] = [] + # Find unscanned model classes - issues.clear() modules = sorted({model_path.split(":")[0] for model_path in SCAN_MODELS}) for modname in modules: if not ignore_name(modname): diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index 65d29649..b5b0ca82 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -445,3 +445,100 @@ def test_workspace_restrictions(workspace, mock_workspace_metadata, requests_moc "inviteCreationRestriction": "unrestricted", "shareCreationRestriction": "onlyOwners", } + + +def test_save_date_dependency_settings(api, base, requests_mock): + table_id = fake_id("tbl") + + from pyairtable import orm + + class TaskModel(orm.Model): + # Used to test that add_date_dependency accepts an ORM field. + class Meta: + api_key = api.api_key + base_id = base.id + table_name = "Tasks" + + duration = orm.fields.IntegerField("Duration") + + obj = { + "id": table_id, + "name": "Tasks", + "description": "", + "primaryFieldId": "fldName", + "views": [], + "fields": [ + { + "id": "fldName", + "name": "Name", + "type": "singleLineText", + "options": {}, + }, + { + "id": "fldDepends", + "name": "Depends", + "type": "multipleRecordLinks", + "options": { + "isReversed": False, + "linkedTableId": table_id, + "prefersSingleRecordLink": False, + "inverseLinkFieldId": None, + "viewIdForRecordSelection": None, + }, + }, + { + "id": "fldStartDate", + "name": "Start Date", + "type": "date", + "options": {}, + }, + { + "id": "fldEndDate", + "name": "End Date", + "type": "date", + "options": {}, + }, + { + "id": "fldDuration", + "name": "Duration", + "type": "number", + "options": {}, + }, + ], + } + table_schema = schema.TableSchema.from_api(obj, api, context={"base": base}) + m = requests_mock.patch(table_schema._url, json=obj) + table_schema.set_date_dependency( + start_date_field="fldStartDate", + end_date_field="End Date", + duration_field=TaskModel.duration, + rescheduling_mode="none", + ) + assert m.call_count == 0 + + table_schema.save() + assert m.call_count == 1 + assert m.last_request.json() == { + "name": "Tasks", + "description": "", + "dateDependencySettings": { + "startDateFieldId": "fldStartDate", + "endDateFieldId": "fldEndDate", + "durationFieldId": "fldDuration", + "reschedulingMode": "none", + "isEnabled": True, + "shouldSkipWeekendsAndHolidays": False, + "holidays": [], + }, + } + + +def test_save_date_dependency_settings__invalid_field(table_schema): + with pytest.raises(KeyError, match=r"^'invalid_field'$"): + table_schema.set_date_dependency( + start_date_field="Name", + end_date_field="Name", + duration_field="Name", + predecessor_field="invalid_field", + rescheduling_mode="none", + ) From fdd7f90017b48508482baab68034ec880cc781da Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Aug 2025 16:54:03 -0700 Subject: [PATCH 255/272] Add sensitivityLabel and licenseType to model definitions --- pyairtable/models/schema.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 5b5dc8ac..d446f4f3 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -191,6 +191,7 @@ class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"): group_collaborators: "BaseCollaborators.GroupCollaborators" = _F("BaseCollaborators.GroupCollaborators") # fmt: skip individual_collaborators: "BaseCollaborators.IndividualCollaborators" = _F("BaseCollaborators.IndividualCollaborators") # fmt: skip invite_links: "BaseCollaborators.InviteLinks" = _F("BaseCollaborators.InviteLinks") # fmt: skip + sensitivity_label: Optional["BaseCollaborators.SensitivityLabel"] = None class InterfaceCollaborators( _Collaborators, @@ -216,6 +217,11 @@ class InviteLinks(RestfulModel, url="{base_collaborators._url}/invites"): via_base: List["InviteLink"] = _FL(alias="baseInviteLinks") via_workspace: List["WorkspaceInviteLink"] = _FL(alias="workspaceInviteLinks") # fmt: skip + class SensitivityLabel(AirtableModel): + id: str + description: str + name: str + class BaseShares(AirtableModel): """ @@ -681,6 +687,7 @@ class UserInfo( is_two_factor_auth_enabled: bool last_activity_time: Optional[datetime] = None created_time: Optional[datetime] = None + license_type: Optional[str] = None enterprise_user_type: Optional[str] = None invited_to_airtable_by_user_id: Optional[str] = None is_managed: bool = False @@ -695,6 +702,7 @@ def logout(self) -> None: self._api.post(self._url + "/logout") class DescendantIds(AirtableModel): + license_type: Optional[str] = None last_activity_time: Optional[datetime] = None collaborations: Optional["Collaborations"] = None is_admin: bool = False @@ -702,6 +710,7 @@ class DescendantIds(AirtableModel): groups: List[NestedId] = _FL() class AggregatedIds(AirtableModel): + license_type: Optional[str] = None last_activity_time: Optional[datetime] = None collaborations: Optional["Collaborations"] = None is_admin: bool = False From 8d8ffa4f89713757b180766f790d00f1275e768a Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Aug 2025 17:02:11 -0700 Subject: [PATCH 256/272] Make find_model_changes syntax easier to read --- scripts/find_model_changes.py | 224 +++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 101 deletions(-) diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index 5bf294c8..0f631540 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -10,6 +10,7 @@ from operator import attrgetter from typing import Any, Dict, Iterator, List, Type +import click import requests from pyairtable.models._base import AirtableModel @@ -19,95 +20,96 @@ INITDATA_RE = r"]*>\s*window\.initData = (\{.*\})\s*" SCAN_MODELS = { - "pyairtable.api.enterprise:UserRemoved": "operations:remove-user-from-enterprise:response:schema", - "pyairtable.api.enterprise:UserRemoved.Shared": "operations:remove-user-from-enterprise:response:schema:@shared", - "pyairtable.api.enterprise:UserRemoved.Shared.Workspace": "operations:remove-user-from-enterprise:response:schema:@shared:@workspaces:items", - "pyairtable.api.enterprise:UserRemoved.Unshared": "operations:remove-user-from-enterprise:response:schema:@unshared", - "pyairtable.api.enterprise:UserRemoved.Unshared.Base": "operations:remove-user-from-enterprise:response:schema:@unshared:@bases:items", - "pyairtable.api.enterprise:UserRemoved.Unshared.Interface": "operations:remove-user-from-enterprise:response:schema:@unshared:@interfaces:items", - "pyairtable.api.enterprise:UserRemoved.Unshared.Workspace": "operations:remove-user-from-enterprise:response:schema:@unshared:@workspaces:items", - "pyairtable.api.enterprise:DeleteUsersResponse": "operations:delete-users-by-email:response:schema", - "pyairtable.api.enterprise:DeleteUsersResponse.UserInfo": "operations:delete-users-by-email:response:schema:@deletedUsers:items", - "pyairtable.api.enterprise:DeleteUsersResponse.Error": "operations:delete-users-by-email:response:schema:@errors:items", - "pyairtable.api.enterprise:ManageUsersResponse": "operations:manage-user-membership:response:schema", - "pyairtable.api.enterprise:ManageUsersResponse.Error": "operations:manage-user-membership:response:schema:@errors:items", - "pyairtable.api.enterprise:MoveError": "operations:move-workspaces:response:schema:@errors:items", - "pyairtable.api.enterprise:MoveGroupsResponse": "operations:move-user-groups:response:schema", - "pyairtable.api.enterprise:MoveWorkspacesResponse": "operations:move-workspaces:response:schema", - "pyairtable.models.audit:AuditLogResponse": "operations:audit-log-events:response:schema", - "pyairtable.models.audit:AuditLogEvent": "operations:audit-log-events:response:schema:@events:items", - "pyairtable.models.audit:AuditLogEvent.Context": "operations:audit-log-events:response:schema:@events:items:@context", - "pyairtable.models.audit:AuditLogEvent.Origin": "operations:audit-log-events:response:schema:@events:items:@origin", - "pyairtable.models.audit:AuditLogActor": "schemas:audit-log-actor", - "pyairtable.models.audit:AuditLogActor.UserInfo": "schemas:audit-log-actor:@user", - "pyairtable.models.collaborator:Collaborator": "operations:list-comments:response:schema:@comments:items:@author", - "pyairtable.models.comment:Comment": "operations:list-comments:response:schema:@comments:items", - "pyairtable.models.comment:Reaction": "operations:list-comments:response:schema:@comments:items:@reactions:items", - "pyairtable.models.comment:Reaction.EmojiInfo": "operations:list-comments:response:schema:@comments:items:@reactions:items:@emoji", - "pyairtable.models.comment:Reaction.ReactingUser": "operations:list-comments:response:schema:@comments:items:@reactions:items:@reactingUser", - "pyairtable.models.comment:Mentioned": "schemas:user-mentioned", - "pyairtable.models.schema:BaseSchema": "operations:get-base-schema:response:schema", - "pyairtable.models.schema:TableSchema": "schemas:table-model", - "pyairtable.models.schema:Bases": "operations:list-bases:response:schema", - "pyairtable.models.schema:Bases.Info": "operations:list-bases:response:schema:@bases:items", - "pyairtable.models.schema:BaseCollaborators": "operations:get-base-collaborators:response:schema", - "pyairtable.models.schema:BaseCollaborators.IndividualCollaborators": "operations:get-base-collaborators:response:schema:@individualCollaborators", - "pyairtable.models.schema:BaseCollaborators.GroupCollaborators": "operations:get-base-collaborators:response:schema:@groupCollaborators", - "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:*", - "pyairtable.models.schema:BaseCollaborators.InviteLinks": "operations:get-base-collaborators:response:schema:@inviteLinks", - "pyairtable.models.schema:BaseShares": "operations:list-shares:response:schema", - "pyairtable.models.schema:BaseShares.Info": "operations:list-shares:response:schema:@shares:items", - "pyairtable.models.schema:ViewSchema": "operations:get-view-metadata:response:schema", - "pyairtable.models.schema:InviteLink": "schemas:invite-link", - "pyairtable.models.schema:WorkspaceInviteLink": "schemas:invite-link", - "pyairtable.models.schema:InterfaceInviteLink": "schemas:invite-link", - "pyairtable.models.schema:EnterpriseInfo": "operations:get-enterprise:response:schema", - "pyairtable.models.schema:EnterpriseInfo.EmailDomain": "operations:get-enterprise:response:schema:@emailDomains:items", - "pyairtable.models.schema:EnterpriseInfo.AggregatedIds": "operations:get-enterprise:response:schema:@aggregated", - "pyairtable.models.schema:WorkspaceCollaborators": "operations:get-workspace-collaborators:response:schema", - "pyairtable.models.schema:WorkspaceCollaborators.Restrictions": "operations:get-workspace-collaborators:response:schema:@workspaceRestrictions", - "pyairtable.models.schema:WorkspaceCollaborators.GroupCollaborators": "operations:get-workspace-collaborators:response:schema:@groupCollaborators", - "pyairtable.models.schema:WorkspaceCollaborators.IndividualCollaborators": "operations:get-workspace-collaborators:response:schema:@individualCollaborators", - "pyairtable.models.schema:WorkspaceCollaborators.InviteLinks": "operations:get-workspace-collaborators:response:schema:@inviteLinks", - "pyairtable.models.schema:GroupCollaborator": "schemas:group-collaborator", - "pyairtable.models.schema:IndividualCollaborator": "schemas:individual-collaborator", - "pyairtable.models.schema:BaseGroupCollaborator": "schemas:base-group-collaborator", - "pyairtable.models.schema:BaseIndividualCollaborator": "schemas:base-individual-collaborator", - "pyairtable.models.schema:BaseInviteLink": "schemas:base-invite-link", - "pyairtable.models.schema:Collaborations": "schemas:collaborations", - "pyairtable.models.schema:Collaborations.BaseCollaboration": "schemas:collaborations:@baseCollaborations:items", - "pyairtable.models.schema:Collaborations.InterfaceCollaboration": "schemas:collaborations:@interfaceCollaborations:items", - "pyairtable.models.schema:Collaborations.WorkspaceCollaboration": "schemas:collaborations:@workspaceCollaborations:items", - "pyairtable.models.schema:UserInfo": "operations:get-user-by-id:response:schema", - "pyairtable.models.schema:UserInfo.AggregatedIds": "operations:get-user-by-id:response:schema:@aggregated", - "pyairtable.models.schema:UserInfo.DescendantIds": "operations:get-user-by-id:response:schema:@descendants:*", - "pyairtable.models.schema:UserGroup": "operations:get-user-group:response:schema", - "pyairtable.models.schema:UserGroup.Member": "operations:get-user-group:response:schema:@members:items", - "pyairtable.models.webhook:Webhook": "operations:list-webhooks:response:schema:@webhooks:items", - "pyairtable.models.webhook:WebhookNotificationResult": "schemas:webhooks-notification", - "pyairtable.models.webhook:WebhookError": "schemas:webhooks-notification:@error", - "pyairtable.models.webhook:WebhookPayloads": "operations:list-webhook-payloads:response:schema", - "pyairtable.models.webhook:WebhookPayload": "schemas:webhooks-payload", - "pyairtable.models.webhook:WebhookPayload.ActionMetadata": "schemas:webhooks-action", - "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:*", - "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:*:@current", - "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:*", - "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:*", - "pyairtable.models.webhook:WebhookPayload.TableChanged": "schemas:webhooks-table-changed", - "pyairtable.models.webhook:WebhookPayload.TableChanged.ChangedMetadata": "schemas:webhooks-table-changed:@changedMetadata", - "pyairtable.models.webhook:WebhookPayload.TableInfo": "schemas:webhooks-table-changed:@changedMetadata:@current", - "pyairtable.models.webhook:WebhookPayload.TableCreated": "schemas:webhooks-table-created", - "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:*", - "pyairtable.models.webhook:CreateWebhook": "operations:create-a-webhook:request:schema", - "pyairtable.models.webhook:CreateWebhookResponse": "operations:create-a-webhook:response:schema", - "pyairtable.models.webhook:WebhookSpecification": "operations:create-a-webhook:request:schema:@specification", - "pyairtable.models.webhook:WebhookSpecification.Options": "schemas:webhooks-specification", - "pyairtable.models.webhook:WebhookSpecification.Includes": "schemas:webhooks-specification:@includes", - "pyairtable.models.webhook:WebhookSpecification.Filters": "schemas:webhooks-specification:@filters", - "pyairtable.models.webhook:WebhookSpecification.SourceOptions": "schemas:webhooks-specification:@filters:@sourceOptions", - "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formSubmission", - "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormPageSubmission": "schemas:webhooks-specification:@filters:@sourceOptions:@formPageSubmission", - "pyairtable.models.schema:TableSchema.DateDependency": "schemas:date-dependency-settings", + "pyairtable.api.enterprise:UserRemoved": "remove-user-from-enterprise.response", + "pyairtable.api.enterprise:UserRemoved.Shared": "remove-user-from-enterprise.response/@shared", + "pyairtable.api.enterprise:UserRemoved.Shared.Workspace": "remove-user-from-enterprise.response/@shared/@workspaces/items", + "pyairtable.api.enterprise:UserRemoved.Unshared": "remove-user-from-enterprise.response/@unshared", + "pyairtable.api.enterprise:UserRemoved.Unshared.Base": "remove-user-from-enterprise.response/@unshared/@bases/items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Interface": "remove-user-from-enterprise.response/@unshared/@interfaces/items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Workspace": "remove-user-from-enterprise.response/@unshared/@workspaces/items", + "pyairtable.api.enterprise:DeleteUsersResponse": "delete-users-by-email.response", + "pyairtable.api.enterprise:DeleteUsersResponse.UserInfo": "delete-users-by-email.response/@deletedUsers/items", + "pyairtable.api.enterprise:DeleteUsersResponse.Error": "delete-users-by-email.response/@errors/items", + "pyairtable.api.enterprise:ManageUsersResponse": "manage-user-membership.response", + "pyairtable.api.enterprise:ManageUsersResponse.Error": "manage-user-membership.response/@errors/items", + "pyairtable.api.enterprise:MoveError": "move-workspaces.response/@errors/items", + "pyairtable.api.enterprise:MoveGroupsResponse": "move-user-groups.response", + "pyairtable.api.enterprise:MoveWorkspacesResponse": "move-workspaces.response", + "pyairtable.models.audit:AuditLogResponse": "audit-log-events.response", + "pyairtable.models.audit:AuditLogEvent": "audit-log-events.response/@events/items", + "pyairtable.models.audit:AuditLogEvent.Context": "audit-log-events.response/@events/items/@context", + "pyairtable.models.audit:AuditLogEvent.Origin": "audit-log-events.response/@events/items/@origin", + "pyairtable.models.audit:AuditLogActor": "schemas/audit-log-actor", + "pyairtable.models.audit:AuditLogActor.UserInfo": "schemas/audit-log-actor/@user", + "pyairtable.models.collaborator:Collaborator": "list-comments.response/@comments/items/@author", + "pyairtable.models.comment:Comment": "list-comments.response/@comments/items", + "pyairtable.models.comment:Reaction": "list-comments.response/@comments/items/@reactions/items", + "pyairtable.models.comment:Reaction.EmojiInfo": "list-comments.response/@comments/items/@reactions/items/@emoji", + "pyairtable.models.comment:Reaction.ReactingUser": "list-comments.response/@comments/items/@reactions/items/@reactingUser", + "pyairtable.models.comment:Mentioned": "schemas/user-mentioned", + "pyairtable.models.schema:BaseSchema": "get-base-schema.response", + "pyairtable.models.schema:TableSchema": "schemas/table-model", + "pyairtable.models.schema:Bases": "list-bases.response", + "pyairtable.models.schema:Bases.Info": "list-bases.response/@bases/items", + "pyairtable.models.schema:BaseCollaborators": "get-base-collaborators.response", + "pyairtable.models.schema:BaseCollaborators.IndividualCollaborators": "get-base-collaborators.response/@individualCollaborators", + "pyairtable.models.schema:BaseCollaborators.GroupCollaborators": "get-base-collaborators.response/@groupCollaborators", + "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "get-base-collaborators.response/@interfaces/*", + "pyairtable.models.schema:BaseCollaborators.InviteLinks": "get-base-collaborators.response/@inviteLinks", + "pyairtable.models.schema:BaseCollaborators.SensitivityLabel": "get-base-collaborators.response/@sensitivityLabel", + "pyairtable.models.schema:BaseShares": "list-shares.response", + "pyairtable.models.schema:BaseShares.Info": "list-shares.response/@shares/items", + "pyairtable.models.schema:ViewSchema": "get-view-metadata.response", + "pyairtable.models.schema:InviteLink": "schemas/invite-link", + "pyairtable.models.schema:WorkspaceInviteLink": "schemas/invite-link", + "pyairtable.models.schema:InterfaceInviteLink": "schemas/invite-link", + "pyairtable.models.schema:EnterpriseInfo": "get-enterprise.response", + "pyairtable.models.schema:EnterpriseInfo.EmailDomain": "get-enterprise.response/@emailDomains/items", + "pyairtable.models.schema:EnterpriseInfo.AggregatedIds": "get-enterprise.response/@aggregated", + "pyairtable.models.schema:WorkspaceCollaborators": "get-workspace-collaborators.response", + "pyairtable.models.schema:WorkspaceCollaborators.Restrictions": "get-workspace-collaborators.response/@workspaceRestrictions", + "pyairtable.models.schema:WorkspaceCollaborators.GroupCollaborators": "get-workspace-collaborators.response/@groupCollaborators", + "pyairtable.models.schema:WorkspaceCollaborators.IndividualCollaborators": "get-workspace-collaborators.response/@individualCollaborators", + "pyairtable.models.schema:WorkspaceCollaborators.InviteLinks": "get-workspace-collaborators.response/@inviteLinks", + "pyairtable.models.schema:GroupCollaborator": "schemas/group-collaborator", + "pyairtable.models.schema:IndividualCollaborator": "schemas/individual-collaborator", + "pyairtable.models.schema:BaseGroupCollaborator": "schemas/base-group-collaborator", + "pyairtable.models.schema:BaseIndividualCollaborator": "schemas/base-individual-collaborator", + "pyairtable.models.schema:BaseInviteLink": "schemas/base-invite-link", + "pyairtable.models.schema:Collaborations": "schemas/collaborations", + "pyairtable.models.schema:Collaborations.BaseCollaboration": "schemas/collaborations/@baseCollaborations/items", + "pyairtable.models.schema:Collaborations.InterfaceCollaboration": "schemas/collaborations/@interfaceCollaborations/items", + "pyairtable.models.schema:Collaborations.WorkspaceCollaboration": "schemas/collaborations/@workspaceCollaborations/items", + "pyairtable.models.schema:UserInfo": "get-user-by-id.response", + "pyairtable.models.schema:UserInfo.AggregatedIds": "get-user-by-id.response/@aggregated", + "pyairtable.models.schema:UserInfo.DescendantIds": "get-user-by-id.response/@descendants/*", + "pyairtable.models.schema:UserGroup": "get-user-group.response", + "pyairtable.models.schema:UserGroup.Member": "get-user-group.response/@members/items", + "pyairtable.models.webhook:Webhook": "list-webhooks.response/@webhooks/items", + "pyairtable.models.webhook:WebhookNotificationResult": "schemas/webhooks-notification", + "pyairtable.models.webhook:WebhookError": "schemas/webhooks-notification/@error", + "pyairtable.models.webhook:WebhookPayloads": "list-webhook-payloads.response", + "pyairtable.models.webhook:WebhookPayload": "schemas/webhooks-payload", + "pyairtable.models.webhook:WebhookPayload.ActionMetadata": "schemas/webhooks-action", + "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas/webhooks-table-changed/@changedFieldsById/*", + "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas/webhooks-table-changed/@changedFieldsById/*/@current", + "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas/webhooks-changed-record/*", + "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas/webhooks-created-record/*", + "pyairtable.models.webhook:WebhookPayload.TableChanged": "schemas/webhooks-table-changed", + "pyairtable.models.webhook:WebhookPayload.TableChanged.ChangedMetadata": "schemas/webhooks-table-changed/@changedMetadata", + "pyairtable.models.webhook:WebhookPayload.TableInfo": "schemas/webhooks-table-changed/@changedMetadata/@current", + "pyairtable.models.webhook:WebhookPayload.TableCreated": "schemas/webhooks-table-created", + "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas/webhooks-table-changed/@changedViewsById/*", + "pyairtable.models.webhook:CreateWebhook": "create-a-webhook.request", + "pyairtable.models.webhook:CreateWebhookResponse": "create-a-webhook.response", + "pyairtable.models.webhook:WebhookSpecification": "create-a-webhook.request/@specification", + "pyairtable.models.webhook:WebhookSpecification.Options": "schemas/webhooks-specification", + "pyairtable.models.webhook:WebhookSpecification.Includes": "schemas/webhooks-specification/@includes", + "pyairtable.models.webhook:WebhookSpecification.Filters": "schemas/webhooks-specification/@filters", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions": "schemas/webhooks-specification/@filters/@sourceOptions", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormSubmission": "schemas/webhooks-specification/@filters/@sourceOptions/@formSubmission", + "pyairtable.models.webhook:WebhookSpecification.SourceOptions.FormPageSubmission": "schemas/webhooks-specification/@filters/@sourceOptions/@formPageSubmission", + "pyairtable.models.schema:TableSchema.DateDependency": "schemas/date-dependency-settings", } IGNORED = [ @@ -124,23 +126,43 @@ ] -def main() -> None: - initdata = get_api_data() - identify_missing_fields(initdata) - identify_unscanned_classes(initdata) +@click.command() +@click.option( + "--save", + "save_apidata", + help="Save API schema information to a file.", + type=click.Path(writable=True), +) +def main(save_apidata: str | None) -> None: + api_data = get_api_data() + if save_apidata: + with open(save_apidata, "w") as f: + json.dump(api_data, f, indent=2, sort_keys=True) + identify_missing_fields(api_data) + identify_unscanned_classes(api_data) -def identify_missing_fields(initdata: "ApiData") -> None: + +def identify_missing_fields(api_data: "ApiData") -> None: issues: List[str] = [] # Find missing/extra fields - for model_path, initdata_path in SCAN_MODELS.items(): + for model_path, data_path in SCAN_MODELS.items(): modname, clsname = model_path.split(":", 1) model_module = importlib.import_module(modname) model_cls = attrgetter(clsname)(model_module) - initdata_path = initdata_path.replace(":@", ":properties:") - initdata_path = re.sub(r":\*(:|$)", r":additionalProperties\1", initdata_path) - issues.extend(scan_schema(model_cls, initdata.get_nested(initdata_path))) + # Use obj/@thing as shorthand for obj/properties/thing + data_path = data_path.replace("/@", "/properties/") + # Use obj/* as shorthand for obj/additionalProperties + data_path = re.sub(r"/\*(/|$)", r"/additionalProperties\1", data_path) + # Use list-bases.request as shorthand for operations/list-bases/request/schema + # and list-bases.response as shorthand for operations/list-bases/response/schema + data_path = re.sub( + r"(^|/)([a-zA-Z_-]+)\.(request|response)(/|$)", + r"\1operations/\2/\3/schema\4", + data_path, + ) + issues.extend(scan_schema(model_cls, api_data.get_nested(data_path))) if not issues: print("No missing/extra fields found in scanned classes") @@ -149,7 +171,7 @@ def identify_missing_fields(initdata: "ApiData") -> None: print(issue) -def identify_unscanned_classes(initdata: "ApiData") -> None: +def identify_unscanned_classes(api_data: "ApiData") -> None: issues: List[str] = [] # Find unscanned model classes @@ -191,7 +213,7 @@ def __getitem__(self, key: str) -> Any: return self.by_model_name return super().__getitem__(key) - def get_nested(self, path: str, separator: str = ":") -> Any: + def get_nested(self, path: str, separator: str = "/") -> Any: """ Retrieves nested objects with a path-like syntax. """ @@ -253,7 +275,7 @@ def get_model(self, name: str) -> Dict[str, Any]: Retrieve a model schema by name. """ return self.collapse_schema( - self.get_nested(f"openApi:components:schemas:{name}") + self.get_nested(f"openApi/components/schemas/{name}") ) def collapse_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: From 7b4e387d88051bcebabaeadc07f1776208dee23b Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Aug 2025 17:20:52 -0700 Subject: [PATCH 257/272] Fix warnings on updated mypy (1.17) --- pyairtable/orm/fields.py | 2 +- pyairtable/utils.py | 2 +- scripts/find_model_changes.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index 4be0be40..ccb08a9d 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -741,7 +741,7 @@ def _get_list_value(self, instance: "Model") -> T_ORM_List: # 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 cast(T_ORM_List, value) + return cast(T_ORM_List, value) # type: ignore[redundant-cast] def valid_or_raise(self, value: Any) -> None: super().valid_or_raise(value) diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 64039b2c..1adf42be 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -170,7 +170,7 @@ def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F: for name, obj in vars(wrapped).items(): if inspect.isfunction(obj): setattr(wrapped, name, enterprise_only(obj)) - return cast(F, wrapped) + return cast(F, wrapped) # type: ignore[redundant-cast] @wraps(wrapped) def _decorated(*args: Any, **kwargs: Any) -> Any: diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index 0f631540..e33e1d1f 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -8,7 +8,7 @@ import re from functools import cached_property from operator import attrgetter -from typing import Any, Dict, Iterator, List, Type +from typing import Any, Dict, Iterator, List, Optional, Type import click import requests @@ -133,7 +133,7 @@ help="Save API schema information to a file.", type=click.Path(writable=True), ) -def main(save_apidata: str | None) -> None: +def main(save_apidata: Optional[str]) -> None: api_data = get_api_data() if save_apidata: with open(save_apidata, "w") as f: From 23dbd5dcb574fccb7d740fcd4f79293c4b8fcb4b Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Thu, 14 Aug 2025 20:59:08 -0700 Subject: [PATCH 258/272] Release 3.2.0 --- docs/source/changelog.rst | 6 ++++++ pyairtable/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index f77b470f..0de64ef6 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,12 @@ Changelog ========= +3.2.0 (2025-08-17) +------------------------ + +* Added several new fields returned in metadata models. + - `PR #434 `_ + 3.1.1 (2025-04-07) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 51c4acf3..973388bd 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.1.1" +__version__ = "3.2.0" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From da29d954f7a5dea84618516166001d230e0c4545 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sat, 1 Nov 2025 18:06:08 -0700 Subject: [PATCH 259/272] Fix deprecation warning from Pydantic 2.11 --- pyairtable/models/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py index c51d9ea4..a10e77b0 100644 --- a/pyairtable/models/_base.py +++ b/pyairtable/models/_base.py @@ -45,7 +45,7 @@ def __init__(self, **data: Any) -> None: # Convert JSON-serializable input data to the types expected by our model. # For now this only converts ISO 8601 strings to datetime objects. - for field_name, field_model in self.model_fields.items(): + for field_name, field_model in self.__class__.model_fields.items(): for name in {field_name, field_model.alias}: if not name or not (value := data.get(name)): continue From 94760d3227c38bbcb3a8038eef4a7880cd4c0616 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 2 Nov 2025 09:32:35 -0800 Subject: [PATCH 260/272] Add count_comments option that passes recordMetadata[]=commentCount --- docs/source/_substitutions.rst | 4 ++++ pyairtable/api/params.py | 12 ++++++++++++ pyairtable/api/table.py | 3 +++ pyairtable/api/types.py | 17 +++++++++++++---- tests/test_api_table.py | 33 +++++++++++++++++++++++++++++++++ tests/test_params.py | 3 +++ 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst index 17d99735..2ce22d36 100644 --- a/docs/source/_substitutions.rst +++ b/docs/source/_substitutions.rst @@ -64,6 +64,10 @@ key is the field id. This defaults to ``False``, which returns field objects where the key is the field name. This behavior can be overridden by passing ``use_field_ids=True`` to :class:`~pyairtable.Api`. +.. |kwarg_count_comments| replace:: If ``True``, the API will include a ``commentCount`` + field for each record. This allows you to see which records have comments without fetching + each record individually. Defaults to ``False``. + .. |kwarg_force_metadata| replace:: By default, this method will only fetch information from the API if it has not been cached. If called with ``force=True`` it will always call the API, and will overwrite any cached values. diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 48aab066..6b9e4040 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -102,6 +102,10 @@ def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: Returns: A dict of query parameters that can be passed to the ``requests`` library. """ + # Handle count_comments separately since it needs special conversion + options = options.copy() + count_comments = options.pop("count_comments", False) + params = {_option_to_param(name): value for (name, value) in options.items()} if "fields" in params: @@ -111,6 +115,8 @@ def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: if "sort" in params: sorting_dict_list = field_names_to_sorting_dict(params.pop("sort")) params.update(dict_list_to_request_params("sort", sorting_dict_list)) + if count_comments: + params["recordMetadata[]"] = ["commentCount"] return params @@ -127,6 +133,10 @@ def options_to_json_and_params( Returns: A 2-tuple that contains the POST data and the non-POSTable query parameters. """ + # Handle count_comments separately since it needs special conversion + options = options.copy() + count_comments = options.pop("count_comments", False) + json = { _option_to_param(name): value for (name, value) in options.items() @@ -142,5 +152,7 @@ def options_to_json_and_params( json["returnFieldsByFieldId"] = bool(json["returnFieldsByFieldId"]) if "sort" in json: json["sort"] = field_names_to_sorting_dict(json.pop("sort")) + if count_comments: + json["recordMetadata"] = ["commentCount"] return (json, params) diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 96b2f0a4..b84a0fe9 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -278,6 +278,7 @@ def iterate(self, **options: Any) -> Iterator[List[RecordDict]]: user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| use_field_ids: |kwarg_use_field_ids| + count_comments: |kwarg_count_comments| """ if isinstance(formula := options.get("formula"), Formula): options["formula"] = to_formula_str(formula) @@ -312,6 +313,7 @@ def all(self, **options: Any) -> List[RecordDict]: user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| use_field_ids: |kwarg_use_field_ids| + count_comments: |kwarg_count_comments| """ return [record for page in self.iterate(**options) for record in page] @@ -332,6 +334,7 @@ def first(self, **options: Any) -> Optional[RecordDict]: user_locale: |kwarg_user_locale| time_zone: |kwarg_time_zone| use_field_ids: |kwarg_use_field_ids| + count_comments: |kwarg_count_comments| """ options.update(dict(page_size=1, max_records=1)) for page in self.iterate(**options): diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py index 3f34b8c1..d2efcd44 100644 --- a/pyairtable/api/types.py +++ b/pyairtable/api/types.py @@ -254,7 +254,7 @@ class AddGroupCollaboratorDict(TypedDict): WritableFields: TypeAlias = Dict[FieldName, WritableFieldValue] -class RecordDict(TypedDict): +class RecordDict(TypedDict, total=False): """ A ``dict`` representing a record returned from the Airtable API. See `List records `__. @@ -266,11 +266,20 @@ class RecordDict(TypedDict): 'createdTime': '2023-05-22T21:24:15.333134Z', 'fields': {'Name': 'Alice', 'Department': 'Engineering'} } + + >>> table.first(count_comments=True) + { + 'id': 'recAdw9EjV90xbW', + 'createdTime': '2023-05-22T21:24:15.333134Z', + 'fields': {'Name': 'Alice'}, + 'commentCount': 5 + } """ - id: RecordId - createdTime: Timestamp - fields: Fields + id: Required[RecordId] + createdTime: Required[Timestamp] + fields: Required[Fields] + commentCount: int class CreateRecordDict(TypedDict): diff --git a/tests/test_api_table.py b/tests/test_api_table.py index e9f1757a..a2559dc9 100644 --- a/tests/test_api_table.py +++ b/tests/test_api_table.py @@ -250,6 +250,7 @@ def test_all(table, requests_mock, mock_response_list, mock_records): "sort[1][field]": ["Email"], }, ), + ({"count_comments": True}, {"recordMetadata[]": ["commentCount"]}), ], ) def test_all__params(table, requests_mock, kwargs, expected): @@ -307,6 +308,38 @@ def test_iterate__formula_conversion(table): ) +def test_all__count_comments(table, requests_mock): + """ + Test that count_comments parameter properly includes commentCount. + """ + mock_response = { + "records": [ + { + "id": "recA", + "createdTime": "2023-01-01T00:00:00.000Z", + "fields": {"Name": "Alice"}, + "commentCount": 5, + }, + { + "id": "recB", + "createdTime": "2023-01-02T00:00:00.000Z", + "fields": {"Name": "Bob"}, + "commentCount": 0, + }, + ] + } + m = requests_mock.get(table.urls.records, status_code=200, json=mock_response) + records = table.all(count_comments=True) + + # Verify the request was made with the correct parameter + assert m.last_request.qs == {"recordMetadata[]": ["commentCount"]} + + # Verify the response includes commentCount + assert len(records) == 2 + assert records[0]["commentCount"] == 5 + assert records[1]["commentCount"] == 0 + + def test_create(table: Table, mock_response_single): with Mocker() as mock: post_data = mock_response_single["fields"] diff --git a/tests/test_params.py b/tests/test_params.py index 81820c9e..b22df356 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -103,6 +103,7 @@ def test_params_integration(table, mock_records, mock_response_iterator): ["use_field_ids", True, "?returnFieldsByFieldId=1"], ["use_field_ids", 1, "?returnFieldsByFieldId=1"], ["use_field_ids", False, "?returnFieldsByFieldId=0"], + ["count_comments", True, "?recordMetadata%5B%5D=commentCount"], # TODO # [ # {"sort": [("Name", "desc"), ("Phone", "asc")]}, @@ -166,6 +167,8 @@ def test_convert_options_to_params(option, value, url_params): ["use_field_ids", True, {"returnFieldsByFieldId": True}], ["use_field_ids", 1, {"returnFieldsByFieldId": True}], ["use_field_ids", False, {"returnFieldsByFieldId": False}], + ["count_comments", True, {"recordMetadata": ["commentCount"]}], + ["count_comments", False, {}], # userLocale and timeZone are not supported via POST, so they return "spare params" ["user_locale", "en-US", ({}, {"userLocale": "en-US"})], ["time_zone", "America/Chicago", ({}, {"timeZone": "America/Chicago"})], From 496c4e40821c8f7809c908646acb9d1db9d8db69 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 2 Nov 2025 09:39:26 -0800 Subject: [PATCH 261/272] Make recordMetadata options extensible for the future --- pyairtable/api/params.py | 39 ++++++++++++++++++++++++------------- pyairtable/formulas.py | 2 +- pyairtable/models/schema.py | 2 +- pyairtable/orm/fields.py | 2 +- tests/test_params.py | 31 +++++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py index 6b9e4040..0d26bdfd 100644 --- a/pyairtable/api/params.py +++ b/pyairtable/api/params.py @@ -91,6 +91,20 @@ def _option_to_param(name: str) -> str: #: See https://github.com/gtalarico/pyairtable/pull/210#discussion_r1046014885 OPTIONS_NOT_SUPPORTED_VIA_POST = ("user_locale", "time_zone") +#: Mapping of option names to their recordMetadata values +#: These options are converted to the recordMetadata array parameter +OPTIONS_TO_RECORD_METADATA = { + "count_comments": "commentCount", +} + + +def _build_record_metadata(options: Dict[str, Any]) -> List[str]: + return [ + metadata_value + for option_name, metadata_value in OPTIONS_TO_RECORD_METADATA.items() + if options.get(option_name) + ] + def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: """ @@ -102,11 +116,11 @@ def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: Returns: A dict of query parameters that can be passed to the ``requests`` library. """ - # Handle count_comments separately since it needs special conversion - options = options.copy() - count_comments = options.pop("count_comments", False) - - params = {_option_to_param(name): value for (name, value) in options.items()} + params = { + _option_to_param(name): value + for (name, value) in options.items() + if name not in OPTIONS_TO_RECORD_METADATA + } if "fields" in params: params["fields[]"] = params.pop("fields") @@ -115,14 +129,14 @@ def options_to_params(options: Dict[str, Any]) -> Dict[str, Any]: if "sort" in params: sorting_dict_list = field_names_to_sorting_dict(params.pop("sort")) params.update(dict_list_to_request_params("sort", sorting_dict_list)) - if count_comments: - params["recordMetadata[]"] = ["commentCount"] + if record_metadata := _build_record_metadata(options): + params["recordMetadata[]"] = record_metadata return params def options_to_json_and_params( - options: Dict[str, Any] + options: Dict[str, Any], ) -> Tuple[Dict[str, Any], Dict[str, Any]]: """ Convert Airtable options to a JSON payload with (possibly) leftover query params. @@ -133,14 +147,11 @@ def options_to_json_and_params( Returns: A 2-tuple that contains the POST data and the non-POSTable query parameters. """ - # Handle count_comments separately since it needs special conversion - options = options.copy() - count_comments = options.pop("count_comments", False) - json = { _option_to_param(name): value for (name, value) in options.items() if name not in OPTIONS_NOT_SUPPORTED_VIA_POST + and name not in OPTIONS_TO_RECORD_METADATA } params = { _option_to_param(name): value @@ -152,7 +163,7 @@ def options_to_json_and_params( json["returnFieldsByFieldId"] = bool(json["returnFieldsByFieldId"]) if "sort" in json: json["sort"] = field_names_to_sorting_dict(json.pop("sort")) - if count_comments: - json["recordMetadata"] = ["commentCount"] + if record_metadata := _build_record_metadata(options): + json["recordMetadata"] = record_metadata return (json, params) diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py index 5e69fc76..e69d8968 100644 --- a/pyairtable/formulas.py +++ b/pyairtable/formulas.py @@ -1154,5 +1154,5 @@ def YEAR(date: FunctionArg, /) -> FunctionCall: return FunctionCall('YEAR', date) -# [[[end]]] (checksum: e89fb729872c20bdff0bf57c061dae96) +# [[[end]]] (sum: 6J+3KYcsIL) # fmt: on diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index d446f4f3..6b49069c 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1495,7 +1495,7 @@ class UnknownFieldSchema(_FieldSchemaBase, UnknownFieldConfig): UrlFieldSchema, UnknownFieldSchema, ] -# [[[end]]] (checksum: ca159bc8c76b1d15a2a57f0e76fb8911) +# [[[end]]] (sum: yhWbyMdrHR) # fmt: on diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index ccb08a9d..a41def84 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -1547,4 +1547,4 @@ class RequiredUrlField(UrlField, _Requires[str]): "TextField", "UrlField", ] -# [[[end]]] (checksum: 5a8ecad26031895bfaac551e751b7278) +# [[[end]]] (sum: Wo7K0mAxiV) diff --git a/tests/test_params.py b/tests/test_params.py index b22df356..0a5b706a 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -208,3 +208,34 @@ def test_field_names_to_sorting_dict(): "direction": "desc", }, ] + + +def test_record_metadata_options(monkeypatch): + """Test that OPTIONS_TO_RECORD_METADATA can be extended for future metadata options.""" + import pyairtable.api.params + + monkeypatch.setattr( + pyairtable.api.params, + "OPTIONS_TO_RECORD_METADATA", + {"count_comments": "commentCount", "future_option": "futureValue"}, + ) + + # Test GET params with multiple recordMetadata options + result = options_to_params({"count_comments": True, "future_option": True}) + assert set(result.get("recordMetadata[]", [])) == { + "commentCount", + "futureValue", + } + + # Test POST JSON with multiple recordMetadata options + json_result, _ = options_to_json_and_params( + {"count_comments": True, "future_option": True} + ) + assert set(json_result.get("recordMetadata", [])) == { + "commentCount", + "futureValue", + } + + # Test with only one option enabled + result = options_to_params({"count_comments": False, "future_option": True}) + assert result.get("recordMetadata[]") == ["futureValue"] From c1a9a83e618c858dfb7c9b62c190c17df045239f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 2 Nov 2025 11:00:44 -0800 Subject: [PATCH 262/272] Add support for count_comments= in ORM record retrieval --- pyairtable/orm/model.py | 5 +++++ tests/test_orm.py | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index c2651ada..06d1d56a 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -122,6 +122,10 @@ def api_key(): #: has never been saved to (or fetched from) the API. created_time: Optional[datetime.datetime] = None + #: The number of comments on this record. Only populated if the record was + #: fetched with ``count_comments=True``. + comment_count: Optional[int] = None + #: A wrapper allowing type-annotated access to ORM configuration. meta: ClassVar["_Meta"] @@ -381,6 +385,7 @@ def from_record( instance._fields = field_values instance._fetched = True instance.created_time = datetime_from_iso_str(record["createdTime"]) + instance.comment_count = record.get("commentCount") cls._maybe_memoize(instance, memoize) return instance diff --git a/tests/test_orm.py b/tests/test_orm.py index 1551201f..9820a107 100644 --- a/tests/test_orm.py +++ b/tests/test_orm.py @@ -126,6 +126,52 @@ def test_first_none(): assert contact is None +def test_all_with_comment_count(): + with mock.patch.object(Table, "all") as m_all: + m_all.return_value = [ + { + "id": "rec1", + "createdTime": NOW, + "fields": {"First Name": "Alice"}, + "commentCount": 5, + }, + { + "id": "rec2", + "createdTime": NOW, + "fields": {"First Name": "Bob"}, + "commentCount": 0, + }, + ] + contacts = Contact.all(count_comments=True) + + # Verify count_comments was passed to Table.all() + m_all.assert_called_once() + assert m_all.call_args.kwargs.get("count_comments") is True + + # Verify comment_count is populated on instances + assert len(contacts) == 2 + assert contacts[0].comment_count == 5 + assert contacts[1].comment_count == 0 + + +def test_first_with_comment_count(): + with mock.patch.object(Table, "first") as m_first: + m_first.return_value = { + "id": "rec1", + "createdTime": NOW, + "fields": {"First Name": "Alice"}, + "commentCount": 3, + } + contact = Contact.first(count_comments=True) + + # Verify count_comments was passed to Table.first() + m_first.assert_called_once() + assert m_first.call_args.kwargs.get("count_comments") is True + + # Verify comment_count is populated + assert contact.comment_count == 3 + + def test_from_record(): # Fetch = True with mock.patch.object(Table, "get") as m_get: From 77855b0e99c558acbb2d67ab409572ad6ae42951 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Sun, 2 Nov 2025 11:23:30 -0800 Subject: [PATCH 263/272] Implement new Create Workspace endpoint --- docs/source/enterprise.rst | 23 +++++++++++++++++++++++ pyairtable/api/enterprise.py | 27 +++++++++++++++++++++++++++ tests/test_api_enterprise.py | 15 +++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst index d43a229c..5ace9b70 100644 --- a/docs/source/enterprise.rst +++ b/docs/source/enterprise.rst @@ -170,3 +170,26 @@ via the following methods. >>> enterprise.revoke_admin("usrUserId") >>> enterprise.revoke_admin("user@example.com") >>> enterprise.revoke_admin(enterprise.user("usrUserId")) + + +Managing workspaces and organizations +-------------------------------------- + +You can use pyAirtable to manage workspaces, user groups, and descendant enterprises +via the following methods. + +`Create workspace `__ + + >>> workspace_id = enterprise.create_workspace("My New Workspace") + +`Move workspaces `__ + + >>> enterprise.move_workspaces(["wspId1", "wspId2"], "entTargetEnterpriseId") + +`Move user groups `__ + + >>> enterprise.move_groups(["ugpId1", "ugpId2"], "entTargetEnterpriseId") + +`Create descendant enterprise `__ + + >>> descendant = enterprise.create_descendant("Descendant Organization Name") diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 8183a55f..85b60372 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -66,6 +66,9 @@ class _urls(UrlBuilder): #: URL for moving workspaces between enterprise accounts. move_workspaces = meta / "moveWorkspaces" + #: URL for creating a new workspace. + create_workspace = Url("meta/workspaces") + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -511,6 +514,30 @@ def move_workspaces( ) return MoveWorkspacesResponse.from_api(response, self.api, context=self) + def create_workspace(self, name: str) -> str: + """ + Creates a new workspace with the provided name within the enterprise account + and returns the workspace ID. The requesting user must be an active effective + admin of the enterprise account; the created workspace's owner will be the user + who makes the request. + + See `Create workspace `__. + + Args: + name: The name of the workspace to be created. + + Returns: + The ID of the newly created workspace. + """ + response = self.api.post( + self.urls.create_workspace, + json={ + "enterpriseAccountId": self.id, + "name": name, + }, + ) + return str(response["id"]) + class UserRemoved(AirtableModel): """ diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 68e6e833..739b06bc 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -444,6 +444,21 @@ def test_create_descendant(enterprise, enterprise_mocks): assert isinstance(descendant, Enterprise) +def test_create_workspace(enterprise, requests_mock): + workspace_id = fake_id("wsp") + m = requests_mock.post( + "https://api.airtable.com/v0/meta/workspaces", + json={"id": workspace_id}, + ) + result = enterprise.create_workspace("My New Workspace") + assert m.call_count == 1 + assert m.last_request.json() == { + "enterpriseAccountId": enterprise.id, + "name": "My New Workspace", + } + assert result == workspace_id + + def test_move_groups(api, enterprise, enterprise_mocks): other_id = fake_id("ent") group_ids = [fake_id("ugp") for _ in range(3)] From 86ba178c9c0a4cf10b34ee2d7af6ae26331100f6 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 08:03:51 -0800 Subject: [PATCH 264/272] Fix breaking datetime typo in UserGroup test fixture --- tests/sample_data/UserGroup.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sample_data/UserGroup.json b/tests/sample_data/UserGroup.json index 1559fd3a..ef22b489 100644 --- a/tests/sample_data/UserGroup.json +++ b/tests/sample_data/UserGroup.json @@ -57,5 +57,5 @@ } ], "name": "Group name", - "updatedTime": "2022-09-02T10:10:35:000Z" + "updatedTime": "2022-09-02T10:10:35.000Z" } From 4239ce6dd8fe78595cf986aa566c07895fd0f530 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 08:26:14 -0800 Subject: [PATCH 265/272] Add support for Python 3.14, drop support for 3.10 --- .github/workflows/test_lint_deploy.yml | 6 +++--- docs/source/changelog.rst | 5 +++++ setup.cfg | 2 +- tox.ini | 8 ++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml index 33170263..4e35dabf 100644 --- a/.github/workflows/test_lint_deploy.yml +++ b/.github/workflows/test_lint_deploy.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 @@ -56,10 +56,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.9 + - name: Set up Python 3.14 uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.14 - name: Install pypa/build run: >- diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0de64ef6..e43483e4 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +3.3.0 (TBA) +------------------------ + +* Added support for Python 3.14 and dropped support for Python 3.9. + 3.2.0 (2025-08-17) ------------------------ diff --git a/setup.cfg b/setup.cfg index 240f5d92..e5328968 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,11 +18,11 @@ classifiers = Intended Audience :: Developers License :: OSI Approved :: MIT License Programming Language :: Python - Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Programming Language :: Python :: Implementation :: CPython Topic :: Software Development diff --git a/tox.ini b/tox.ini index b292d812..7e857df3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,18 @@ [tox] envlist = pre-commit - mypy-py3{9,10,11,12,13} - py3{9,10,11,12,13}{,-requestsmin} + mypy-py3{10,11,12,13,14} + py3{10,11,12,13,14}{,-requestsmin} integration coverage [gh-actions] python = - 3.9: py39, mypy-py39 3.10: py310, mypy-py310 3.11: py311, mypy-py311 3.12: coverage, mypy-py312 3.13: py313, mypy-py313 + 3.14: py314, mypy-py314 [testenv] passenv = @@ -32,11 +32,11 @@ commands = pre-commit run --all-files [testenv:mypy,mypy-py3{9,10,11,12,13}] basepython = - py39: python3.9 py310: python3.10 py311: python3.11 py312: python3.12 py313: python3.13 + py314: python3.14 deps = -r requirements-dev.txt commands = mypy --strict pyairtable scripts tests/test_typing.py From d4872cff830c2209fd5cd3b4e80b079a57d1aa15 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 08:24:54 -0800 Subject: [PATCH 266/272] Use py310 for docs and black --- .readthedocs.yaml | 4 ++-- docs/source/cli.rst | 2 +- docs/source/orm.rst | 2 +- pyproject.toml | 2 +- tox.ini | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f1a8858f..44dd2215 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,9 +2,9 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.9" + python: "3.10" sphinx: configuration: docs/source/conf.py diff --git a/docs/source/cli.rst b/docs/source/cli.rst index d542b7e6..f2f5a60c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -275,4 +275,4 @@ enterprise groups -c, --collaborations Include collaborations. --help Show this message and exit. -.. [[[end]]] (checksum: 9181d3a8abea1b24cb46cb6e997b08f0) +.. [[[end]]] (sum: kYHTqKvqGy) diff --git a/docs/source/orm.rst b/docs/source/orm.rst index 00494a9c..59a6e2a5 100644 --- a/docs/source/orm.rst +++ b/docs/source/orm.rst @@ -267,7 +267,7 @@ See :ref:`Required Values` for more details. - `Single line text `__, `Long text `__ * - :class:`~pyairtable.orm.fields.RequiredUrlField` - `Url `__ -.. [[[end]]] (checksum: 3ed2090cb24140caa19860a20b0f5a33) +.. [[[end]]] (sum: PtIJDLJBQM) Type Annotations diff --git a/pyproject.toml b/pyproject.toml index c4a55065..08e3c045 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,5 @@ build-backend = "setuptools.build_meta" [tool.black] line-length = 88 -target-version = ['py39'] +target-version = ['py310'] include = '\.pyi?$' diff --git a/tox.ini b/tox.ini index 7e857df3..8e0ae354 100644 --- a/tox.ini +++ b/tox.ini @@ -54,7 +54,7 @@ commands = --cov-fail-under=100 [testenv:docs] -basepython = python3.9 +basepython = python3.10 deps = -r requirements-dev.txt commands = From b02dee924a4e093e7f68df1422670f03b2282385 Mon Sep 17 00:00:00 2001 From: "Alex L." Date: Tue, 4 Nov 2025 13:20:35 -0800 Subject: [PATCH 267/272] Update mypy-py3* env names in tox.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 8e0ae354..d0a0853f 100644 --- a/tox.ini +++ b/tox.ini @@ -30,7 +30,7 @@ deps = deps = pre-commit commands = pre-commit run --all-files -[testenv:mypy,mypy-py3{9,10,11,12,13}] +[testenv:mypy,mypy-py3{10,11,12,13,14}] basepython = py310: python3.10 py311: python3.11 From 92e61e00c4732b9d0423ba4454e68f0993d88394 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 14:05:35 -0800 Subject: [PATCH 268/272] Enterprise.create_workspace returns Workspace --- pyairtable/api/enterprise.py | 5 +++-- tests/test_api_enterprise.py | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 85b60372..90213bbd 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from pyairtable.api.api import Api + from pyairtable.api.workspace import Workspace @enterprise_only @@ -514,7 +515,7 @@ def move_workspaces( ) return MoveWorkspacesResponse.from_api(response, self.api, context=self) - def create_workspace(self, name: str) -> str: + def create_workspace(self, name: str) -> "Workspace": """ Creates a new workspace with the provided name within the enterprise account and returns the workspace ID. The requesting user must be an active effective @@ -536,7 +537,7 @@ def create_workspace(self, name: str) -> str: "name": name, }, ) - return str(response["id"]) + return self.api.workspace(str(response["id"])) class UserRemoved(AirtableModel): diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 739b06bc..2c577ae1 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -445,6 +445,8 @@ def test_create_descendant(enterprise, enterprise_mocks): def test_create_workspace(enterprise, requests_mock): + from pyairtable.api.workspace import Workspace + workspace_id = fake_id("wsp") m = requests_mock.post( "https://api.airtable.com/v0/meta/workspaces", @@ -456,7 +458,8 @@ def test_create_workspace(enterprise, requests_mock): "enterpriseAccountId": enterprise.id, "name": "My New Workspace", } - assert result == workspace_id + assert isinstance(result, Workspace) + assert result.id == workspace_id def test_move_groups(api, enterprise, enterprise_mocks): From 3044307e58a1750755c1d1dc90170b73c6d08705 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 21:58:11 -0800 Subject: [PATCH 269/272] Add pyairtable.models.schema.FieldType enum --- pyairtable/models/schema.py | 118 +++++++++++++++++++++++++----------- pyairtable/orm/fields.py | 74 +++++++++++----------- pyairtable/orm/generate.py | 5 +- tests/test_models_schema.py | 50 +++++++++++++++ 4 files changed, 176 insertions(+), 71 deletions(-) diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 6b49069c..b71162df 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -1,5 +1,6 @@ import importlib from datetime import datetime +from enum import Enum from functools import partial from typing import ( TYPE_CHECKING, @@ -30,6 +31,55 @@ from pyairtable import orm +class FieldType(str, Enum): + """ + Enumeration of all field types supported by Airtable. + + Usage: + >>> from pyairtable.models.schema import FieldType + >>> FieldType.SINGLE_LINE_TEXT + FieldType('singleLineText') + """ + + AI_TEXT = "aiText" + AUTO_NUMBER = "autoNumber" + BARCODE = "barcode" + BUTTON = "button" + CHECKBOX = "checkbox" + COUNT = "count" + CREATED_BY = "createdBy" + CREATED_TIME = "createdTime" + CURRENCY = "currency" + DATE = "date" + DATE_TIME = "dateTime" + DURATION = "duration" + EMAIL = "email" + EXTERNAL_SYNC_SOURCE = "externalSyncSource" + FORMULA = "formula" + LAST_MODIFIED_BY = "lastModifiedBy" + LAST_MODIFIED_TIME = "lastModifiedTime" + MANUAL_SORT = "manualSort" + MULTILINE_TEXT = "multilineText" + MULTIPLE_ATTACHMENTS = "multipleAttachments" + MULTIPLE_COLLABORATORS = "multipleCollaborators" + MULTIPLE_LOOKUP_VALUES = "multipleLookupValues" + MULTIPLE_RECORD_LINKS = "multipleRecordLinks" + MULTIPLE_SELECTS = "multipleSelects" + NUMBER = "number" + PERCENT = "percent" + PHONE_NUMBER = "phoneNumber" + RATING = "rating" + RICH_TEXT = "richText" + ROLLUP = "rollup" + SINGLE_COLLABORATOR = "singleCollaborator" + SINGLE_LINE_TEXT = "singleLineText" + SINGLE_SELECT = "singleSelect" + URL = "url" + + def __repr__(self) -> str: + return f"FieldType({self.value!r})" + + FieldSpecifier: TypeAlias = Union[str, "orm.fields.AnyField"] _T = TypeVar("_T", bound=Any) @@ -755,7 +805,7 @@ class AITextFieldConfig(AirtableModel): Field configuration for `AI text `__. """ - type: Literal["aiText"] + type: Literal[FieldType.AI_TEXT] options: "AITextFieldOptions" @@ -772,7 +822,7 @@ class AutoNumberFieldConfig(AirtableModel): Field configuration for `Auto number `__. """ - type: Literal["autoNumber"] + type: Literal[FieldType.AUTO_NUMBER] class BarcodeFieldConfig(AirtableModel): @@ -780,7 +830,7 @@ class BarcodeFieldConfig(AirtableModel): Field configuration for `Barcode `__. """ - type: Literal["barcode"] + type: Literal[FieldType.BARCODE] class ButtonFieldConfig(AirtableModel): @@ -788,7 +838,7 @@ class ButtonFieldConfig(AirtableModel): Field configuration for `Button `__. """ - type: Literal["button"] + type: Literal[FieldType.BUTTON] class CheckboxFieldConfig(AirtableModel): @@ -796,7 +846,7 @@ class CheckboxFieldConfig(AirtableModel): Field configuration for `Checkbox `__. """ - type: Literal["checkbox"] + type: Literal[FieldType.CHECKBOX] options: "CheckboxFieldOptions" @@ -810,7 +860,7 @@ class CountFieldConfig(AirtableModel): Field configuration for `Count `__. """ - type: Literal["count"] + type: Literal[FieldType.COUNT] options: "CountFieldOptions" @@ -824,7 +874,7 @@ class CreatedByFieldConfig(AirtableModel): Field configuration for `Created by `__. """ - type: Literal["createdBy"] + type: Literal[FieldType.CREATED_BY] class CreatedTimeFieldConfig(AirtableModel): @@ -832,7 +882,7 @@ class CreatedTimeFieldConfig(AirtableModel): Field configuration for `Created time `__. """ - type: Literal["createdTime"] + type: Literal[FieldType.CREATED_TIME] class CurrencyFieldConfig(AirtableModel): @@ -840,7 +890,7 @@ class CurrencyFieldConfig(AirtableModel): Field configuration for `Currency `__. """ - type: Literal["currency"] + type: Literal[FieldType.CURRENCY] options: "CurrencyFieldOptions" @@ -854,7 +904,7 @@ class DateFieldConfig(AirtableModel): Field configuration for `Date `__. """ - type: Literal["date"] + type: Literal[FieldType.DATE] options: "DateFieldOptions" @@ -867,7 +917,7 @@ class DateTimeFieldConfig(AirtableModel): Field configuration for `Date and time `__. """ - type: Literal["dateTime"] + type: Literal[FieldType.DATE_TIME] options: "DateTimeFieldOptions" @@ -890,7 +940,7 @@ class DurationFieldConfig(AirtableModel): Field configuration for `Duration `__. """ - type: Literal["duration"] + type: Literal[FieldType.DURATION] options: "DurationFieldOptions" @@ -903,7 +953,7 @@ class EmailFieldConfig(AirtableModel): Field configuration for `Email `__. """ - type: Literal["email"] + type: Literal[FieldType.EMAIL] class ExternalSyncSourceFieldConfig(AirtableModel): @@ -911,7 +961,7 @@ class ExternalSyncSourceFieldConfig(AirtableModel): Field configuration for `Sync source `__. """ - type: Literal["externalSyncSource"] + type: Literal[FieldType.EXTERNAL_SYNC_SOURCE] options: "SingleSelectFieldOptions" @@ -920,7 +970,7 @@ class FormulaFieldConfig(AirtableModel): Field configuration for `Formula `__. """ - type: Literal["formula"] + type: Literal[FieldType.FORMULA] options: "FormulaFieldOptions" @@ -936,7 +986,7 @@ class LastModifiedByFieldConfig(AirtableModel): Field configuration for `Last modified by `__. """ - type: Literal["lastModifiedBy"] + type: Literal[FieldType.LAST_MODIFIED_BY] class LastModifiedTimeFieldConfig(AirtableModel): @@ -944,7 +994,7 @@ class LastModifiedTimeFieldConfig(AirtableModel): Field configuration for `Last modified time `__. """ - type: Literal["lastModifiedTime"] + type: Literal[FieldType.LAST_MODIFIED_TIME] options: "LastModifiedTimeFieldOptions" @@ -959,7 +1009,7 @@ class ManualSortFieldConfig(AirtableModel): Field configuration for ``manualSort`` field type (not documented). """ - type: Literal["manualSort"] + type: Literal[FieldType.MANUAL_SORT] class MultilineTextFieldConfig(AirtableModel): @@ -967,7 +1017,7 @@ class MultilineTextFieldConfig(AirtableModel): Field configuration for `Long text `__. """ - type: Literal["multilineText"] + type: Literal[FieldType.MULTILINE_TEXT] class MultipleAttachmentsFieldConfig(AirtableModel): @@ -975,7 +1025,7 @@ class MultipleAttachmentsFieldConfig(AirtableModel): Field configuration for `Attachments `__. """ - type: Literal["multipleAttachments"] + type: Literal[FieldType.MULTIPLE_ATTACHMENTS] options: "MultipleAttachmentsFieldOptions" @@ -992,7 +1042,7 @@ class MultipleCollaboratorsFieldConfig(AirtableModel): Field configuration for `Multiple Collaborators `__. """ - type: Literal["multipleCollaborators"] + type: Literal[FieldType.MULTIPLE_COLLABORATORS] class MultipleLookupValuesFieldConfig(AirtableModel): @@ -1000,7 +1050,7 @@ class MultipleLookupValuesFieldConfig(AirtableModel): Field configuration for `Lookup __`. """ - type: Literal["multipleLookupValues"] + type: Literal[FieldType.MULTIPLE_LOOKUP_VALUES] options: "MultipleLookupValuesFieldOptions" @@ -1016,7 +1066,7 @@ class MultipleRecordLinksFieldConfig(AirtableModel): Field configuration for `Link to another record __`. """ - type: Literal["multipleRecordLinks"] + type: Literal[FieldType.MULTIPLE_RECORD_LINKS] options: "MultipleRecordLinksFieldOptions" @@ -1033,7 +1083,7 @@ class MultipleSelectsFieldConfig(AirtableModel): Field configuration for `Multiple select `__. """ - type: Literal["multipleSelects"] + type: Literal[FieldType.MULTIPLE_SELECTS] options: "SingleSelectFieldOptions" @@ -1042,7 +1092,7 @@ class NumberFieldConfig(AirtableModel): Field configuration for `Number `__. """ - type: Literal["number"] + type: Literal[FieldType.NUMBER] options: "NumberFieldOptions" @@ -1055,7 +1105,7 @@ class PercentFieldConfig(AirtableModel): Field configuration for `Percent `__. """ - type: Literal["percent"] + type: Literal[FieldType.PERCENT] options: "NumberFieldOptions" @@ -1064,7 +1114,7 @@ class PhoneNumberFieldConfig(AirtableModel): Field configuration for `Phone `__. """ - type: Literal["phoneNumber"] + type: Literal[FieldType.PHONE_NUMBER] class RatingFieldConfig(AirtableModel): @@ -1072,7 +1122,7 @@ class RatingFieldConfig(AirtableModel): Field configuration for `Rating `__. """ - type: Literal["rating"] + type: Literal[FieldType.RATING] options: "RatingFieldOptions" @@ -1087,7 +1137,7 @@ class RichTextFieldConfig(AirtableModel): Field configuration for `Rich text `__. """ - type: Literal["richText"] + type: Literal[FieldType.RICH_TEXT] class RollupFieldConfig(AirtableModel): @@ -1095,7 +1145,7 @@ class RollupFieldConfig(AirtableModel): Field configuration for `Rollup __`. """ - type: Literal["rollup"] + type: Literal[FieldType.ROLLUP] options: "RollupFieldOptions" @@ -1112,7 +1162,7 @@ class SingleCollaboratorFieldConfig(AirtableModel): Field configuration for `Collaborator `__. """ - type: Literal["singleCollaborator"] + type: Literal[FieldType.SINGLE_COLLABORATOR] class SingleLineTextFieldConfig(AirtableModel): @@ -1120,7 +1170,7 @@ class SingleLineTextFieldConfig(AirtableModel): Field configuration for `Single line text `__. """ - type: Literal["singleLineText"] + type: Literal[FieldType.SINGLE_LINE_TEXT] class SingleSelectFieldConfig(AirtableModel): @@ -1128,7 +1178,7 @@ class SingleSelectFieldConfig(AirtableModel): Field configuration for `Single select `__. """ - type: Literal["singleSelect"] + type: Literal[FieldType.SINGLE_SELECT] options: "SingleSelectFieldOptions" @@ -1146,7 +1196,7 @@ class UrlFieldConfig(AirtableModel): Field configuration for `Url `__. """ - type: Literal["url"] + type: Literal[FieldType.URL] class UnknownFieldConfig(AirtableModel): diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py index a41def84..2a48e2ca 100644 --- a/pyairtable/orm/fields.py +++ b/pyairtable/orm/fields.py @@ -69,6 +69,7 @@ UnsavedRecordError, ) from pyairtable.models import schema as S +from pyairtable.models.schema import FieldType from pyairtable.orm.lists import AttachmentsList, ChangeTrackingList if TYPE_CHECKING: @@ -1410,43 +1411,46 @@ class RequiredUrlField(UrlField, _Requires[str]): #: for the ORM to know or detect those fields' types. These two #: field type names are mapped to the constant ``NotImplemented``. #: +#: Keys are :class:`~pyairtable.models.schema.FieldType` enum values, +#: which inherit from ``str`` and can be used in string comparisons. +#: #: :meta hide-value: FIELD_TYPES_TO_CLASSES: Dict[str, Type[AnyField]] = { - "aiText": AITextField, - "autoNumber": AutoNumberField, - "barcode": BarcodeField, - "button": ButtonField, - "checkbox": CheckboxField, - "count": CountField, - "createdBy": CreatedByField, - "createdTime": CreatedTimeField, - "currency": CurrencyField, - "date": DateField, - "dateTime": DatetimeField, - "duration": DurationField, - "email": EmailField, - "externalSyncSource": ExternalSyncSourceField, - "formula": NotImplemented, - "lastModifiedBy": LastModifiedByField, - "lastModifiedTime": LastModifiedTimeField, - "lookup": LookupField, - "manualSort": ManualSortField, - "multilineText": TextField, - "multipleAttachments": AttachmentsField, - "multipleCollaborators": MultipleCollaboratorsField, - "multipleLookupValues": LookupField, - "multipleRecordLinks": LinkField, - "multipleSelects": MultipleSelectField, - "number": NumberField, - "percent": PercentField, - "phoneNumber": PhoneNumberField, - "rating": RatingField, - "richText": RichTextField, - "rollup": NotImplemented, - "singleCollaborator": CollaboratorField, - "singleLineText": TextField, - "singleSelect": SelectField, - "url": UrlField, + FieldType.AI_TEXT: AITextField, + FieldType.AUTO_NUMBER: AutoNumberField, + FieldType.BARCODE: BarcodeField, + FieldType.BUTTON: ButtonField, + FieldType.CHECKBOX: CheckboxField, + FieldType.COUNT: CountField, + FieldType.CREATED_BY: CreatedByField, + FieldType.CREATED_TIME: CreatedTimeField, + FieldType.CURRENCY: CurrencyField, + FieldType.DATE: DateField, + FieldType.DATE_TIME: DatetimeField, + FieldType.DURATION: DurationField, + FieldType.EMAIL: EmailField, + FieldType.EXTERNAL_SYNC_SOURCE: ExternalSyncSourceField, + FieldType.FORMULA: NotImplemented, + FieldType.LAST_MODIFIED_BY: LastModifiedByField, + FieldType.LAST_MODIFIED_TIME: LastModifiedTimeField, + "lookup": LookupField, # Deprecated alias for multipleLookupValues + FieldType.MANUAL_SORT: ManualSortField, + FieldType.MULTILINE_TEXT: TextField, + FieldType.MULTIPLE_ATTACHMENTS: AttachmentsField, + FieldType.MULTIPLE_COLLABORATORS: MultipleCollaboratorsField, + FieldType.MULTIPLE_LOOKUP_VALUES: LookupField, + FieldType.MULTIPLE_RECORD_LINKS: LinkField, + FieldType.MULTIPLE_SELECTS: MultipleSelectField, + FieldType.NUMBER: NumberField, + FieldType.PERCENT: PercentField, + FieldType.PHONE_NUMBER: PhoneNumberField, + FieldType.RATING: RatingField, + FieldType.RICH_TEXT: RichTextField, + FieldType.ROLLUP: NotImplemented, + FieldType.SINGLE_COLLABORATOR: CollaboratorField, + FieldType.SINGLE_LINE_TEXT: TextField, + FieldType.SINGLE_SELECT: SelectField, + FieldType.URL: UrlField, } diff --git a/pyairtable/orm/generate.py b/pyairtable/orm/generate.py index a1e89cf9..3d6d0673 100644 --- a/pyairtable/orm/generate.py +++ b/pyairtable/orm/generate.py @@ -19,6 +19,7 @@ from pyairtable.api.base import Base from pyairtable.api.table import Table from pyairtable.models import schema as S +from pyairtable.models.schema import FieldType from pyairtable.orm import fields _ANNOTATION_IMPORTS = { @@ -165,7 +166,7 @@ def __str__(self) -> str: if cls is fields._ListField: generic = "str" - if self.schema.type in ("formula", "rollup"): + if self.schema.type in (FieldType.FORMULA, FieldType.ROLLUP): assert isinstance(self.schema, (S.FormulaFieldSchema, S.RollupFieldSchema)) cls = fields.Field if self.schema.options.result: @@ -209,7 +210,7 @@ def lookup_field_type_annotation(schema: S.MultipleLookupValuesFieldSchema) -> s if not schema.options.result: return "Any" lookup_type = schema.options.result.type - if lookup_type == "multipleRecordLinks": + if lookup_type == FieldType.MULTIPLE_RECORD_LINKS: return "str" # otherwise this will be 'list' cls = fields.FIELD_TYPES_TO_CLASSES[lookup_type] if isinstance(contained_type := getattr(cls, "contains_type", None), type): diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py index b5b0ca82..e909c337 100644 --- a/tests/test_models_schema.py +++ b/tests/test_models_schema.py @@ -542,3 +542,53 @@ def test_save_date_dependency_settings__invalid_field(table_schema): predecessor_field="invalid_field", rescheduling_mode="none", ) + + +def test_field_type_enum(): + """ + Test that FieldType enum contains all expected field types. + """ + # Test that enum inherits from str + assert isinstance(schema.FieldType.SINGLE_LINE_TEXT, str) + + # Test that enum can be used in string comparisons + assert schema.FieldType.SINGLE_LINE_TEXT == "singleLineText" + + # Test that all field config types have corresponding enum values + expected_types = { + "aiText", + "autoNumber", + "barcode", + "button", + "checkbox", + "count", + "createdBy", + "createdTime", + "currency", + "date", + "dateTime", + "duration", + "email", + "externalSyncSource", + "formula", + "lastModifiedBy", + "lastModifiedTime", + "manualSort", + "multilineText", + "multipleAttachments", + "multipleCollaborators", + "multipleLookupValues", + "multipleRecordLinks", + "multipleSelects", + "number", + "percent", + "phoneNumber", + "rating", + "richText", + "rollup", + "singleCollaborator", + "singleLineText", + "singleSelect", + "url", + } + assert expected_types == {member.value for member in schema.FieldType} From f71770fbd79254894164209a5b4c62164fe444b9 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 4 Nov 2025 14:09:30 -0800 Subject: [PATCH 270/272] Release 3.3.0 --- docs/source/changelog.rst | 10 +++++++++- pyairtable/__init__.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index e43483e4..71e707d6 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -2,10 +2,18 @@ Changelog ========= -3.3.0 (TBA) +3.3.0 (2025-11-05) ------------------------ +* Added ``count_comments=`` parameter to ``Table.all`` and ``Table.first``. + - `PR #441 `_ +* Added support for `Create Workspace `_ + via :meth:`Enterprise.create_workspace `. + - `PR #442 `_ * Added support for Python 3.14 and dropped support for Python 3.9. + - `PR #443 `_ +* Added pyairtable.models.schema.FieldType enum. + - `PR #444 `_ 3.2.0 (2025-08-17) ------------------------ diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 973388bd..8c4e633a 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.2.0" +__version__ = "3.3.0" from pyairtable.api import Api, Base, Table from pyairtable.api.enterprise import Enterprise From 5c64da4f61bf7f92e5ce0db9e4b4f3b21b245aa7 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Tue, 13 Jan 2026 20:57:14 -0800 Subject: [PATCH 271/272] Initial version of AGENTS.md --- AGENTS.md | 19 +++++++++++++++++++ README.md | 16 ++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7e31e585 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# AGENTS.md + +Read all developer instructions and contribution guidelines in @README.md. + +## Testing your changes + +When changing code in `pyairtable/`, follow these steps one at a time: + +1. `tox -e mypy` +2. `tox -e py314 -- $CHANGED_FILE $CORRESPONDING_TEST_FILE` +3. `tox -e coverage` +4. `make lint` +5. `make docs` +6. `make test` + +When changing code in `docs/`, follow these steps instead: + +1. `make docs` +2. `make lint` diff --git a/README.md b/README.md index 5d748c63..46b512d1 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,14 @@ Everyone who has an idea or suggestion is welcome to contribute! As maintainers, If it's your first time working on this library, clone the repo, set up pre-commit hooks, and make sure you can run tests (and they pass). If that doesn't work out of the box, please check your local development environment before filing an issue. ```sh -% make setup -% make test +make setup +make test +``` + +For a quick test run (~15s after the environments are created) use: + +```sh +tox -e mypy && tox -e coverage ``` ### Reporting a bug @@ -50,8 +56,10 @@ Anyone who uses this library is welcome to [submit a pull request](https://githu 1. Public functions/methods have docstrings and type annotations. 2. New functionality is accompanied by clear, descriptive unit tests. -3. You can run `make test && make docs` successfully. -4. You have [signed your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification). +3. The library maintains 100% test coverage. +4. You can run `make test && make docs` successfully. +5. No backwards-incompatible changes (unless discussed in an existing issue). +6. You have [signed your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification). If you want to discuss an idea you're working on but haven't yet finished all of the above, please [open a draft pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests). That will be a clear signal that you're not asking to merge your code (yet) and are just looking for discussion or feedback. From 33c5bf964f2d267859ca9b747b294a7757b3b41b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:23:04 +0000 Subject: [PATCH 272/272] Bump wheel from 0.38.1 to 0.46.2 Bumps [wheel](https://github.com/pypa/wheel) from 0.38.1 to 0.46.2. - [Release notes](https://github.com/pypa/wheel/releases) - [Changelog](https://github.com/pypa/wheel/blob/main/docs/news.rst) - [Commits](https://github.com/pypa/wheel/compare/0.38.1...0.46.2) --- updated-dependencies: - dependency-name: wheel dependency-version: 0.46.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7c5bc7d0..777a8e71 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.5 # Packaging -wheel==0.38.1 +wheel==0.46.2 twine==3.3.0 build==0.6.0.post1