Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/msgraph_core/models/page_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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)
33 changes: 28 additions & 5 deletions src/msgraph_core/tasks/page_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

T = TypeVar('T', bound=Parsable)

ODATA_DELTA_LINK_KEY = '@odata.deltaLink'


class PageIterator:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]]:
"""
Expand Down
51 changes: 51 additions & 0 deletions tests/tasks/test_page_iterator.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch, Mock

import pytest
Expand Down Expand Up @@ -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
Expand Down