Skip to content

Commit 237d3c1

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

File tree

4 files changed

+199
-0
lines changed

4 files changed

+199
-0
lines changed

flareio/_ratelimit.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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()

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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pytest
2+
import requests_mock
3+
4+
from .utils import get_test_client
5+
from datetime import timedelta
6+
7+
from flareio._ratelimit import Limiter
8+
9+
10+
def test_scroll_events() -> None:
11+
api_client = get_test_client()
12+
13+
no_limit: Limiter = Limiter(
14+
tick_interval=timedelta(seconds=0),
15+
)
16+
17+
# This should make no http call.
18+
with requests_mock.Mocker() as mocker:
19+
events_iterator = api_client.scroll_events(
20+
method="GET",
21+
pages_url="https://api.flare.io/pages",
22+
events_url="https://api.flare.io/events",
23+
params={
24+
"from": None,
25+
},
26+
_pages_limiter=no_limit,
27+
_events_limiter=no_limit,
28+
)
29+
assert len(mocker.request_history) == 0
30+
31+
# First page
32+
with requests_mock.Mocker() as mocker:
33+
mocker.register_uri(
34+
"GET",
35+
"https://api.flare.io/pages",
36+
json={
37+
"items": [
38+
{"metadata": {"uid": "first_event_uid"}},
39+
],
40+
"next": "second_page",
41+
},
42+
status_code=200,
43+
)
44+
mocker.register_uri(
45+
"GET",
46+
"https://api.flare.io/events",
47+
json={"event": "hello"},
48+
status_code=200,
49+
)
50+
51+
item, cursor = next(events_iterator)
52+
assert len(mocker.request_history) == 2
53+
assert item == {"event": "hello"}
54+
assert cursor == "second_page"
55+
56+
# Last page
57+
with requests_mock.Mocker() as mocker:
58+
mocker.register_uri(
59+
"GET",
60+
"https://api.flare.io/pages",
61+
json={
62+
"items": [],
63+
"next": None,
64+
},
65+
)
66+
with pytest.raises(StopIteration):
67+
next(events_iterator)
68+
assert len(mocker.request_history) == 1

tests/test_ratelimit.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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

0 commit comments

Comments
 (0)