diff --git a/docs/source/api.rst b/docs/source/api.rst index 2c0d9baa..fcfa1aa0 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,19 +64,28 @@ 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: + + +API: pyairtable.models.webhook +------------------------------- + +.. automodule:: pyairtable.models.webhook + :members: + :exclude-members: Webhook, WebhookNotification, WebhookPayload :inherited-members: AirtableModel @@ -81,6 +105,13 @@ API: pyairtable.orm.fields :no-inherited-members: +API: pyairtable.testing +******************************* + +.. automodule:: pyairtable.testing + :members: + + API: pyairtable.utils ******************************* 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/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/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/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/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", 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/pyairtable/models/record.py b/pyairtable/models/record.py deleted file mode 100644 index e69de29b..00000000 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 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/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_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_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( 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"})