Skip to content

Commit f937a74

Browse files
tpellissierclaude
andcommitted
Enable per-page telemetry access for paginated get() queries
Extends the fluent .with_response_details() API to support paginated queries. Each page batch is now wrapped in OperationResult, allowing telemetry access per page while maintaining backward compatibility. Changes: - Add __add__ and __radd__ to OperationResult for batch concatenation - Modify _get_multiple to yield (batch, metadata) tuples - Update get() to wrap each page in OperationResult - Add per-page telemetry printing to walkthrough example - Add tests for concatenation and per-page telemetry access Backward compatibility: - Existing iteration patterns work unchanged via OperationResult delegation - Batch concatenation (batch1 + batch2) returns raw list as expected Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7bfd93d commit f937a74

File tree

7 files changed

+204
-27
lines changed

7 files changed

+204
-27
lines changed

examples/advanced/walkthrough.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ def main():
212212
records_iterator = backoff(lambda: client.get(table_name, filter="new_quantity gt 5"))
213213
for page in records_iterator:
214214
all_records.extend(page)
215+
print_telemetry(page.with_response_details().telemetry)
215216
print(f"[OK] Found {len(all_records)} records with new_quantity > 5")
216217
for rec in all_records:
217218
print(f" - new_Title='{rec.get('new_title')}', new_Quantity={rec.get('new_quantity')}")
@@ -266,6 +267,7 @@ def main():
266267
for page_num, page in enumerate(paging_iterator, start=1):
267268
record_ids = [r.get("new_walkthroughdemoid")[:8] + "..." for r in page]
268269
print(f" Page {page_num}: {len(page)} records - IDs: {record_ids}")
270+
print_telemetry(page.with_response_details().telemetry)
269271

270272
# ============================================================================
271273
# 7. SQL QUERY

src/PowerPlatform/Dataverse/client.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def get(
293293
top: Optional[int] = None,
294294
expand: Optional[List[str]] = None,
295295
page_size: Optional[int] = None,
296-
) -> Union[OperationResult[Dict[str, Any]], Iterable[List[Dict[str, Any]]]]:
296+
) -> Union[OperationResult[Dict[str, Any]], Iterable[OperationResult[List[Dict[str, Any]]]]]:
297297
"""
298298
Fetch a single record by ID or query multiple records.
299299
@@ -320,9 +320,11 @@ def get(
320320
:return: When ``record_id`` is provided, returns an OperationResult containing the record dict.
321321
The result supports dict-like access (e.g., ``result["name"]``) or call
322322
``.with_response_details()`` to access telemetry data.
323-
When querying multiple records, returns a generator yielding lists of record
324-
dictionaries (one list per page).
325-
:rtype: :class:`OperationResult` [:class:`dict`] or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict`
323+
When querying multiple records, returns a generator yielding OperationResult objects,
324+
each containing a list of record dictionaries (one list per page). Each batch supports
325+
iteration and indexing directly, or call ``.with_response_details()`` to access
326+
that page's telemetry data.
327+
:rtype: :class:`OperationResult` [:class:`dict`] or :class:`collections.abc.Iterable` of :class:`OperationResult` [:class:`list` of :class:`dict`]
326328
327329
:raises TypeError: If ``record_id`` is provided but not a string.
328330
@@ -368,6 +370,14 @@ def get(
368370
page_size=50
369371
):
370372
print(f"Batch size: {len(batch)}")
373+
374+
Query with per-page telemetry access::
375+
376+
for batch in client.get("account", filter="statecode eq 0"):
377+
response = batch.with_response_details()
378+
print(f"Page request ID: {response.telemetry['service_request_id']}")
379+
for account in response.result:
380+
print(account["name"])
371381
"""
372382
if record_id is not None:
373383
if not isinstance(record_id, str):
@@ -380,17 +390,18 @@ def get(
380390
)
381391
return OperationResult(record, metadata)
382392

383-
def _paged() -> Iterable[List[Dict[str, Any]]]:
393+
def _paged() -> Iterable[OperationResult[List[Dict[str, Any]]]]:
384394
with self._scoped_odata() as od:
385-
yield from od._get_multiple(
395+
for batch, metadata in od._get_multiple(
386396
table_schema_name,
387397
select=select,
388398
filter=filter,
389399
orderby=orderby,
390400
top=top,
391401
expand=expand,
392402
page_size=page_size,
393-
)
403+
):
404+
yield OperationResult(batch, metadata)
394405

395406
return _paged()
396407

src/PowerPlatform/Dataverse/core/results.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,29 @@ def __contains__(self, item: Any) -> bool:
240240
"""
241241
return item in self._result # type: ignore[operator]
242242

243+
def __add__(self, other: Any) -> Any:
244+
"""
245+
Support concatenation with + operator.
246+
247+
When combining OperationResults (e.g., concatenating batches), returns
248+
the raw combined result since there's no meaningful single telemetry
249+
to preserve for the combined value.
250+
251+
:param other: Value to concatenate with.
252+
:return: Combined result (raw value, not wrapped in OperationResult).
253+
"""
254+
if isinstance(other, OperationResult):
255+
return self._result + other._result # type: ignore[operator]
256+
return self._result + other # type: ignore[operator]
257+
258+
def __radd__(self, other: Any) -> Any:
259+
"""
260+
Support right-hand concatenation (e.g., [] + result).
261+
262+
:param other: Left-hand value to concatenate with.
263+
:return: Combined result (raw value, not wrapped in OperationResult).
264+
"""
265+
return other + self._result # type: ignore[operator]
266+
243267

244268
__all__ = ["RequestTelemetryData", "DataverseResponse", "OperationResult"]

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -629,8 +629,8 @@ def _get_multiple(
629629
top: Optional[int] = None,
630630
expand: Optional[List[str]] = None,
631631
page_size: Optional[int] = None,
632-
) -> Iterable[List[Dict[str, Any]]]:
633-
"""Iterate records from an entity set, yielding one page (list of dicts) at a time.
632+
) -> Iterable[_ODataRequestResult]:
633+
"""Iterate records from an entity set, yielding one page with telemetry at a time.
634634
635635
:param table_schema_name: Schema name of the table.
636636
:type table_schema_name: ``str``
@@ -647,12 +647,8 @@ def _get_multiple(
647647
:param page_size: Per-page size hint via ``Prefer: odata.maxpagesize``.
648648
:type page_size: ``int`` | ``None``
649649
650-
:return: Iterator yielding pages (each page is a ``list`` of record dicts).
651-
:rtype: ``Iterable[list[dict[str, Any]]]``
652-
653-
.. note::
654-
This method is a generator and does not return metadata directly.
655-
For paginated queries, metadata is captured per-request but not surfaced.
650+
:return: Iterator yielding tuples of (page records, telemetry) for each page.
651+
:rtype: ``Iterable[tuple[list[dict[str, Any]], RequestTelemetryData]]``
656652
"""
657653

658654
extra_headers: Dict[str, str] = {}
@@ -661,13 +657,13 @@ def _get_multiple(
661657
if ps > 0:
662658
extra_headers["Prefer"] = f"odata.maxpagesize={ps}"
663659

664-
def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
660+
def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], RequestTelemetryData]:
665661
headers = extra_headers if extra_headers else None
666-
r, _ = self._request("get", url, headers=headers, params=params)
662+
r, metadata = self._request("get", url, headers=headers, params=params)
667663
try:
668-
return r.json()
664+
return r.json(), metadata
669665
except ValueError:
670-
return {}
666+
return {}, metadata
671667

672668
entity_set = self._entity_set_from_schema_name(table_schema_name)
673669
base_url = f"{self.api}/{entity_set}"
@@ -687,20 +683,20 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st
687683
if top is not None:
688684
params["$top"] = int(top)
689685

690-
data = _do_request(base_url, params=params)
686+
data, metadata = _do_request(base_url, params=params)
691687
items = data.get("value") if isinstance(data, dict) else None
692688
if isinstance(items, list) and items:
693-
yield [x for x in items if isinstance(x, dict)]
689+
yield [x for x in items if isinstance(x, dict)], metadata
694690

695691
next_link = None
696692
if isinstance(data, dict):
697693
next_link = data.get("@odata.nextLink") or data.get("odata.nextLink")
698694

699695
while next_link:
700-
data = _do_request(next_link)
696+
data, metadata = _do_request(next_link)
701697
items = data.get("value") if isinstance(data, dict) else None
702698
if isinstance(items, list) and items:
703-
yield [x for x in items if isinstance(x, dict)]
699+
yield [x for x in items if isinstance(x, dict)], metadata
704700
next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") if isinstance(data, dict) else None
705701

706702
# --------------------------- SQL Custom API -------------------------

tests/unit/data/test_logical_crud.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,13 @@ def test_get_multiple_paging():
119119
]
120120
c = MockableClient(responses)
121121
pages = list(c._get_multiple("account", select=["accountid"], page_size=1))
122-
assert pages == [[{"accountid": "1"}], [{"accountid": "2"}]]
122+
# _get_multiple now returns (batch, metadata) tuples
123+
assert len(pages) == 2
124+
assert pages[0][0] == [{"accountid": "1"}]
125+
assert pages[1][0] == [{"accountid": "2"}]
126+
# Each page has telemetry metadata
127+
assert pages[0][1].client_request_id is not None
128+
assert pages[1][1].client_request_id is not None
123129

124130

125131
def test_unknown_table_schema_name_raises():

tests/unit/test_client.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,10 @@ def test_get_single(self):
158158

159159
def test_get_multiple(self):
160160
"""Test get method for querying multiple records."""
161-
# Setup mock return value (iterator)
161+
# Setup mock return value (iterator of (batch, metadata) tuples)
162162
expected_batch = [{"accountid": "1", "name": "A"}, {"accountid": "2", "name": "B"}]
163-
self.client._odata._get_multiple.return_value = iter([expected_batch])
163+
mock_metadata = RequestTelemetryData(client_request_id="test-page-1")
164+
self.client._odata._get_multiple.return_value = iter([(expected_batch, mock_metadata)])
164165

165166
# Execute query
166167
result_iterator = self.client.get("account", filter="statecode eq 0", top=10)
@@ -177,4 +178,63 @@ def test_get_multiple(self):
177178
expand=None,
178179
page_size=None,
179180
)
180-
self.assertEqual(results, [expected_batch])
181+
# Each batch is now wrapped in OperationResult
182+
self.assertEqual(len(results), 1)
183+
# Can iterate/index the batch directly (OperationResult delegates)
184+
self.assertEqual(results[0][0], {"accountid": "1", "name": "A"})
185+
self.assertEqual(list(results[0]), expected_batch)
186+
# Can access telemetry via with_response_details()
187+
response = results[0].with_response_details()
188+
self.assertEqual(response.telemetry["client_request_id"], "test-page-1")
189+
190+
def test_get_multiple_pagination_with_telemetry(self):
191+
"""Test get method returns per-page telemetry for paginated results."""
192+
# Setup mock with multiple pages
193+
batch1 = [{"accountid": "1"}, {"accountid": "2"}]
194+
batch2 = [{"accountid": "3"}, {"accountid": "4"}]
195+
metadata1 = RequestTelemetryData(client_request_id="page-1", service_request_id="svc-1")
196+
metadata2 = RequestTelemetryData(client_request_id="page-2", service_request_id="svc-2")
197+
self.client._odata._get_multiple.return_value = iter([
198+
(batch1, metadata1),
199+
(batch2, metadata2),
200+
])
201+
202+
# Execute query
203+
results = list(self.client.get("account"))
204+
205+
# Verify we got two pages
206+
self.assertEqual(len(results), 2)
207+
208+
# First page telemetry
209+
response1 = results[0].with_response_details()
210+
self.assertEqual(response1.result, batch1)
211+
self.assertEqual(response1.telemetry["client_request_id"], "page-1")
212+
self.assertEqual(response1.telemetry["service_request_id"], "svc-1")
213+
214+
# Second page telemetry
215+
response2 = results[1].with_response_details()
216+
self.assertEqual(response2.result, batch2)
217+
self.assertEqual(response2.telemetry["client_request_id"], "page-2")
218+
self.assertEqual(response2.telemetry["service_request_id"], "svc-2")
219+
220+
def test_get_multiple_batch_concatenation(self):
221+
"""Test that batches can be concatenated with + operator."""
222+
# Setup mock with multiple pages
223+
batch1 = [{"id": "1"}, {"id": "2"}]
224+
batch2 = [{"id": "3"}, {"id": "4"}]
225+
metadata = RequestTelemetryData()
226+
self.client._odata._get_multiple.return_value = iter([
227+
(batch1, metadata),
228+
(batch2, metadata),
229+
])
230+
231+
# Execute query and concatenate batches
232+
batches = list(self.client.get("account"))
233+
all_records = batches[0] + batches[1]
234+
235+
# Verify concatenation works
236+
self.assertEqual(len(all_records), 4)
237+
self.assertEqual(all_records[0]["id"], "1")
238+
self.assertEqual(all_records[3]["id"], "4")
239+
# Result is raw list, not OperationResult
240+
self.assertIsInstance(all_records, list)

tests/unit/test_results.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,81 @@ def test_telemetry_access(self, sample_telemetry_data):
347347
assert response.telemetry["client_request_id"] == "client-123"
348348
assert response.telemetry["correlation_id"] == "corr-456"
349349
assert response.telemetry["service_request_id"] == "svc-789"
350+
351+
352+
class TestOperationResultConcatenation:
353+
"""Tests for OperationResult concatenation with + operator."""
354+
355+
@pytest.fixture
356+
def sample_telemetry_data(self):
357+
return RequestTelemetryData(
358+
client_request_id="client-123",
359+
correlation_id="corr-456",
360+
service_request_id="svc-789",
361+
)
362+
363+
def test_add_two_operation_results(self, sample_telemetry_data):
364+
"""Adding two OperationResults should concatenate their results."""
365+
result1 = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data)
366+
result2 = OperationResult(result=["c", "d"], telemetry_data=sample_telemetry_data)
367+
combined = result1 + result2
368+
assert combined == ["a", "b", "c", "d"]
369+
# Result should be raw list, not OperationResult
370+
assert isinstance(combined, list)
371+
372+
def test_add_operation_result_with_list(self, sample_telemetry_data):
373+
"""Adding OperationResult with a list should work."""
374+
result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data)
375+
combined = result + ["c", "d"]
376+
assert combined == ["a", "b", "c", "d"]
377+
assert isinstance(combined, list)
378+
379+
def test_radd_list_with_operation_result(self, sample_telemetry_data):
380+
"""Right-hand addition: list + OperationResult should work."""
381+
result = OperationResult(result=["c", "d"], telemetry_data=sample_telemetry_data)
382+
combined = ["a", "b"] + result
383+
assert combined == ["a", "b", "c", "d"]
384+
assert isinstance(combined, list)
385+
386+
def test_add_empty_lists(self, sample_telemetry_data):
387+
"""Adding empty OperationResults should return empty list."""
388+
result1 = OperationResult(result=[], telemetry_data=sample_telemetry_data)
389+
result2 = OperationResult(result=[], telemetry_data=sample_telemetry_data)
390+
combined = result1 + result2
391+
assert combined == []
392+
assert isinstance(combined, list)
393+
394+
def test_add_with_empty_list(self, sample_telemetry_data):
395+
"""Adding OperationResult with empty list should work."""
396+
result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data)
397+
combined = result + []
398+
assert combined == ["a", "b"]
399+
400+
def test_radd_empty_list(self, sample_telemetry_data):
401+
"""Right-hand addition with empty list should work."""
402+
result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data)
403+
combined = [] + result
404+
assert combined == ["a", "b"]
405+
406+
def test_concatenate_multiple_batches(self, sample_telemetry_data):
407+
"""Simulate combining multiple page batches."""
408+
batch1 = OperationResult(result=[{"id": "1"}, {"id": "2"}], telemetry_data=sample_telemetry_data)
409+
batch2 = OperationResult(result=[{"id": "3"}, {"id": "4"}], telemetry_data=sample_telemetry_data)
410+
batch3 = OperationResult(result=[{"id": "5"}], telemetry_data=sample_telemetry_data)
411+
412+
all_records = batch1 + batch2 + batch3
413+
assert len(all_records) == 5
414+
assert all_records[0]["id"] == "1"
415+
assert all_records[4]["id"] == "5"
416+
417+
def test_string_concatenation(self, sample_telemetry_data):
418+
"""String concatenation should work."""
419+
result = OperationResult(result="Hello ", telemetry_data=sample_telemetry_data)
420+
combined = result + "World"
421+
assert combined == "Hello World"
422+
423+
def test_radd_string_concatenation(self, sample_telemetry_data):
424+
"""Right-hand string concatenation should work."""
425+
result = OperationResult(result="World", telemetry_data=sample_telemetry_data)
426+
combined = "Hello " + result
427+
assert combined == "Hello World"

0 commit comments

Comments
 (0)