Skip to content

Commit

Permalink
Simplify missing_value implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
mesozoic committed Apr 2, 2024
1 parent d395ca7 commit 9948c80
Show file tree
Hide file tree
Showing 3 changed files with 27 additions and 85 deletions.
58 changes: 22 additions & 36 deletions pyairtable/orm/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -314,14 +295,15 @@ 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]


#: An alias for any type of Field.
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.
Expand All @@ -330,6 +312,7 @@ class TextField(_FieldWithTypedDefaultValue[str]):
and `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
"""

missing_value = ""
valid_types = str


Expand Down Expand Up @@ -394,14 +377,15 @@ 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.
See `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__.
"""

missing_value = False
valid_types = bool


Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion tests/test_orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
52 changes: 4 additions & 48 deletions tests/test_orm_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def test_repr(instance, expected):
argvalues=[
(f.Field, None),
(f.CheckboxField, False),
(f.TextField, ""),
(f.LookupField, []),
(f.AttachmentsField, []),
(f.MultipleCollaboratorsField, []),
Expand All @@ -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 = {
Expand Down Expand Up @@ -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",
[
Expand Down

0 comments on commit 9948c80

Please sign in to comment.