Skip to content

Commit f78bd14

Browse files
authored
Merge pull request #479 from microsoftgraph/shem/add-page-iterator
Add Page Iterator support
2 parents 5d21e6e + 0eee03e commit f78bd14

File tree

11 files changed

+440
-2
lines changed

11 files changed

+440
-2
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ venv/
109109
ENV/
110110
env.bak/
111111
venv.bak/
112+
*env/
112113

113114
# Spyder project settings
114115
.spyderproject
@@ -131,4 +132,4 @@ dmypy.json
131132
# Pycharm
132133
.idea/
133134

134-
app.py
135+
app*.py

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"editor.formatOnSave": true
3+
}

requirements-dev.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ pytest-cov==4.1.0
8686

8787
pytest-mock==3.12.0
8888

89+
python-dotenv==1.0.1
90+
8991
pytest-trio==0.8.0
9092

9193
pywin32==306 ; platform_system == 'Windows'
@@ -110,9 +112,10 @@ trio==0.25.0
110112

111113
types-python-dateutil==2.9.0.20240316
112114

115+
types-requests==2.31.0.20240125; python_version >= '3.7'
116+
urllib3==2.1.0 ; python_version >= '3.7'
113117
typing-extensions==4.10.0 ; python_version >= '3.7'
114118

115-
urllib3==2.2.1 ; python_version >= '3.7'
116119

117120
wrapt==1.15.0 ; python_version < '3.11'
118121

src/msgraph_core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@
1313
from .authentication import AzureIdentityAuthenticationProvider
1414
from .base_graph_request_adapter import BaseGraphRequestAdapter
1515
from .graph_client_factory import GraphClientFactory
16+
from .tasks import PageIterator
17+
from .models import PageResult
1618

1719
__version__ = SDK_VERSION

src/msgraph_core/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .page_result import PageResult
2+
3+
__all__ = ['PageResult']
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
This module defines the PageResult class which represents a page of
3+
items in a paged response.
4+
5+
The PageResult class provides methods to get and set the next link and
6+
the items in the page, create a PageResult from a discriminator value, set
7+
the value, get the field deserializers, and serialize the PageResult.
8+
9+
Classes:
10+
PageResult: Represents a page of items in a paged response.
11+
"""
12+
from __future__ import annotations
13+
from typing import List, Optional, Dict, Callable
14+
from dataclasses import dataclass
15+
16+
from kiota_abstractions.serialization.parsable import Parsable
17+
from kiota_abstractions.serialization.serialization_writer \
18+
import SerializationWriter
19+
from kiota_abstractions.serialization.parse_node import ParseNode
20+
from typing import TypeVar, List, Optional
21+
22+
T = TypeVar('T')
23+
24+
25+
@dataclass
26+
class PageResult(Parsable):
27+
odata_next_link: Optional[str] = None
28+
value: Optional[List[Parsable]] = None
29+
30+
@staticmethod
31+
def create_from_discriminator_value(parse_node: Optional[ParseNode] = None) -> PageResult:
32+
"""
33+
Creates a new instance of the appropriate class based on discriminator value
34+
Args:
35+
parseNode: The parse node to use to read the discriminator value and create the object
36+
Returns: Attachment
37+
"""
38+
if not parse_node:
39+
raise TypeError("parse_node cannot be null")
40+
return PageResult()
41+
42+
def get_field_deserializers(self) -> Dict[str, Callable[[ParseNode], None]]:
43+
"""Gets the deserialization information for this object.
44+
45+
Returns:
46+
Dict[str, Callable[[ParseNode], None]]: The deserialization information for this
47+
object where each entry is a property key with its deserialization callback.
48+
"""
49+
return {
50+
"@odata.nextLink": lambda x: setattr(self, "odata_next_link", x.get_str_value()),
51+
"value": lambda x: setattr(self, "value", x.get_collection_of_object_values(Parsable))
52+
}
53+
54+
def serialize(self, writer: SerializationWriter) -> None:
55+
"""Writes the objects properties to the current writer.
56+
57+
Args:
58+
writer (SerializationWriter): The writer to write to.
59+
"""
60+
if not writer:
61+
raise TypeError("Writer cannot be null")
62+
writer.write_str_value("@odata.nextLink", self.odata_next_link)
63+
writer.write_collection_of_object_values("value", self.value)

src/msgraph_core/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .page_iterator import PageIterator
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
This module contains the PageIterator class which is used to
3+
iterate over paged responses from a server.
4+
5+
The PageIterator class provides methods to iterate over the items
6+
in the pages, fetch the next page, convert a response to a page, and
7+
fetch the next page from the server.
8+
9+
The PageIterator class uses the Parsable interface to parse the responses
10+
from the server, the HttpxRequestAdapter class to send requests to the
11+
server, and the PageResult class to represent the pages.
12+
13+
This module also imports the necessary types and exceptions from the
14+
typing, requests.exceptions, kiota_http.httpx_request_adapter,
15+
kiota_abstractions.method, kiota_abstractions.headers_collection,
16+
kiota_abstractions.request_information, kiota_abstractions.serialization.parsable,
17+
and models modules.
18+
"""
19+
20+
from typing import Callable, Optional, Union, Dict, List
21+
22+
from typing import TypeVar
23+
from requests.exceptions import InvalidURL
24+
25+
from kiota_http.httpx_request_adapter import HttpxRequestAdapter
26+
from kiota_abstractions.method import Method
27+
from kiota_abstractions.headers_collection import HeadersCollection
28+
from kiota_abstractions.request_information import RequestInformation
29+
from kiota_abstractions.serialization.parsable import Parsable
30+
31+
from msgraph_core.models.page_result import PageResult # pylint: disable=no-name-in-module, import-error
32+
33+
T = TypeVar('T', bound=Parsable)
34+
35+
36+
class PageIterator:
37+
"""
38+
This class is used to iterate over paged responses from a server.
39+
40+
The PageIterator class provides methods to iterate over the items in the pages,
41+
fetch the next page, and convert a response to a page.
42+
43+
Attributes:
44+
request_adapter (HttpxRequestAdapter): The adapter used to send HTTP requests.
45+
pause_index (int): The index at which to pause iteration.
46+
headers (HeadersCollection): The headers to include in the HTTP requests.
47+
request_options (list): The options for the HTTP requests.
48+
current_page (PageResult): The current page of items.
49+
object_type (str): The type of the items in the pages.
50+
has_next (bool): Whether there are more pages to fetch.
51+
52+
Methods:
53+
__init__(response: Union[T, list, object], request_adapter: HttpxRequestAdapter,
54+
constructor_callable: Optional[Callable] = None): Initializes a new instance of
55+
the PageIterator class.
56+
"""
57+
58+
def __init__(
59+
self,
60+
response: Union[T, list, object],
61+
request_adapter: HttpxRequestAdapter,
62+
constructor_callable: Optional[Callable] = None
63+
):
64+
self.request_adapter = request_adapter
65+
66+
if isinstance(response, Parsable) and not constructor_callable:
67+
parsable_factory = type(response)
68+
elif constructor_callable is None:
69+
parsable_factory = PageResult
70+
self.parsable_factory = parsable_factory
71+
self.pause_index = 0
72+
self.headers: HeadersCollection = HeadersCollection()
73+
self.request_options = [] # type: ignore
74+
self.current_page = self.convert_to_page(response)
75+
self.object_type = self.current_page.value[
76+
0].__class__.__name__ if self.current_page.value else None
77+
page = self.current_page
78+
self._next_link = response.get('odata_next_link', '') if isinstance(
79+
response, dict
80+
) else getattr(response, 'odata_next_link', '')
81+
self._delta_link = response.get('@odata.deltaLink', '') if isinstance(
82+
response, dict
83+
) else getattr(response, '@odata.deltaLink', '')
84+
85+
if page is not None:
86+
self.current_page = page
87+
self.has_next = bool(page.odata_next_link)
88+
89+
def set_headers(self, headers: dict) -> HeadersCollection:
90+
"""
91+
Sets the headers for the HTTP requests.
92+
This method takes a dictionary of headers and adds them to the
93+
existing headers.
94+
Args:
95+
headers (dict): A dictionary of headers to add. The keys are the
96+
header names and the values are the header values.
97+
"""
98+
self.headers.add_all(**headers)
99+
100+
@property
101+
def delta_link(self):
102+
return self._delta_link
103+
104+
@property
105+
def next_link(self):
106+
return self._next_link
107+
108+
def set_request_options(self, request_options: list) -> None:
109+
"""
110+
Sets the request options for the HTTP requests.
111+
Args:
112+
request_options (list): The request options to set.
113+
"""
114+
self.request_options = request_options
115+
116+
async def iterate(self, callback: Callable) -> None:
117+
"""
118+
Iterates over the pages and applies a callback function to each item.
119+
The iteration stops when there are no more pages or the callback
120+
function returns False.
121+
Args:
122+
callback (Callable): The function to apply to each item.
123+
It should take one argument (the item) and return a boolean.
124+
"""
125+
while True:
126+
keep_iterating = self.enumerate(callback)
127+
if not keep_iterating:
128+
return
129+
next_page = await self.next()
130+
if not next_page:
131+
return
132+
self.current_page = next_page
133+
self.pause_index = 0
134+
135+
async def next(self) -> Optional[PageResult]:
136+
"""
137+
Fetches the next page of items.
138+
Returns:
139+
dict: The next page of items, or None if there are no more pages.
140+
"""
141+
if self.current_page is not None and not self.current_page.odata_next_link:
142+
return None
143+
response = await self.fetch_next_page()
144+
print(f"Response - {type(response)}")
145+
page: PageResult = PageResult(response.odata_next_link, response.value) # type: ignore
146+
return page
147+
148+
@staticmethod
149+
def convert_to_page(response: Union[T, list, object]) -> PageResult:
150+
"""
151+
Converts a response to a PageResult.
152+
This method extracts the 'value' and 'odata_next_link' from the
153+
response and uses them to create a PageResult.
154+
Args:
155+
response (Union[T, list, object]): The response to convert. It can
156+
be a list, an object, or any other type.
157+
Returns:
158+
PageResult: The PageResult created from the response.
159+
Raises:
160+
ValueError: If the response is None or does not contain a 'value'.
161+
"""
162+
if not response:
163+
raise ValueError('Response cannot be null.')
164+
value = None
165+
if isinstance(response, list):
166+
value = response.value # type: ignore
167+
elif hasattr(response, 'value'):
168+
value = getattr(response, 'value')
169+
elif isinstance(response, object):
170+
value = getattr(response, 'value', [])
171+
if value is None:
172+
raise ValueError('The response does not contain a value.')
173+
parsable_page = response if isinstance(response, dict) else vars(response)
174+
next_link = parsable_page.get('odata_next_link', '') if isinstance(
175+
parsable_page, dict
176+
) else getattr(parsable_page, 'odata_next_link', '')
177+
178+
page: PageResult = PageResult(next_link, value)
179+
return page
180+
181+
async def fetch_next_page(self) -> List[Parsable]:
182+
"""
183+
Fetches the next page of items from the server.
184+
Returns:
185+
dict: The response from the server.
186+
Raises:
187+
ValueError: If the current page does not contain a next link.
188+
InvalidURL: If the next link URL could not be parsed.
189+
"""
190+
191+
next_link = self.current_page.odata_next_link
192+
if not next_link:
193+
raise ValueError('The response does not contain a nextLink.')
194+
if not next_link.startswith('http'):
195+
raise InvalidURL('Could not parse nextLink URL.')
196+
request_info = RequestInformation()
197+
request_info.http_method = Method.GET
198+
request_info.url = next_link
199+
request_info.headers = self.headers
200+
if self.request_options:
201+
request_info.add_request_options(*self.request_options)
202+
error_map: Dict[str, int] = {}
203+
response = await self.request_adapter.send_async(
204+
request_info, self.parsable_factory, error_map
205+
)
206+
return response
207+
208+
def enumerate(self, callback: Optional[Callable] = None) -> bool:
209+
"""
210+
Enumerates over the items in the current page and applies a
211+
callback function to each item.
212+
Args:
213+
callback (Callable, optional): The function to apply to each item.
214+
It should take one argument (the item) and return a boolean.
215+
Returns:
216+
bool: False if there are no items in the current page or the
217+
callback function returns False, True otherwise.
218+
"""
219+
keep_iterating = True
220+
page_items = self.current_page.value
221+
if not page_items:
222+
return False
223+
for i in range(self.pause_index, len(page_items)):
224+
keep_iterating = callback(page_items[i]) if callback is not None else True
225+
if not keep_iterating:
226+
self.pause_index = i + 1
227+
break
228+
return keep_iterating

tests/tasks/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)