Repository: gtalarico/pyairtable (python)

Issue:
enterprise.info() no longer works
With the API changes made today to the Enterprise API, the `enterprise.info()` function no longer works, since the `include` options have been changed in a backward incompatible way: https://airtable.com/developers/web/api/get-enterprise

Changelog: https://airtable.com/developers/web/api/changelog#anchor-2024-11-11

Pull request description:
Fix enterprise.info and enterprise.users after new API params
This adds support for the new `include=` params [announced today](https://airtable.com/developers/web/api/changelog#anchor-2024-11-11) for [Get Enterprise](https://airtable.com/developers/web/api/get-enterprise) and also fixes a bug where we were sending unsupported params to that same endpoint.

Fixes #404 

Patch:
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index 7fedcfd..d897129 100644
--- a/pyairtable/api/enterprise.py
+++ b/pyairtable/api/enterprise.py
@@ -81,11 +81,26 @@ class Enterprise:
         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 <https://airtable.com/developers/web/api/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 @@ class Enterprise:
         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 @@ class Enterprise:
             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 @@ class Enterprise:
                 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 4ac071b..c7155ba 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 4b6e66f..a08d025 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 2988d7f..b82d7d6 100644
--- a/scripts/find_model_changes.py
+++ b/scripts/find_model_changes.py
@@ -38,7 +38,7 @@ SCAN_MODELS = {
     "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 @@ SCAN_MODELS = {
     "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 @@ SCAN_MODELS = {
     "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 @@ SCAN_MODELS = {
     "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 @@ class ApiData(Dict[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]]:


Test patch:
diff --git a/tests/sample_data/EnterpriseInfo.json b/tests/sample_data/EnterpriseInfo.json
index 4ff1caa..bd84828 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 359dee0..71bf12f 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 86555b4..f202039 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