Repository: gtalarico/pyairtable
Pull request #358 (2024-03-22 17:43:36)

Release 2.3.3
I cherry-picked #356 and #357 so we can get those bugfixes out. This diff is against 2.3.2, since the `main` branch already includes a bunch of breaking changes for 3.0. If we need to do more point releases for 2.x, we can use the `release-2.x` branch as a merge target.


Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index baebf6a..e990f8d 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -2,6 +2,14 @@
 Changelog
 =========
 
+2.3.3 (2024-03-22)
+------------------------
+
+* Fixed a bug affecting ORM Meta values which are computed at runtime.
+  - `PR #357 <https://github.com/gtalarico/pyairtable/pull/357>`_.
+* Fixed documentation for the ORM module.
+  - `PR #356 <https://github.com/gtalarico/pyairtable/pull/356>`_.
+
 2.3.2 (2024-03-18)
 ------------------------
 
diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py
index cad16f9..e34077c 100644
--- a/pyairtable/__init__.py
+++ b/pyairtable/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "2.3.2"
+__version__ = "2.3.3"
 
 from .api import Api, Base, Table
 from .api.enterprise import Enterprise
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index e3b9f49..2c5cc9d 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -826,7 +826,7 @@ READONLY_FIELDS = {cls for cls in ALL_FIELDS if cls.readonly}
 
 #: 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
@@ -881,25 +881,27 @@ FIELD_CLASSES_TO_TYPES = {
 
 
 # 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]]]"""
+#
+# [[[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__ = [
+    "Field",
     "AITextField",
     "AttachmentsField",
     "AutoNumberField",
@@ -916,12 +918,12 @@ __all__ = [
     "DurationField",
     "EmailField",
     "ExternalSyncSourceField",
+    "Field",
     "FloatField",
     "IntegerField",
     "LastModifiedByField",
     "LastModifiedTimeField",
     "LinkField",
-    "LinkSelf",
     "LookupField",
     "MultipleCollaboratorsField",
     "MultipleSelectField",
@@ -933,5 +935,10 @@ __all__ = [
     "SelectField",
     "TextField",
     "UrlField",
+    "ALL_FIELDS",
+    "READONLY_FIELDS",
+    "FIELD_TYPES_TO_CLASSES",
+    "FIELD_CLASSES_TO_TYPES",
+    "LinkSelf",
 ]
-# [[[end]]] (checksum: 4722c0951e598ac999d3c16ebd3d8c1c)
+# [[[end]]] (checksum: 2aa36f4e76db73f3d0b741b6be6c9e9e)
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index f3e752c..0dd3222 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -142,13 +142,25 @@ class Model:
             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 @@ class Model:
 
     @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 @@ class Model:
     @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:


Test patch:
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index 3f06930..92b2164 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -51,6 +51,22 @@ def test_model_empty_meta():
             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):
     """
@@ -176,7 +192,8 @@ def test_from_ids__no_fetch(mock_all):
 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",
@@ -188,12 +205,12 @@ def test_dynamic_model_meta():
         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()