This commit is contained in:
Rodrigo Rodrigues 2026-01-20 14:27:33 +00:00 committed by GitHub
commit a895731873
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 201 additions and 0 deletions

View File

@ -1,3 +1,19 @@
# thousandeyes-sdk-core
This package provides core functionality for interacting with the ThousandEyes API and should be installed before using any of the published SDKs.
Usage example for iterating paginated responses:
```python
from thousandeyes_sdk.core import PaginatorIterator
from thousandeyes_sdk.dashboards.api.dashboards_api import DashboardsApi
dashboards_api = DashboardsApi()
for widget_data in PaginatorIterator(
dashboards_api.get_dashboard_widget_data,
lambda response: response.data.tests if response.data else [],
dashboard_id="dashboard-id",
widget_id="widget-id",
):
print(widget_data)
```

View File

@ -18,5 +18,6 @@ from . import exceptions
from .api_client import ApiClient
from .api_response import ApiResponse
from .configuration import Configuration
from .pagination_iterator import PaginatorIterator
import os.path

View File

@ -0,0 +1,109 @@
# Copyright 2024 Cisco Systems, Inc. and its affiliates
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator
from typing import Any, Mapping, Optional, TypeVar, Generic
from urllib.parse import parse_qs, urlparse
from typing_extensions import ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
I = TypeVar("I")
class PaginatorIterator(Generic[P, R, I]):
"""Iterate over cursor-paginated responses.
Calls ``method`` repeatedly, passing a cursor parameter between calls,
and yields items obtained from ``items_getter``.
The next cursor is derived from ``response.data.links`` or ``response.data._links``
(or mapping equivalents), supporting these link formats:
- a direct ``href`` string
- a mapping with a ``href`` key
- an object with a ``href`` attribute
Iteration stops when no next cursor is found or the cursor repeats.
"""
def __init__(
self,
method: Callable[P, R],
items_getter: Callable[[R], Iterable[I]],
*,
cursor_param: str = "cursor",
**params: P.kwargs,
) -> None:
self._method = method
self._items_getter = items_getter
self._cursor_param = cursor_param
self._params: dict[str, Any] = dict(params)
def __iter__(self) -> Iterator[I]:
params = dict(self._params)
last_cursor = params.get(self._cursor_param)
while True:
response = self._method(**params)
for item in self._items_getter(response):
yield item
next_cursor = self._next_cursor_from_response(response)
if not next_cursor or next_cursor == last_cursor:
break
params[self._cursor_param] = next_cursor
last_cursor = next_cursor
def _next_cursor_from_response(self, response: Any) -> Optional[str]:
data = getattr(response, "data", response)
links = getattr(data, "links", None)
if links is None:
links = getattr(data, "_links", None)
if links is None and isinstance(data, Mapping):
links = data.get("_links") or data.get("links")
if links is None:
return None
next_link = getattr(links, "next", None)
if next_link is None and isinstance(links, Mapping):
next_link = links.get("next")
if next_link is None:
return None
if isinstance(next_link, str):
href = next_link
elif isinstance(next_link, Mapping):
href = next_link.get("href")
else:
href = getattr(next_link, "href", None)
if not href:
return None
parsed = urlparse(href)
query_params = parse_qs(parsed.query)
cursor_values = query_params.get(self._cursor_param)
if cursor_values:
return cursor_values[0]
return None

View File

@ -0,0 +1,75 @@
from types import SimpleNamespace
from thousandeyes_sdk.core.pagination_iterator import PaginatorIterator
def test_iterator_uses_cursor_from_next_href():
calls = []
def method(**params):
calls.append(params.copy())
if params.get("cursor") is None:
links = SimpleNamespace(next="https://example.com/items?cursor=abc")
items = ["first", "second"]
else:
links = SimpleNamespace(next=None)
items = ["third"]
data = SimpleNamespace(links=links)
return SimpleNamespace(data=data, items=items)
responses = list(PaginatorIterator(method, lambda response: response.items))
assert responses == ["first", "second", "third"]
assert calls == [{}, {"cursor": "abc"}]
def test_iterator_reads_cursor_from_links_mapping():
calls = []
def method(**params):
calls.append(params.copy())
if params.get("pageCursor") is None:
data = {"_links": {"next": {"href": "https://example.com?foo=1&pageCursor=xyz"}}}
items = ["alpha"]
else:
data = {"_links": {"next": None}}
items = ["beta"]
return SimpleNamespace(data=data, items=items)
responses = list(PaginatorIterator(method, lambda response: response.items, cursor_param="pageCursor"))
assert responses == ["alpha", "beta"]
assert calls == [{}, {"pageCursor": "xyz"}]
def test_iterator_stops_when_no_cursor_param_present():
calls = []
def method(**params):
calls.append(params.copy())
if params.get("cursor") is None:
data = {"links": {"next": "/next/page"}}
items = ["one"]
else:
data = {"links": {"next": None}}
items = ["two"]
return SimpleNamespace(data=data, items=items)
responses = list(PaginatorIterator(method, lambda response: response.items))
assert responses == ["one"]
assert calls == [{}]
def test_iterator_stops_on_repeated_cursor():
calls = []
def method(**params):
calls.append(params.copy())
data = {"links": {"next": "https://example.com?cursor=same"}}
return SimpleNamespace(data=data, items=["only"])
responses = list(PaginatorIterator(method, lambda response: response.items, cursor="same"))
assert responses == ["only"]
assert calls == [{"cursor": "same"}]