rtbnext 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtbnext/__init__.py +26 -0
- rtbnext/_version.py +7 -0
- rtbnext/core/cache.py +102 -0
- rtbnext/core/http_client.py +153 -0
- rtbnext/core/loader.py +173 -0
- rtbnext/core/parser.py +84 -0
- rtbnext/core/rate_limiter.py +86 -0
- rtbnext/defaults.py +26 -0
- rtbnext/endpoint/base.py +80 -0
- rtbnext/endpoint/filter.py +112 -0
- rtbnext/endpoint/list.py +85 -0
- rtbnext/endpoint/mover.py +33 -0
- rtbnext/endpoint/profile.py +158 -0
- rtbnext/endpoint/stats.py +156 -0
- rtbnext/endpoint/system.py +25 -0
- rtbnext/py.typed +0 -0
- rtbnext/resource/base.py +206 -0
- rtbnext/resource/collectable.py +201 -0
- rtbnext/resource/collection.py +313 -0
- rtbnext/resource/dateable.py +59 -0
- rtbnext/resource/indexable.py +91 -0
- rtbnext/resource/series.py +266 -0
- rtbnext/rtbnext.py +107 -0
- rtbnext/schema/assets.py +101 -0
- rtbnext/schema/filter.py +35 -0
- rtbnext/schema/generic.py +105 -0
- rtbnext/schema/list.py +64 -0
- rtbnext/schema/mover.py +40 -0
- rtbnext/schema/profile.py +137 -0
- rtbnext/schema/stats.py +133 -0
- rtbnext/schema/system.py +15 -0
- rtbnext/utils.py +36 -0
- rtbnext-1.0.0.dist-info/METADATA +161 -0
- rtbnext-1.0.0.dist-info/RECORD +36 -0
- rtbnext-1.0.0.dist-info/WHEEL +4 -0
- rtbnext-1.0.0.dist-info/licenses/LICENSE +21 -0
rtbnext/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RTBNext Python SDK
|
|
3
|
+
|
|
4
|
+
Official Python SDK for the RTBNext API.
|
|
5
|
+
|
|
6
|
+
Provides access to all RTBNext API endpoints, resources, and data models.
|
|
7
|
+
The SDK offers lazy loading, transparent caching, automatic revalidation,
|
|
8
|
+
and a resource-oriented API for working with lists, profiles, filters,
|
|
9
|
+
statistics, and time series data.
|
|
10
|
+
|
|
11
|
+
Author: Paul Köhler (komed3)
|
|
12
|
+
License: MIT
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from rtbnext._version import __version__
|
|
16
|
+
from rtbnext.core.http_client import ClientIdentity
|
|
17
|
+
from rtbnext.core.loader import CacheMode, CacheType
|
|
18
|
+
from rtbnext.rtbnext import RTBNext, rtbnext
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"RTBNext",
|
|
22
|
+
"rtbnext",
|
|
23
|
+
"ClientIdentity",
|
|
24
|
+
"CacheType",
|
|
25
|
+
"CacheMode"
|
|
26
|
+
]
|
rtbnext/_version.py
ADDED
rtbnext/core/cache.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache
|
|
3
|
+
|
|
4
|
+
Implements a cache interface for storing and retrieving resource states.
|
|
5
|
+
|
|
6
|
+
The cache protocol can be used to implement different storage backends,
|
|
7
|
+
such as in-memory caches or external databases.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from rtbnext.core.loader import ResourceState
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Cache( Protocol ):
|
|
20
|
+
"""
|
|
21
|
+
Interface for cache implementations.
|
|
22
|
+
|
|
23
|
+
A cache stores and retrieves resource states using string keys.
|
|
24
|
+
Implementations may choose any storage backend, such as memory,
|
|
25
|
+
the filesystem or an external database.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def size( self ) -> int:
|
|
30
|
+
"""Returns the number of cached resource states."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def get( self, key: str ) -> ResourceState | None:
|
|
34
|
+
"""Retrieves a cached resource state."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def set( self, key: str, value: ResourceState ) -> None:
|
|
38
|
+
"""Stores a resource state."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
async def delete( self, key: str ) -> None:
|
|
42
|
+
"""Removes a resource state from the cache."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
async def clear( self ) -> None:
|
|
46
|
+
"""Removes all resource states from the cache."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EmptyCache( Cache ):
|
|
51
|
+
"""
|
|
52
|
+
Cache implementation that never stores any data.
|
|
53
|
+
|
|
54
|
+
All read operations return `None`, while write operations are ignored.
|
|
55
|
+
This implementation can be used to disable caching without changing the
|
|
56
|
+
SDK's cache interface.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def size( self ) -> int:
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
async def get( self, _key: str ) -> ResourceState | None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
async def set( self, _key: str, _value: ResourceState ) -> None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def delete( self, _key: str ) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
async def clear( self ) -> None:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MemoryCache ( Cache ):
|
|
77
|
+
"""
|
|
78
|
+
Simple in-memory cache backed by a dictionary.
|
|
79
|
+
|
|
80
|
+
Suitable for short-lived applications where persistence is not required.
|
|
81
|
+
Cached entries remain until they are explicitly removed or the cache is
|
|
82
|
+
cleared.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__( self ) -> None:
|
|
86
|
+
self._store: dict[ str, ResourceState ] = {}
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def size( self ) -> int:
|
|
90
|
+
return len( self._store )
|
|
91
|
+
|
|
92
|
+
async def get( self, key: str ) -> ResourceState | None:
|
|
93
|
+
return self._store.get( key )
|
|
94
|
+
|
|
95
|
+
async def set( self, key: str, value: ResourceState ) -> None:
|
|
96
|
+
self._store[ key ] = value
|
|
97
|
+
|
|
98
|
+
async def delete( self, key: str ) -> None:
|
|
99
|
+
self._store.pop( key, None )
|
|
100
|
+
|
|
101
|
+
async def clear( self ) -> None:
|
|
102
|
+
self._store.clear()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Client
|
|
3
|
+
|
|
4
|
+
Implements an HTTP client with built-in rate limiting, request deduplication
|
|
5
|
+
and proper client identification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from time import perf_counter
|
|
13
|
+
from typing import Self
|
|
14
|
+
from urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from rtbnext._version import __version__
|
|
19
|
+
from rtbnext.core.rate_limiter import RateLimiter, RateLimitMode
|
|
20
|
+
from rtbnext.defaults import (
|
|
21
|
+
DEFAULT_API_URL, DEFAULT_MAX_REQUESTS, DEFAULT_PER_SECONDS, DEFAULT_RATE_LIMIT_MODE,
|
|
22
|
+
DEFAULT_TIMEOUT
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type HttpHeader = httpx.Headers | dict[ str, str ] | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass( slots= True, frozen= True )
|
|
29
|
+
class ClientIdentity:
|
|
30
|
+
"""Information used to identify the client making API requests."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
version: str
|
|
34
|
+
contact: str | None = None
|
|
35
|
+
email: str | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__( self ) -> None:
|
|
38
|
+
if not self.name.strip():
|
|
39
|
+
raise ValueError( "Client name is required." )
|
|
40
|
+
if not self.version.strip():
|
|
41
|
+
raise ValueError( "Client version is required." )
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass( slots= True, frozen= True, kw_only= True )
|
|
45
|
+
class HttpResponse:
|
|
46
|
+
"""Represents the response returned by an HTTP request."""
|
|
47
|
+
|
|
48
|
+
url: str
|
|
49
|
+
ok: bool
|
|
50
|
+
status: int
|
|
51
|
+
body: bytes
|
|
52
|
+
headers: httpx.Headers
|
|
53
|
+
latency: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HttpClient:
|
|
57
|
+
"""
|
|
58
|
+
HTTP client with built-in rate limiting and request deduplication.
|
|
59
|
+
|
|
60
|
+
Concurrent requests to the same URL share the same underlying request,
|
|
61
|
+
preventing duplicate network traffic.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, client: ClientIdentity, *,
|
|
66
|
+
base_url: str = DEFAULT_API_URL,
|
|
67
|
+
max_requests: int = DEFAULT_MAX_REQUESTS,
|
|
68
|
+
per_seconds: float = DEFAULT_PER_SECONDS,
|
|
69
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
70
|
+
) -> None:
|
|
71
|
+
self._base_url, self._client_info, self._timeout = base_url, client, timeout
|
|
72
|
+
|
|
73
|
+
self._limiter = RateLimiter( max_requests, per_seconds )
|
|
74
|
+
self._client = httpx.AsyncClient( headers= self._create_headers(), follow_redirects= True )
|
|
75
|
+
self._pending: dict[ str, asyncio.Task[ HttpResponse ] ] = {}
|
|
76
|
+
|
|
77
|
+
async def __aenter__( self ) -> Self:
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
async def __aexit__( self, *_ ) -> None:
|
|
81
|
+
await self.aclose()
|
|
82
|
+
|
|
83
|
+
def _create_headers( self ) -> httpx.Headers:
|
|
84
|
+
"""Creates the default headers sent with every request."""
|
|
85
|
+
|
|
86
|
+
client = self._client_info
|
|
87
|
+
info = "; ".join( value for value in ( client.contact, client.email ) if value )
|
|
88
|
+
|
|
89
|
+
headers = httpx.Headers( {
|
|
90
|
+
"User-Agent": (
|
|
91
|
+
f"{ client.name }/{ client.version }"
|
|
92
|
+
f"{ f' ({ info })' if info else '' }"
|
|
93
|
+
f" @rtbnext/sdk/{ __version__ }"
|
|
94
|
+
),
|
|
95
|
+
"X-Client-Name": client.name,
|
|
96
|
+
"X-Client-Version": client.version
|
|
97
|
+
} )
|
|
98
|
+
|
|
99
|
+
if client.contact:
|
|
100
|
+
headers[ "X-Client-Contact" ] = client.contact
|
|
101
|
+
|
|
102
|
+
return headers
|
|
103
|
+
|
|
104
|
+
async def _execute(
|
|
105
|
+
self, url: str, *,
|
|
106
|
+
headers: HttpHeader = None,
|
|
107
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
108
|
+
timeout: float | None = None
|
|
109
|
+
) -> HttpResponse:
|
|
110
|
+
"""Executes a single HTTP request."""
|
|
111
|
+
|
|
112
|
+
await self._limiter.acquire( mode )
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
start = perf_counter()
|
|
116
|
+
res = await self._client.get(
|
|
117
|
+
url, headers= headers, timeout= self._timeout if timeout is None else timeout
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except httpx.HTTPError as exc:
|
|
121
|
+
raise RuntimeError( f"Fetch failed: { exc }" ) from exc
|
|
122
|
+
|
|
123
|
+
return HttpResponse(
|
|
124
|
+
url= str( res.url ), ok= res.is_success, status= res.status_code, body= res.content,
|
|
125
|
+
headers= res.headers, latency= round( ( perf_counter() - start ) * 1000 )
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def request(
|
|
129
|
+
self, path: str, *,
|
|
130
|
+
headers: HttpHeader = None,
|
|
131
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
132
|
+
timeout: float | None = None
|
|
133
|
+
) -> HttpResponse:
|
|
134
|
+
"""Sends a request relative to the configured base URL."""
|
|
135
|
+
|
|
136
|
+
url = urljoin( self._base_url, path )
|
|
137
|
+
|
|
138
|
+
if task := self._pending.get( url ):
|
|
139
|
+
return await task
|
|
140
|
+
|
|
141
|
+
self._pending[ url ] = task = asyncio.create_task( self._execute(
|
|
142
|
+
url, headers= headers, mode= mode, timeout= timeout
|
|
143
|
+
) )
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return await task
|
|
147
|
+
finally:
|
|
148
|
+
self._pending.pop( url, None )
|
|
149
|
+
|
|
150
|
+
async def aclose( self ) -> None:
|
|
151
|
+
"""Closes the underlying HTTP client and release network resources."""
|
|
152
|
+
|
|
153
|
+
await self._client.aclose()
|
rtbnext/core/loader.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource State Loader
|
|
3
|
+
|
|
4
|
+
Implements the resource state loader with build-in caching and HTTP revalidation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from time import time
|
|
11
|
+
from typing import Literal, Self
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from rtbnext.core.cache import Cache, EmptyCache, MemoryCache
|
|
16
|
+
from rtbnext.core.http_client import HttpClient, HttpHeader, HttpResponse, RateLimitMode
|
|
17
|
+
from rtbnext.defaults import DEFAULT_CACHE_MODE, DEFAULT_CACHE_TYPE, DEFAULT_RATE_LIMIT_MODE
|
|
18
|
+
|
|
19
|
+
type CacheType = Cache | Literal[ "memory", False ]
|
|
20
|
+
type CacheMode = Literal[ "ttl", "session", "revalidate" ]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass( slots= True, kw_only= True )
|
|
24
|
+
class ResourceState:
|
|
25
|
+
"""Represents the state of a cached resource."""
|
|
26
|
+
|
|
27
|
+
response: HttpResponse
|
|
28
|
+
created: float
|
|
29
|
+
expires: float | None = None
|
|
30
|
+
etag: str | None = None
|
|
31
|
+
last_modified: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResourceStateLoader:
|
|
35
|
+
"""
|
|
36
|
+
Loads resource states with optional caching and HTTP revalidation.
|
|
37
|
+
|
|
38
|
+
The loader supports session, TTL and revalidation cache modes while
|
|
39
|
+
transparently handling conditional HTTP requests.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self, *,
|
|
44
|
+
client: HttpClient,
|
|
45
|
+
cache: CacheType = DEFAULT_CACHE_TYPE,
|
|
46
|
+
mode: CacheMode = DEFAULT_CACHE_MODE
|
|
47
|
+
) -> None:
|
|
48
|
+
self._client, self._mode = client, mode
|
|
49
|
+
|
|
50
|
+
match cache:
|
|
51
|
+
case "memory": self._cache = MemoryCache()
|
|
52
|
+
case False: self._cache = EmptyCache()
|
|
53
|
+
case _ if isinstance( cache, Cache ): self._cache = cache
|
|
54
|
+
case _: raise ValueError( f"Invalid cache type: { cache }" )
|
|
55
|
+
|
|
56
|
+
async def __aenter__( self ) -> Self:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __aexit__( self, *_ ) -> None:
|
|
60
|
+
await self.aclose()
|
|
61
|
+
|
|
62
|
+
def _state( self, res: HttpResponse, prev: ResourceState | None = None ) -> ResourceState:
|
|
63
|
+
"""Creates a cached resource state from an HTTP response."""
|
|
64
|
+
|
|
65
|
+
created = time()
|
|
66
|
+
|
|
67
|
+
max_age = next( (
|
|
68
|
+
int( p.split( "=" )[ 1 ] )
|
|
69
|
+
for p in res.headers.get( "Cache-Control", "" ).split( "," )
|
|
70
|
+
if p.strip().startswith( "max-age=" )
|
|
71
|
+
), None )
|
|
72
|
+
|
|
73
|
+
expires = created + max_age if max_age is not None else getattr( prev, "expires", None )
|
|
74
|
+
etag = res.headers.get( "ETag" ) or getattr( prev, "etag", None )
|
|
75
|
+
last_modified = res.headers.get( "Last-Modified" ) or getattr( prev, "last_modified", None )
|
|
76
|
+
|
|
77
|
+
if res.status == 304 and prev:
|
|
78
|
+
res = HttpResponse(
|
|
79
|
+
url= prev.response.url, ok= prev.response.ok, status= prev.response.status,
|
|
80
|
+
body= prev.response.body, headers= res.headers, latency= res.latency
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return ResourceState(
|
|
84
|
+
response= res, created= created, expires= expires,
|
|
85
|
+
etag= etag, last_modified= last_modified
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _fetch(
|
|
89
|
+
self, path: str, prev: ResourceState | None = None, *,
|
|
90
|
+
headers: HttpHeader = None,
|
|
91
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
92
|
+
timeout: float | None = None
|
|
93
|
+
) -> ResourceState:
|
|
94
|
+
"""Fetches a resource from the network."""
|
|
95
|
+
|
|
96
|
+
headers = httpx.Headers( headers or {} )
|
|
97
|
+
|
|
98
|
+
if prev and prev.etag:
|
|
99
|
+
headers[ "If-None-Match" ] = prev.etag
|
|
100
|
+
if prev and prev.last_modified:
|
|
101
|
+
headers[ "If-Modified-Since" ] = prev.last_modified
|
|
102
|
+
|
|
103
|
+
res = await self._client.request( path, headers= headers, mode= mode, timeout= timeout )
|
|
104
|
+
return self._state( res, prev )
|
|
105
|
+
|
|
106
|
+
async def refresh(
|
|
107
|
+
self, path: str, *,
|
|
108
|
+
headers: HttpHeader = None,
|
|
109
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
110
|
+
timeout: float | None = None
|
|
111
|
+
) -> ResourceState:
|
|
112
|
+
"""Refreshes a cached resource."""
|
|
113
|
+
|
|
114
|
+
state = await self._fetch(
|
|
115
|
+
path, await self._cache.get( path ),
|
|
116
|
+
headers= headers, mode= mode, timeout= timeout
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await self._cache.set( path, state )
|
|
120
|
+
return state
|
|
121
|
+
|
|
122
|
+
def _is_expired( self, state: ResourceState ) -> bool:
|
|
123
|
+
"""Determines whether a cached resource has expired."""
|
|
124
|
+
|
|
125
|
+
return state.expires is not None and state.expires <= time()
|
|
126
|
+
|
|
127
|
+
def valid( self, state: ResourceState | None ) -> bool:
|
|
128
|
+
"""Determines whether a cached resource is valid."""
|
|
129
|
+
|
|
130
|
+
return ( state is not None and ( self._mode == "session" or (
|
|
131
|
+
self._mode == "ttl" and not self._is_expired( state )
|
|
132
|
+
) ) )
|
|
133
|
+
|
|
134
|
+
async def load(
|
|
135
|
+
self, path: str, *,
|
|
136
|
+
headers: HttpHeader = None,
|
|
137
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
138
|
+
timeout: float | None = None
|
|
139
|
+
) -> ResourceState:
|
|
140
|
+
"""Loads a resource using the configured cache policy."""
|
|
141
|
+
|
|
142
|
+
if self._mode == "revalidate":
|
|
143
|
+
return await self.refresh( path, headers= headers, mode= mode, timeout= timeout )
|
|
144
|
+
|
|
145
|
+
if ( cached := await self._cache.get( path ) ) and self.valid( cached ):
|
|
146
|
+
return cached
|
|
147
|
+
|
|
148
|
+
state = await self._fetch( path, None, headers= headers, mode= mode, timeout= timeout )
|
|
149
|
+
if self._mode == "session" or state.expires:
|
|
150
|
+
await self._cache.set( path, state )
|
|
151
|
+
|
|
152
|
+
return state
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def size( self ) -> int:
|
|
156
|
+
"""Returns the number of cached resource states."""
|
|
157
|
+
|
|
158
|
+
return self._cache.size
|
|
159
|
+
|
|
160
|
+
async def delete( self, path: str ) -> None:
|
|
161
|
+
"""Removes a resource from the cache."""
|
|
162
|
+
|
|
163
|
+
await self._cache.delete( path )
|
|
164
|
+
|
|
165
|
+
async def clear( self ) -> None:
|
|
166
|
+
"""Removes all cached resource states."""
|
|
167
|
+
|
|
168
|
+
await self._cache.clear()
|
|
169
|
+
|
|
170
|
+
async def aclose( self ) -> None:
|
|
171
|
+
"""Closes the underlying HTTP client and release network resources."""
|
|
172
|
+
|
|
173
|
+
await self._client.aclose()
|
rtbnext/core/parser.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parser
|
|
3
|
+
|
|
4
|
+
Implements the HTTP response body parser methods for simple text,
|
|
5
|
+
JSON responses and CSV data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from typing import Callable, Generic, Literal, TypeVar
|
|
14
|
+
|
|
15
|
+
from rtbnext.core.http_client import HttpResponse
|
|
16
|
+
|
|
17
|
+
D = TypeVar( "D" )
|
|
18
|
+
|
|
19
|
+
type ParserFn[ D ] = Callable[ [ HttpResponse ], D ]
|
|
20
|
+
type ParserMode = Literal[ "text", "json", "csv" ]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Parser( Generic[ D ] ):
|
|
24
|
+
"""
|
|
25
|
+
Parses HTTP response bodies into text or objects.
|
|
26
|
+
|
|
27
|
+
The parser validates the response status and ensures that
|
|
28
|
+
the response contains data before decoding it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _number( value: str ) -> str | int | float:
|
|
33
|
+
"""Parses an individual CSV field value, converting numeric strings to numbers."""
|
|
34
|
+
|
|
35
|
+
value = value.strip()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
return float( value ) if "." in value else int( value ) if value else value
|
|
39
|
+
except ValueError:
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def text( res: HttpResponse ) -> str:
|
|
44
|
+
"""Converts an HTTP response body to UTF-8 text."""
|
|
45
|
+
|
|
46
|
+
if not res.ok:
|
|
47
|
+
raise RuntimeError( f"Request failed with status { res.status }." )
|
|
48
|
+
if not res.body:
|
|
49
|
+
raise RuntimeError( "Response contains no data." )
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return res.body.decode()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise RuntimeError( f"Failed to decode response: { exc }" ) from exc
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def json( res: HttpResponse ) -> D:
|
|
58
|
+
"""Parses an HTTP response body as JSON."""
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
return json.loads( Parser.text( res ) )
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
raise RuntimeError( f"Failed to parse JSON: { exc }" ) from exc
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def csv( res: HttpResponse, delimiter: str = "," ) -> list[ list[ str | int | float ] ]:
|
|
67
|
+
"""Parses an HTTP response body as CSV."""
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
[ Parser._number( value ) for value in row ]
|
|
71
|
+
for row in csv.reader( StringIO( Parser.text( res ) ), delimiter= delimiter )
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parser( mode: ParserMode ) -> ParserFn[ D ]:
|
|
76
|
+
"""
|
|
77
|
+
Returns the parser function based on the given mode.
|
|
78
|
+
Raises if the parser for the given mode is not implemented.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if not hasattr( Parser, mode ):
|
|
82
|
+
raise ValueError( f"Unsupported parser mode: { mode }" )
|
|
83
|
+
|
|
84
|
+
return getattr( Parser, mode )
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate Limiter
|
|
3
|
+
|
|
4
|
+
Implements a rate limiter supporting burst and spread strategies.
|
|
5
|
+
|
|
6
|
+
Supports two strategies:
|
|
7
|
+
- `burst`: allows short bursts of requests
|
|
8
|
+
- `spread`: distributes requests evenly over time
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from collections import deque
|
|
15
|
+
from time import monotonic
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from rtbnext.defaults import DEFAULT_MAX_REQUESTS, DEFAULT_PER_SECONDS
|
|
19
|
+
|
|
20
|
+
type RateLimitMode = Literal[ "burst", "spread" ]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimiter:
|
|
24
|
+
"""
|
|
25
|
+
Rate limitting API requests.
|
|
26
|
+
|
|
27
|
+
Async rate limiter supporting burst and spread strategies:
|
|
28
|
+
- `burst`: allows short bursts of requests
|
|
29
|
+
- `spread`: distributes requests evenly over time
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
max_requests: int = DEFAULT_MAX_REQUESTS,
|
|
35
|
+
per_seconds: float = DEFAULT_PER_SECONDS
|
|
36
|
+
) -> None:
|
|
37
|
+
self._max_requests, self._per_seconds = max_requests, per_seconds
|
|
38
|
+
self._interval = self._per_seconds / max_requests
|
|
39
|
+
|
|
40
|
+
self._burst = deque[ float ]()
|
|
41
|
+
self._next_allowed = 0.0
|
|
42
|
+
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
|
|
45
|
+
async def _spread_refill( self ) -> None:
|
|
46
|
+
"""Waits until the next evenly distributed request is allowed."""
|
|
47
|
+
|
|
48
|
+
async with self._lock:
|
|
49
|
+
now = monotonic()
|
|
50
|
+
self._next_allowed = max( self._next_allowed, now )
|
|
51
|
+
wait = self._next_allowed - now
|
|
52
|
+
self._next_allowed += self._interval
|
|
53
|
+
|
|
54
|
+
if wait > 0:
|
|
55
|
+
await asyncio.sleep( wait )
|
|
56
|
+
|
|
57
|
+
async def burst( self ) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Allows up to max_requests immediately.
|
|
60
|
+
Further requests wait until the rolling window expires.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
async with self._lock:
|
|
65
|
+
now = monotonic()
|
|
66
|
+
|
|
67
|
+
while self._burst and now - self._burst[ 0 ] >= self._per_seconds:
|
|
68
|
+
self._burst.popleft()
|
|
69
|
+
|
|
70
|
+
if len( self._burst ) < self._max_requests:
|
|
71
|
+
self._burst.append( now )
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
wait = self._per_seconds - ( now - self._burst[ 0 ] )
|
|
75
|
+
|
|
76
|
+
await asyncio.sleep( wait )
|
|
77
|
+
|
|
78
|
+
async def spread( self ) -> None:
|
|
79
|
+
"""Distributes requests evenly across the configured time window."""
|
|
80
|
+
|
|
81
|
+
await self._spread_refill()
|
|
82
|
+
|
|
83
|
+
async def acquire( self, mode: RateLimitMode = "burst" ) -> None:
|
|
84
|
+
"""Acquires permission to perform a request."""
|
|
85
|
+
|
|
86
|
+
await getattr( self, mode )()
|
rtbnext/defaults.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defaults
|
|
3
|
+
|
|
4
|
+
Declares default values for options.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from rtbnext.core.loader import CacheMode, CacheType
|
|
13
|
+
from rtbnext.core.rate_limiter import RateLimitMode
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_URL: str = "https://api.rtbnext.de"
|
|
16
|
+
|
|
17
|
+
DEFAULT_MAX_REQUESTS: int = 60
|
|
18
|
+
DEFAULT_PER_SECONDS: float = 10.0
|
|
19
|
+
DEFAULT_RATE_LIMIT_MODE: RateLimitMode = "burst"
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT: float = 30.0
|
|
22
|
+
|
|
23
|
+
DEFAULT_CACHE_TYPE: CacheType = "memory"
|
|
24
|
+
DEFAULT_CACHE_MODE: CacheMode = "ttl"
|
|
25
|
+
|
|
26
|
+
DEFAULT_PER_PAGE: int = 10
|