Skip to content

Commit c9162c5

Browse files
committed
api_client: add scroll_events method
1 parent a62c975 commit c9162c5

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-0
lines changed

flareio/_ratelimit.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import time
2+
3+
from datetime import datetime
4+
from datetime import timedelta
5+
6+
import typing as t
7+
8+
9+
class Limiter:
10+
def __init__(
11+
self,
12+
*,
13+
tick_interval: timedelta,
14+
_sleeper: t.Callable[[float], None] = time.sleep,
15+
) -> None:
16+
self._tick_interval: timedelta = tick_interval
17+
self._next_tick: datetime = datetime.now()
18+
self._sleeper: t.Callable[[float], None] = _sleeper
19+
self._slept_for: float = 0.0
20+
21+
def _push_next_tick(self) -> None:
22+
self._next_tick = datetime.now() + self._tick_interval
23+
24+
@staticmethod
25+
def _seconds_until(t: datetime) -> float:
26+
td: timedelta = t - datetime.now()
27+
return max(td.total_seconds(), 0.0)
28+
29+
def _sleep(self, seconds: float) -> None:
30+
self._sleeper(seconds)
31+
self._slept_for += seconds
32+
33+
def tick(self) -> None:
34+
"""
35+
You should call this method before making a request.
36+
The first time will be instantaneous.
37+
"""
38+
self._sleep(self._seconds_until(self._next_tick))
39+
self._push_next_tick()
40+
41+
@classmethod
42+
def _unlimited(cls) -> "Limiter":
43+
return cls(tick_interval=timedelta(seconds=0))

flareio/api_client.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import typing as t
1313

14+
from flareio._ratelimit import Limiter
1415
from flareio.exceptions import TokenError
1516
from flareio.version import __version__ as _flareio_version
1617

@@ -263,3 +264,57 @@ def scroll(
263264
params["from"] = next_page
264265
if json and from_in_json:
265266
json["from"] = next_page
267+
268+
def scroll_events(
269+
self,
270+
*,
271+
method: t.Literal[
272+
"GET",
273+
"POST",
274+
],
275+
pages_url: str,
276+
events_url: str,
277+
params: t.Optional[t.Dict[str, t.Any]] = None,
278+
json: t.Optional[t.Dict[str, t.Any]] = None,
279+
_pages_limiter: t.Optional[Limiter] = None,
280+
_events_limiter: t.Optional[Limiter] = None,
281+
) -> t.Iterator[
282+
t.Tuple[
283+
dict,
284+
t.Optional[str],
285+
],
286+
]:
287+
pages_limiter: Limiter = _pages_limiter or Limiter(
288+
tick_interval=timedelta(seconds=1),
289+
)
290+
events_limiter: Limiter = _events_limiter or Limiter(
291+
tick_interval=timedelta(seconds=0.25),
292+
)
293+
294+
pages_limiter.tick()
295+
for page_resp in self.scroll(
296+
method=method,
297+
url=pages_url,
298+
params=params,
299+
json=json,
300+
):
301+
page_resp.raise_for_status()
302+
page_items: t.List[dict] = page_resp.json()["items"]
303+
page_next: t.Optional[str] = page_resp.json()["next"]
304+
305+
for page_item in page_items:
306+
event_uid: str = page_item["metadata"]["uid"]
307+
308+
events_limiter.tick()
309+
event_resp: requests.Response = self.get(
310+
url=events_url,
311+
params={
312+
"uid": event_uid,
313+
},
314+
)
315+
event_resp.raise_for_status()
316+
event: dict = event_resp.json()
317+
318+
yield event, page_next
319+
320+
pages_limiter.tick()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import pytest
2+
import requests_mock
3+
4+
from .utils import get_test_client
5+
6+
from flareio._ratelimit import Limiter
7+
8+
9+
def test_scroll_events() -> None:
10+
api_client = get_test_client()
11+
12+
no_limit: Limiter = Limiter._unlimited()
13+
14+
# This should make no http call.
15+
with requests_mock.Mocker() as mocker:
16+
events_iterator = api_client.scroll_events(
17+
method="GET",
18+
pages_url="https://api.flare.io/pages",
19+
events_url="https://api.flare.io/events",
20+
params={
21+
"from": None,
22+
},
23+
_pages_limiter=no_limit,
24+
_events_limiter=no_limit,
25+
)
26+
assert len(mocker.request_history) == 0
27+
28+
# First page
29+
with requests_mock.Mocker() as mocker:
30+
mocker.register_uri(
31+
"GET",
32+
"https://api.flare.io/pages",
33+
json={
34+
"items": [
35+
{"metadata": {"uid": "first_event_uid"}},
36+
],
37+
"next": "second_page",
38+
},
39+
status_code=200,
40+
)
41+
mocker.register_uri(
42+
"GET",
43+
"https://api.flare.io/events",
44+
json={"event": "hello"},
45+
status_code=200,
46+
)
47+
48+
item, cursor = next(events_iterator)
49+
assert len(mocker.request_history) == 2
50+
assert item == {"event": "hello"}
51+
assert cursor == "second_page"
52+
53+
# Last page
54+
with requests_mock.Mocker() as mocker:
55+
mocker.register_uri(
56+
"GET",
57+
"https://api.flare.io/pages",
58+
json={
59+
"items": [],
60+
"next": None,
61+
},
62+
)
63+
with pytest.raises(StopIteration):
64+
next(events_iterator)
65+
assert len(mocker.request_history) == 1

tests/test_ratelimit.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from datetime import datetime
2+
from datetime import timedelta
3+
4+
from flareio._ratelimit import Limiter
5+
6+
7+
def test_limiter() -> None:
8+
# Setup limiter
9+
limiter: Limiter = Limiter(
10+
tick_interval=timedelta(seconds=35),
11+
_sleeper=lambda _: None,
12+
)
13+
14+
# The first tick is instantaneous. This is so that the limiter
15+
# never sleeps if the requests are taking longer than the interval.
16+
t_1: datetime = limiter._next_tick
17+
limiter.tick()
18+
t_2: datetime = limiter._next_tick
19+
assert limiter._slept_for == 0
20+
assert t_2 > t_1
21+
22+
# Second tick is delayed.
23+
limiter.tick()
24+
t_3: datetime = limiter._next_tick
25+
assert t_3 > t_2
26+
assert limiter._slept_for > 30 # Intentionally not exact to avoid test races.
27+
28+
29+
def test_seconds_until() -> None:
30+
future: datetime = datetime.now() + timedelta(seconds=10)
31+
assert Limiter._seconds_until(future) > 5
32+
33+
34+
def test_seconds_until_negative() -> None:
35+
now: datetime = datetime.now()
36+
past = now - timedelta(seconds=10)
37+
assert Limiter._seconds_until(past) == 0.0
38+
39+
40+
def test_limiter_unlimited() -> None:
41+
limiter: Limiter = Limiter._unlimited()
42+
assert limiter._slept_for == 0
43+
limiter.tick()
44+
limiter.tick()
45+
limiter.tick()
46+
assert limiter._slept_for == 0

0 commit comments

Comments
 (0)