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 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
@@ -0,0 +1,7 @@
1
+ """
2
+ Version
3
+
4
+ Declares the RTBNext Python SDK version.
5
+ """
6
+
7
+ __version__ = "1.0.0"
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