Repository: gtalarico/pyairtable (python)

Issue:
ORM specify configuration and API key outside the code
Hi,
I have a simple ORM Model and of course I dont want to place any API keys inside the code, and I want to make those settings configurable anyways (as I have special testing and live databases on airtable).

```py
class AirtableEvent(airtable.Model):
    title = airtable.fields.TextField("Titel")
    description = airtable.fields.TextField("Beschreibung")
    start = airtable.fields.DatetimeField("Start")
    end = airtable.fields.DatetimeField("Ende")
    url = airtable.fields.TextField("Url")
    address = airtable.fields.TextField("Adresse")
    location = airtable.fields.TextField("Ort")
    status = airtable.fields.TextField("Status")
    recurring = airtable.fields.CheckboxField("Wiederkehrend")
    source = airtable.fields.TextField("Quelle")
    google_calendar_meta = airtable.fields.TextField("Google Calendar Meta")
    google_uid_meta = airtable.fields.TextField("Google Uid Meta")
    google_sequence_meta = airtable.fields.IntegerField("Google Sequence Meta")
    trash = airtable.fields.CheckboxField("Papierkorb")

    class Meta:
        # This must be loaded from the configuration
        base_id = None
        table_name = None
        api_key = None

    @classmethod
    def load_json_config(cls, api_key, config):
        cls.Meta.base_id=config["base_id"]
        cls.Meta.table_name=config["table_id"]
        cls.Meta.api_key=api_key
```

Now if you use the class as it is right now, it will fail with `ValueError: Meta.base_id must be defined in model`

I can surpress this by setting it to a non-empty string and set those metadata afterwards. But how can we do this in a more proper way? I am searching for a good solution, do you have any idea?

Pull request description:
Allow ORM Meta options to be lookups at runtime (not import time)
This allows the `Meta` class inside each ORM model to provide a callable that will be executed whenever the ORM requests one of its meta properties. The use case we have is to be able to provide API keys via global configuration singletons or environment variables which might not be loaded at import time but will be present at runtime.

Resolves #192 

Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index fb2aedf..376781c 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -20,6 +20,8 @@ See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
   - `PR #273 <https://github.com/gtalarico/pyairtable/pull/273>`_.
 * pyAirtable will automatically retry requests when throttled by Airtable's QPS.
   - `PR #272 <https://github.com/gtalarico/pyairtable/pull/272>`_.
+* ORM Meta attributes can now be defined as callables.
+  - `PR #268 <https://github.com/gtalarico/pyairtable/pull/268>`_.
 * Removed ``ApiAbstract``.
   - `PR #267 <https://github.com/gtalarico/pyairtable/pull/267>`_.
 * Implemented strict type annotations on all functions and methods.
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 61b59c6..f285daf 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -20,9 +20,9 @@ from pyairtable.orm.fields import AnyField, Field
 
 class Model:
     """
-    This class allows you create an orm-style class for your Airtable tables.
+    This class allows you create an ORM-style class for your Airtable tables.
 
-    This is a meta class and can only be used to define sub-classes.
+    This is a metaclass and can only be used to define sub-classes.
 
     The ``Meta`` is reuired and must specify all three attributes: ``base_id``,
     ``table_id``, and ``api_key``.
@@ -38,6 +38,22 @@ class Model:
     ...         api_key = "keyapikey"
     ...         timeout: Optional[Tuple[int, int]] = (5, 5)
     ...         typecast: bool = True
+
+    You can implement meta attributes as callables if certain values
+    need to be dynamically provided or are unavailable at import time:
+
+    >>> 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"
+    ...
+    ...         @staticmethod
+    ...         def api_key():
+    ...             return os.environ["AIRTABLE_API_KEY"]
     """
 
     id: str = ""
@@ -125,6 +141,8 @@ class Model:
         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):
+            value = value()
         if required and value is None:
             raise ValueError(f"{cls.__name__}.Meta.{name} cannot be None")
         return value


Test patch:
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index f2c4831..832a7cb 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -1,3 +1,4 @@
+from functools import partial
 from unittest import mock
 
 import pytest
@@ -118,3 +119,29 @@ def test_from_ids(mock_all):
     with pytest.raises(KeyError):
         FakeModel.from_ids(fake_ids + ["recDefinitelyNotValid"])
     mock_all.assert_called_once()
+
+
+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.
+    """
+    data = {
+        "api_key": "FakeApiKey",
+        "base_id": "appFakeBaseId",
+        "table_name": "tblFakeTableName",
+    }
+
+    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"]
+
+    f = Fake()
+    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"]