diff --git a/src/msgraph_core/models/page_result.py b/src/msgraph_core/models/page_result.py index b11bc965..4355694a 100644 --- a/src/msgraph_core/models/page_result.py +++ b/src/msgraph_core/models/page_result.py @@ -26,6 +26,7 @@ class PageResult(Parsable): odata_next_link: Optional[str] = None value: Optional[list[Parsable]] = None + odata_delta_link: Optional[str] = None @staticmethod def create_from_discriminator_value(parse_node: Optional[ParseNode] = None) -> PageResult: @@ -49,6 +50,8 @@ def get_field_deserializers(self) -> dict[str, Callable[[ParseNode], None]]: return { "@odata.nextLink": lambda x: setattr(self, "odata_next_link", x.get_str_value()), + "@odata.deltaLink": + lambda x: setattr(self, "odata_delta_link", x.get_str_value()), "value": lambda x: setattr( self, @@ -69,4 +72,5 @@ def serialize(self, writer: SerializationWriter) -> None: if not writer: raise TypeError("Writer cannot be null") writer.write_str_value("@odata.nextLink", self.odata_next_link) + writer.write_str_value("@odata.deltaLink", self.odata_delta_link) writer.write_collection_of_object_values("value", self.value) diff --git a/src/msgraph_core/tasks/page_iterator.py b/src/msgraph_core/tasks/page_iterator.py index c7efd1c9..7e3219de 100644 --- a/src/msgraph_core/tasks/page_iterator.py +++ b/src/msgraph_core/tasks/page_iterator.py @@ -32,6 +32,8 @@ T = TypeVar('T', bound=Parsable) +ODATA_DELTA_LINK_KEY = '@odata.deltaLink' + class PageIterator: """ @@ -83,9 +85,7 @@ def __init__( self._next_link = response.get('odata_next_link', '') if isinstance( response, dict ) else getattr(response, 'odata_next_link', '') - self._delta_link = response.get('@odata.deltaLink', '') if isinstance( - response, dict - ) else getattr(response, '@odata.deltaLink', '') + self._delta_link = self._extract_delta_link(response) if page is not None: self.current_page = page @@ -151,8 +151,31 @@ async def next(self) -> Optional[PageResult]: next_link = response.odata_next_link if response and hasattr( response, 'odata_next_link' ) else None + delta_link = self._extract_delta_link(response) if response else None + if delta_link: + self._delta_link = delta_link value = response.value if response and hasattr(response, 'value') else None - return PageResult(next_link, value) + return PageResult(odata_next_link=next_link, value=value) + + @staticmethod + def _extract_delta_link(response: T | dict | object) -> str: + """ + Extracts the '@odata.deltaLink' from a response. + Checks the additional data bag first (for models that do not + explicitly declare the field), then falls back to the typed + 'odata_delta_link' attribute. + Args: + response (Union[T, dict, object]): The response to extract the + delta link from. + Returns: + str: The delta link, or an empty string if none is present. + """ + if isinstance(response, dict): + return response.get(ODATA_DELTA_LINK_KEY, '') + additional_data = getattr(response, 'additional_data', None) + if additional_data and additional_data.get(ODATA_DELTA_LINK_KEY): + return additional_data.get(ODATA_DELTA_LINK_KEY) + return getattr(response, 'odata_delta_link', '') @staticmethod def convert_to_page(response: Union[T, list, object]) -> PageResult: @@ -184,7 +207,7 @@ def convert_to_page(response: Union[T, list, object]) -> PageResult: parsable_page, dict ) else getattr(parsable_page, 'odata_next_link', '') - return PageResult(next_link, value) + return PageResult(odata_next_link=next_link, value=value) async def fetch_next_page(self) -> Optional[Union[T, PageResult]]: """ diff --git a/tests/tasks/test_page_iterator.py b/tests/tasks/test_page_iterator.py index 02f41f1f..a305186f 100644 --- a/tests/tasks/test_page_iterator.py +++ b/tests/tasks/test_page_iterator.py @@ -1,4 +1,5 @@ import os +from types import SimpleNamespace from unittest.mock import AsyncMock, patch, Mock import pytest @@ -100,6 +101,56 @@ def test_convert_to_page(first_page_data): # pylint: disable=redefined-outer-na assert first_page_data['@odata.next_link'] == first_page.odata_next_link +@pytest.mark.asyncio +async def test_delta_link_updated_from_final_page(): + """Reproduces the bug where the delta link from the last page of a + multi-page delta sync was never captured on the PageIterator.""" + first_page = PageResult(odata_next_link='https://graph.microsoft.com/v1.0/next', value=[1, 2]) + final_page = PageResult( + odata_next_link=None, + odata_delta_link='https://graph.microsoft.com/v1.0/delta?token=final', + value=[3, 4], + ) + + adapter = Mock() + adapter.send_async = AsyncMock(return_value=final_page) + + page_iterator = PageIterator(first_page, adapter) + # No delta link on the first page, matching the real multi-page scenario. + assert not page_iterator.delta_link + + items = [] + await page_iterator.iterate(lambda item: items.append(item) or True) + + assert items == [1, 2, 3, 4] + assert page_iterator.delta_link == 'https://graph.microsoft.com/v1.0/delta?token=final' + + +@pytest.mark.asyncio +async def test_delta_link_falls_back_to_additional_data(): + """Reproduces the gap where a model without a typed 'odata_delta_link' + attribute stores the delta link in additional_data instead, like a + Kiota-generated collection response that doesn't model the deltaLink + property.""" + first_page = PageResult(odata_next_link='https://graph.microsoft.com/v1.0/next', value=[1, 2]) + final_page = SimpleNamespace( + value=[3, 4], + odata_next_link=None, + additional_data={'@odata.deltaLink': 'https://graph.microsoft.com/v1.0/delta?token=final'}, + ) + + adapter = Mock() + adapter.send_async = AsyncMock(return_value=final_page) + + page_iterator = PageIterator(first_page, adapter) + + items = [] + await page_iterator.iterate(lambda item: items.append(item) or True) + + assert items == [1, 2, 3, 4] + assert page_iterator.delta_link == 'https://graph.microsoft.com/v1.0/delta?token=final' + + @pytest.mark.asyncio async def test_iterate(): # Mock the next method to return None after the first call