This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e99f9af45a8 Add iter_paginated_run to KiotaRequestAdapterHook (#75649)
e99f9af45a8 is described below

commit e99f9af45a8d3286e29418d337c3b598ae286b53
Author: David Blain <[email protected]>
AuthorDate: Thu Nov 12 17:09:05 2026 +0100

    Add iter_paginated_run to KiotaRequestAdapterHook (#75649)
    
    * Add iter_paginated_run to KiotaRequestAdapterHook
    
    paginated_run collects every page of a Microsoft Graph request in one
    list before it returns, so a task that extracts a large collection (all
    users of a tenant, their group memberships) holds the whole dataset in
    memory even when it only writes each page away.
    
    iter_paginated_run yields the pages one at a time, with the same
    pagination function and the same allowed-host check on every link, and
    paginated_run now collects what it yields, so there is a single
    pagination loop. The pagination function of paginated_run keeps
    receiving the pages collected so far; the generator has nothing to hand
    it, since it keeps no page it already yielded.
    
    We have run this as a monkey patch in production Dags that stream
    Entra ID objects to S3 page by page.
    
    * Resolve the next page before iter_paginated_run yields the current one
    
    The generator yielded a page and read it again afterwards to resolve the
    next request, so a consumer that changed the page (popping ``value`` after
    writing its items, for instance) made ``default_pagination`` end a counted
    query after the first request, silently.
    
    The next page is now resolved and host-checked before the page is yielded,
    so a yielded page belongs to the consumer and is never read again.
    ``paginated_run`` collects the page inside its pagination wrapper, which
    keeps ``responses()`` including the page being paginated. A cross-host link
    therefore raises before the page that carried it is yielded.
    
    Tests: a consumer that empties every page still receives every item, a
    custom ``pagination_function`` receives the expected arguments, and one
    that declares ``responses`` is rejected by ``iter_paginated_run``.
---
 .../providers/microsoft/azure/hooks/msgraph.py     | 120 +++++++++++------
 .../unit/microsoft/azure/hooks/test_msgraph.py     | 148 +++++++++++++++++++++
 2 files changed, 229 insertions(+), 39 deletions(-)

diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
index adbf499e305..b0e62b85d45 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
@@ -33,7 +33,7 @@ import re
 import warnings
 from ast import literal_eval
 from base64 import b64encode
-from collections.abc import Callable
+from collections.abc import AsyncIterator, Callable
 from contextlib import suppress
 from email.utils import parseaddr
 from http import HTTPStatus
@@ -676,6 +676,60 @@ class KiotaRequestAdapterHook(BaseHook):
                 f"Microsoft Graph endpoints {sorted(allowed_netlocs)}."
             )
 
+    async def iter_paginated_run(
+        self,
+        url: str = "",
+        response_type: str | None = None,
+        path_parameters: dict[str, Any] | None = None,
+        method: str = "GET",
+        query_parameters: dict[str, Any] | None = None,
+        headers: dict[str, str] | None = None,
+        data: dict[str, Any] | str | BytesIO | None = None,
+        pagination_function: PaginationCallable | None = None,
+    ) -> AsyncIterator[Any]:
+        """
+        Yield the pages of a paginated request one at a time.
+
+        The caller handles each page before the next one is requested, so a 
collection of any size passes
+        through in page-sized steps instead of the single list 
``paginated_run`` builds. The next page is
+        resolved by ``pagination_function`` (``default_pagination`` by 
default: ``@odata.nextLink``, or
+        ``$skip`` on counted queries) and its host is checked with 
``assert_allowed_host`` before the page
+        is yielded, so a yielded page is the caller's to change: it is not 
read again. ``pagination_function``
+        receives the same arguments as in ``paginated_run`` except 
``responses``: nothing keeps the pages
+        already yielded.
+        """
+        if pagination_function is None:
+            pagination_function = self.default_pagination
+
+        while url:
+            response = await self.run(
+                url=url,
+                response_type=response_type,
+                path_parameters=path_parameters,
+                method=method,
+                query_parameters=query_parameters,
+                headers=headers,
+                data=data,
+            )
+
+            if not response:
+                return
+
+            url, query_parameters = execute_callable(
+                pagination_function,
+                response=response,
+                url=url,
+                response_type=response_type,
+                path_parameters=path_parameters,
+                method=method,
+                query_parameters=query_parameters,
+                headers=headers,
+                data=data,
+            )
+            await self.assert_allowed_host(url)
+
+            yield response
+
     async def paginated_run(
         self,
         url: str = "",
@@ -686,49 +740,37 @@ class KiotaRequestAdapterHook(BaseHook):
         headers: dict[str, str] | None = None,
         data: dict[str, Any] | str | BytesIO | None = None,
         pagination_function: PaginationCallable | None = None,
-    ):
+    ) -> list[Any]:
+        """
+        Collect the pages of a paginated request in a list.
+
+        The pages come from ``iter_paginated_run``; use that directly when the 
collection should be handled
+        page by page. ``pagination_function`` additionally receives 
``responses``, a callable returning the
+        pages collected so far, the one being paginated included.
+        """
         if pagination_function is None:
             pagination_function = self.default_pagination
 
-        responses: list[dict] = []
+        responses: list[Any] = []
 
-        async def run(
-            url: str = "",
-            query_parameters: dict[str, Any] | None = None,
-        ):
-            while url:
-                response = await self.run(
-                    url=url,
-                    response_type=response_type,
-                    path_parameters=path_parameters,
-                    method=method,
-                    query_parameters=query_parameters,
-                    headers=headers,
-                    data=data,
-                )
+        def paginate(response: Any, **kwargs: Any) -> tuple[str, dict[str, 
Any] | None]:
+            # Called before the page is yielded, so the page is collected here 
for ``responses`` to include it.
+            responses.append(response)
+            return execute_callable(
+                pagination_function, response=response, responses=lambda: 
responses, **kwargs
+            )
 
-                if response:
-                    responses.append(response)
-
-                    if pagination_function:
-                        next_url, query_parameters = execute_callable(
-                            pagination_function,
-                            response=response,
-                            url=url,
-                            response_type=response_type,
-                            path_parameters=path_parameters,
-                            method=method,
-                            query_parameters=query_parameters,
-                            headers=headers,
-                            data=data,
-                            responses=lambda: responses,
-                        )
-                        await self.assert_allowed_host(next_url)
-                        url = next_url
-                else:
-                    break
-
-        await run(url=url, query_parameters=query_parameters)
+        async for _ in self.iter_paginated_run(
+            url=url,
+            response_type=response_type,
+            path_parameters=path_parameters,
+            method=method,
+            query_parameters=query_parameters,
+            headers=headers,
+            data=data,
+            pagination_function=paginate,
+        ):
+            pass
 
         return responses
 
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
index 27e4e0ca06e..bc46d6e2495 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
@@ -504,6 +504,154 @@ class TestKiotaRequestAdapterHook:
             # token is never sent to the attacker host.
             assert mock_get_http_response.call_count == 1
 
+    @pytest.mark.asyncio
+    async def 
test_paginated_run_hands_the_pages_collected_so_far_to_the_pagination_function(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        next_users = load_json_from_resources(dirname(__file__), "..", 
"resources", "next_users.json")
+        response = mock_json_response(200, users, next_users)
+        collected: list[list] = []
+
+        def pagination_function(response, responses):
+            collected.append(list(responses()))
+            return response.get("@odata.nextLink"), None
+
+        with patch_hook_and_request_adapter(response):
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            actual = await hook.paginated_run(url="users", 
pagination_function=pagination_function)
+
+        assert actual == [users, next_users]
+        assert collected == [[users], [users, next_users]]
+
+    @pytest.mark.asyncio
+    async def test_iter_paginated_run(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        next_users = load_json_from_resources(dirname(__file__), "..", 
"resources", "next_users.json")
+        response = mock_json_response(200, users, next_users)
+
+        with patch_hook_and_request_adapter(response):
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            actual = [page async for page in 
hook.iter_paginated_run(url="users")]
+
+            assert actual == [users, next_users]
+
+    @pytest.mark.asyncio
+    async def 
test_iter_paginated_run_requests_the_next_page_only_when_asked(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        next_users = load_json_from_resources(dirname(__file__), "..", 
"resources", "next_users.json")
+        response = mock_json_response(200, users, next_users)
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+            pages = hook.iter_paginated_run(url="users")
+
+            assert await anext(pages) == users
+            assert mock_get_http_response.call_count == 1
+
+            assert await anext(pages) == next_users
+            assert mock_get_http_response.call_count == 2
+
+            await pages.aclose()
+
+    @pytest.mark.asyncio
+    async def test_iter_paginated_run_stops_on_an_empty_response(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        response = mock_json_response(200, users, None)
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            actual = [page async for page in 
hook.iter_paginated_run(url="users")]
+
+            assert actual == [users]
+            assert mock_get_http_response.call_count == 2
+
+    @pytest.mark.asyncio
+    async def test_iter_paginated_run_refuses_cross_host_next_link(self):
+        first_page = {
+            "@odata.nextLink": 
"https://attacker.example/v1.0/users?$skiptoken=steal";,
+            "value": [{"id": "1"}],
+        }
+        response = mock_json_response(200, first_page)
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            with pytest.raises(ValueError, match="attacker.example"):
+                [page async for page in hook.iter_paginated_run(url="users")]
+
+            # The off-host pagination link is refused before it is fetched, so 
the bearer
+            # token is never sent to the attacker host.
+            assert mock_get_http_response.call_count == 1
+
+    @pytest.mark.asyncio
+    async def test_iter_paginated_run_does_not_read_a_yielded_page_again(self):
+        first_page = {"@odata.count": 4, "value": [{"id": "1"}, {"id": "2"}]}
+        second_page = {"@odata.count": 4, "value": [{"id": "3"}, {"id": "4"}]}
+        response = mock_json_response(200, first_page, second_page, None)
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+            ids = []
+
+            async for page in hook.iter_paginated_run(url="users", 
query_parameters={"$top": 2}):
+                # The consumer owns the page once it is yielded: emptying it 
must not end the pagination,
+                # which ``default_pagination`` decides from ``value`` and 
``@odata.count``.
+                ids.extend(item["id"] for item in page.pop("value"))
+
+            assert ids == ["1", "2", "3", "4"]
+            assert mock_get_http_response.call_count == 3
+
+    @pytest.mark.asyncio
+    async def test_iter_paginated_run_with_a_custom_pagination_function(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+        response = mock_json_response(200, users, users)
+        seen: list[tuple[str, dict | None]] = []
+
+        def skip_once(response, url, query_parameters):
+            seen.append((url, query_parameters))
+            if query_parameters and query_parameters.get("$skip"):
+                return None, query_parameters
+            return url, {"$top": 1, "$skip": 1}
+
+        with patch_hook_and_request_adapter(response) as mocks:
+            mock_get_http_response = mocks[-1]
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            actual = [
+                page
+                async for page in hook.iter_paginated_run(
+                    url="users", query_parameters={"$top": 1}, 
pagination_function=skip_once
+                )
+            ]
+
+            assert actual == [users, users]
+            assert seen == [("users", {"$top": 1}), ("users", {"$top": 1, 
"$skip": 1})]
+            assert mock_get_http_response.call_count == 2
+
+    @pytest.mark.asyncio
+    async def 
test_iter_paginated_run_does_not_pass_responses_to_the_pagination_function(self):
+        users = load_json_from_resources(dirname(__file__), "..", "resources", 
"users.json")
+
+        def needs_responses(response, responses):
+            return None, None
+
+        with patch_hook_and_request_adapter(mock_json_response(200, users)):
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            with pytest.raises(TypeError, match="responses"):
+                [
+                    page
+                    async for page in hook.iter_paginated_run(
+                        url="users", pagination_function=needs_responses
+                    )
+                ]
+
     @pytest.mark.asyncio
     async def test_assert_allowed_host_refuses_another_host(self):
         with patch_hook_and_request_adapter(mock_json_response(200, {})):

Reply via email to