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/resource/base.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Resource
|
|
3
|
+
|
|
4
|
+
Implements the base resource wrapper for HTTP responses.
|
|
5
|
+
Resource instance pooling is intended to optimize memory allocation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any, Awaitable, Callable, Generic, Self, TypeVar
|
|
12
|
+
|
|
13
|
+
from rtbnext.core.http_client import HttpHeader
|
|
14
|
+
from rtbnext.core.loader import ResourceState, ResourceStateLoader
|
|
15
|
+
from rtbnext.core.parser import ParserFn
|
|
16
|
+
from rtbnext.core.rate_limiter import RateLimitMode
|
|
17
|
+
from rtbnext.defaults import DEFAULT_RATE_LIMIT_MODE
|
|
18
|
+
|
|
19
|
+
D = TypeVar( "D" )
|
|
20
|
+
R = TypeVar( "R", bound= "Resource[ Any ]" )
|
|
21
|
+
|
|
22
|
+
type TransformFn[ D ] = Callable[ [ D ], Awaitable[ Any ] | Any ]
|
|
23
|
+
|
|
24
|
+
_EMPTY_HOOKS: frozenset = frozenset()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Resource( Generic[ D ] ):
|
|
28
|
+
"""
|
|
29
|
+
Base resource wrapper for lazy loading, parsing and cache state management.
|
|
30
|
+
|
|
31
|
+
Resources are loaded only when data is requested. Loaded responses are kept
|
|
32
|
+
as resource state while parsed values are cached separately.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__( self, path: str, loader: ResourceStateLoader, parser: ParserFn[ D ] ) -> None:
|
|
36
|
+
self._path, self._loader, self._parser = path, loader, parser
|
|
37
|
+
|
|
38
|
+
self._hooks: dict[ str, set[ Callable[ [ Self ], None ] ] ] = {}
|
|
39
|
+
|
|
40
|
+
self._state: ResourceState | None = None
|
|
41
|
+
self._loading: asyncio.Task[ None ] | None = None
|
|
42
|
+
self._loaded = False
|
|
43
|
+
|
|
44
|
+
self._parsed = False
|
|
45
|
+
self._value: D | None = None
|
|
46
|
+
|
|
47
|
+
self._transformed: Any | Awaitable[ Any ] | None = None
|
|
48
|
+
|
|
49
|
+
def _emit( self, *events: str ) -> None:
|
|
50
|
+
"""Emits lifecycle events."""
|
|
51
|
+
|
|
52
|
+
for event in events:
|
|
53
|
+
for handler in self._hooks.get( event, _EMPTY_HOOKS ):
|
|
54
|
+
handler( self )
|
|
55
|
+
|
|
56
|
+
def _reset( self ) -> None:
|
|
57
|
+
"""Resets parsed values after loading or refreshing."""
|
|
58
|
+
|
|
59
|
+
self._parsed = False
|
|
60
|
+
self._value = None
|
|
61
|
+
self._transformed = None
|
|
62
|
+
|
|
63
|
+
def _parse( self ) -> D:
|
|
64
|
+
"""Parses the loaded HTTP response."""
|
|
65
|
+
|
|
66
|
+
if self._state is None:
|
|
67
|
+
raise RuntimeError( "Resource has not been loaded." )
|
|
68
|
+
|
|
69
|
+
self._value = self._parser( self._state.response )
|
|
70
|
+
self._parsed = True
|
|
71
|
+
self._emit( "parse" )
|
|
72
|
+
|
|
73
|
+
return self._value
|
|
74
|
+
|
|
75
|
+
async def _transform( self, fn: TransformFn[ D ] ) -> Any:
|
|
76
|
+
"""Transforms parsed resource data and cache the result."""
|
|
77
|
+
|
|
78
|
+
if self._transformed is None:
|
|
79
|
+
async def execute () -> Any:
|
|
80
|
+
value = fn( await self.data() )
|
|
81
|
+
|
|
82
|
+
if asyncio.iscoroutine( value ):
|
|
83
|
+
value = await value
|
|
84
|
+
|
|
85
|
+
self._transformed = value
|
|
86
|
+
self._emit( "transform" )
|
|
87
|
+
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
self._transformed = asyncio.create_task( execute() )
|
|
91
|
+
|
|
92
|
+
return await self._transformed
|
|
93
|
+
|
|
94
|
+
def _value_or_raise( self ) -> D:
|
|
95
|
+
"""Returns the parsed resource value or raise an error if unavailable."""
|
|
96
|
+
|
|
97
|
+
if self._value is None:
|
|
98
|
+
raise RuntimeError( "Resource value unavailable." )
|
|
99
|
+
|
|
100
|
+
return self._value
|
|
101
|
+
|
|
102
|
+
def on( self, event: str, handler: Callable[ [ Self ], None ] ) -> Self:
|
|
103
|
+
"""Registers an event handler."""
|
|
104
|
+
|
|
105
|
+
self._hooks.setdefault( event, set() ).add( handler )
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def off( self, event: str, handler: Callable[ [ Self ], None ] ) -> Self:
|
|
109
|
+
"""Removes an event handler."""
|
|
110
|
+
|
|
111
|
+
self._hooks.get( event, set() ).discard( handler )
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def valid( self ) -> bool:
|
|
116
|
+
"""Returns whether the current resource state is still valid."""
|
|
117
|
+
|
|
118
|
+
return not self._loaded or self._state is None or self._loader.valid( self._state )
|
|
119
|
+
|
|
120
|
+
async def load(
|
|
121
|
+
self, *,
|
|
122
|
+
headers: HttpHeader = None,
|
|
123
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
124
|
+
timeout: float | None = None
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Loads the resource if it has not already been loaded."""
|
|
127
|
+
|
|
128
|
+
if self._loaded:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if self._loading is None:
|
|
132
|
+
async def execute() -> None:
|
|
133
|
+
try:
|
|
134
|
+
self._state = await self._loader.load(
|
|
135
|
+
self._path, headers= headers, mode= mode, timeout= timeout
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
self._loaded = True
|
|
139
|
+
self._reset()
|
|
140
|
+
self._emit( "load", "update" )
|
|
141
|
+
|
|
142
|
+
finally:
|
|
143
|
+
self._loading = None
|
|
144
|
+
|
|
145
|
+
self._loading = asyncio.create_task( execute() )
|
|
146
|
+
|
|
147
|
+
await self._loading
|
|
148
|
+
|
|
149
|
+
async def refresh(
|
|
150
|
+
self, *,
|
|
151
|
+
headers: HttpHeader = None,
|
|
152
|
+
mode: RateLimitMode = DEFAULT_RATE_LIMIT_MODE,
|
|
153
|
+
timeout: float | None = None
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Refreshes the resource from the network."""
|
|
156
|
+
|
|
157
|
+
self._state = await self._loader.refresh(
|
|
158
|
+
self._path, headers= headers, mode= mode, timeout= timeout
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._loaded = True
|
|
162
|
+
self._reset()
|
|
163
|
+
self._emit( "refresh", "update" )
|
|
164
|
+
|
|
165
|
+
async def data( self ) -> D:
|
|
166
|
+
"""Returns parsed resource data."""
|
|
167
|
+
|
|
168
|
+
await self.load()
|
|
169
|
+
return self._parse() if not self._parsed else self._value_or_raise()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ResourcePool( Generic[ R ] ):
|
|
173
|
+
"""
|
|
174
|
+
Stores and reuses resource instances by their resource path.
|
|
175
|
+
|
|
176
|
+
Valid resources are reused, while invalid resources are replaced with
|
|
177
|
+
newly created instances.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__( self ) -> None:
|
|
181
|
+
self._resources: dict[ str, R ] = {}
|
|
182
|
+
|
|
183
|
+
def get( self, path: str, factory: Callable[ [], R ] ) -> R:
|
|
184
|
+
"""Returns an existing valid resource or create a new one."""
|
|
185
|
+
|
|
186
|
+
if ( res := self._resources.get( path ) ) and res.valid:
|
|
187
|
+
return res
|
|
188
|
+
|
|
189
|
+
self._resources[ path ] = res = factory()
|
|
190
|
+
return res
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def size( self ) -> int:
|
|
194
|
+
"""Returns the number of pooled resources."""
|
|
195
|
+
|
|
196
|
+
return len( self._resources )
|
|
197
|
+
|
|
198
|
+
def delete( self, path: str ) -> None:
|
|
199
|
+
"""Deletes a resource from the cache by path."""
|
|
200
|
+
|
|
201
|
+
self._resources.pop( path, None )
|
|
202
|
+
|
|
203
|
+
def clear( self ) -> None:
|
|
204
|
+
"""Removes all pooled resources."""
|
|
205
|
+
|
|
206
|
+
self._resources.clear()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collectable Resource
|
|
3
|
+
|
|
4
|
+
Implements the resource wrapper for collectable endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Callable, Generic, Self, TypedDict, TypeVar
|
|
11
|
+
|
|
12
|
+
from rtbnext.core.loader import ResourceStateLoader
|
|
13
|
+
from rtbnext.core.parser import ParserFn
|
|
14
|
+
from rtbnext.resource.base import Resource
|
|
15
|
+
from rtbnext.resource.collection import CursorCollectionBase, ItemFactory
|
|
16
|
+
from rtbnext.utils import sanitize
|
|
17
|
+
|
|
18
|
+
CollectItem = TypedDict( "CollectItem", { "uri": str } )
|
|
19
|
+
|
|
20
|
+
class CollectData[ T: CollectItem ]( TypedDict ):
|
|
21
|
+
"""Ensure `items` is a list of collect items."""
|
|
22
|
+
items: list[ T ]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
D = TypeVar( "D", bound= CollectData )
|
|
26
|
+
I = TypeVar( "I", bound= CollectItem )
|
|
27
|
+
E = TypeVar( "E" )
|
|
28
|
+
K = TypeVar( "K" )
|
|
29
|
+
|
|
30
|
+
type EntityFn[ I, E ] = ItemFactory[ I, E ]
|
|
31
|
+
type FindFn[ I ] = Callable[ [ list[ I ], str ], I | None ]
|
|
32
|
+
type SearchFn[ I ] = Callable[ [ I, str, list[ str ] ], bool ]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CollectCollection( CursorCollectionBase[ I, E ], Generic[ I, E ] ):
|
|
36
|
+
"""
|
|
37
|
+
Collection wrapper for entity-based resources.
|
|
38
|
+
|
|
39
|
+
This class extends the cursor-based collection with searching,
|
|
40
|
+
filtering, grouping, sorting and set operations.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, items: list[ I ], *,
|
|
45
|
+
factory: EntityFn[ I, E ],
|
|
46
|
+
total: int | None = None,
|
|
47
|
+
find: FindFn[ I ] | None = None,
|
|
48
|
+
search: SearchFn[ I ] | None = None
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__( items, factory= factory, total= total )
|
|
51
|
+
|
|
52
|
+
self._find = find or self._default_find
|
|
53
|
+
self._search = search or self._default_search
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _default_find( items: list[ I ], uri_like: str ) -> I | None:
|
|
57
|
+
"""Returns the first item matching a URI-like string."""
|
|
58
|
+
|
|
59
|
+
uri = sanitize( uri_like )
|
|
60
|
+
return next( ( item for item in items if item[ "uri" ] == uri ), None )
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _default_search( item: I, query: str, terms: list[ str ] ) -> bool:
|
|
64
|
+
"""Returns whether an item matches a search query."""
|
|
65
|
+
|
|
66
|
+
name = item.get( "searchName" ) or sanitize( item.get( "name", "" ) )
|
|
67
|
+
text = item.get( "text", "" )
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
query in name or query in text
|
|
71
|
+
or all( term in name or term in text for term in terms )
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _clone( self, items: list[ I ] ) -> Self:
|
|
75
|
+
"""Creates a new collection preserving configuration."""
|
|
76
|
+
|
|
77
|
+
return self.__class__(
|
|
78
|
+
items, factory= self._factory, total= self._total,
|
|
79
|
+
find= self._find, search= self._search
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def get( self, uri: str ) -> E | None:
|
|
83
|
+
"""Returns an item by its exact URI."""
|
|
84
|
+
|
|
85
|
+
return next( (
|
|
86
|
+
self._factory( item ) for item in self._items if item[ "uri" ] == uri
|
|
87
|
+
), None )
|
|
88
|
+
|
|
89
|
+
def filter( self, predicate: Callable[ [ I ], bool ] ) -> Self:
|
|
90
|
+
"""Returns items matching a predicate."""
|
|
91
|
+
|
|
92
|
+
return self._clone( [ item for item in self._items if predicate( item ) ] )
|
|
93
|
+
|
|
94
|
+
def find( self, uri_like: str ) -> E | None:
|
|
95
|
+
"""Returns the first matching URI-like item."""
|
|
96
|
+
|
|
97
|
+
if item := self._find( self._items, uri_like ):
|
|
98
|
+
return self._factory( item )
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def search( self, query: str ) -> Self:
|
|
104
|
+
"""Returns items matching a search query."""
|
|
105
|
+
|
|
106
|
+
query, terms = sanitize( query ), query.split()
|
|
107
|
+
|
|
108
|
+
return self._clone( [
|
|
109
|
+
item for item in self._items
|
|
110
|
+
if self._search( item, query, terms )
|
|
111
|
+
] )
|
|
112
|
+
|
|
113
|
+
def intersect( self, other: Self ) -> Self:
|
|
114
|
+
"""Returns items also contained in another collection."""
|
|
115
|
+
|
|
116
|
+
uris = { item[ "uri" ] for item in other.items }
|
|
117
|
+
return self._clone( [ item for item in self._items if item[ "uri" ] in uris ] )
|
|
118
|
+
|
|
119
|
+
def exclude( self, other: Self ) -> Self:
|
|
120
|
+
"""Returns items not contained in another collection."""
|
|
121
|
+
|
|
122
|
+
uris = { item[ "uri" ] for item in other.items }
|
|
123
|
+
return self._clone( [ item for item in self._items if item[ "uri" ] not in uris ] )
|
|
124
|
+
|
|
125
|
+
def union( self, other: Self ) -> Self:
|
|
126
|
+
"""Returns the union of two collections."""
|
|
127
|
+
|
|
128
|
+
seen: set[ str ] = set()
|
|
129
|
+
merged: list[ I ] = []
|
|
130
|
+
|
|
131
|
+
for item in [ *self._items, *other.items ]:
|
|
132
|
+
if item[ "uri" ] not in seen:
|
|
133
|
+
seen.add( item[ "uri" ] )
|
|
134
|
+
merged.append( item )
|
|
135
|
+
|
|
136
|
+
return self._clone( merged )
|
|
137
|
+
|
|
138
|
+
def group_by( self, callback: Callable[ [ I ], K ] ) -> dict[ K, Self ]:
|
|
139
|
+
"""Groups items using a callback."""
|
|
140
|
+
|
|
141
|
+
groups: defaultdict[ K, list[ I ] ] = defaultdict( list )
|
|
142
|
+
|
|
143
|
+
for item in self._items:
|
|
144
|
+
groups[ callback( item ) ].append( item )
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
key: self._clone( values )
|
|
148
|
+
for key, values in groups.items()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def order_by( self, key: str, descending: bool = False ) -> Self:
|
|
152
|
+
"""Returns items ordered by a dictionary key."""
|
|
153
|
+
|
|
154
|
+
return self._clone( sorted(
|
|
155
|
+
self._items,
|
|
156
|
+
key= lambda item: ( item.get( key ) is None, item.get( key ) ),
|
|
157
|
+
reverse= descending
|
|
158
|
+
) )
|
|
159
|
+
|
|
160
|
+
def sort(
|
|
161
|
+
self, *,
|
|
162
|
+
key: Callable[ [ I ], str | int | float | bool ],
|
|
163
|
+
reverse: bool = False
|
|
164
|
+
) -> Self:
|
|
165
|
+
"""Returns items sorted using a custom key."""
|
|
166
|
+
|
|
167
|
+
return self._clone( sorted( self._items, key= key, reverse= reverse ) )
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CollectableResource( Resource[ D ], Generic[ D, I, E ] ):
|
|
171
|
+
"""
|
|
172
|
+
Resource wrapper for collection-oriented endpoints.
|
|
173
|
+
|
|
174
|
+
This class converts raw API items into entity objects and exposes them
|
|
175
|
+
through a lazily created collection supporting filtering, searching,
|
|
176
|
+
sorting and paging operations.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self, path: str, loader: ResourceStateLoader, parser: ParserFn[ D ], *,
|
|
181
|
+
entity: EntityFn[ I, E ],
|
|
182
|
+
find: FindFn[ I ] | None = None,
|
|
183
|
+
search: SearchFn[ I ] | None = None
|
|
184
|
+
) -> None:
|
|
185
|
+
super().__init__( path, loader, parser )
|
|
186
|
+
|
|
187
|
+
self._entity = entity
|
|
188
|
+
self._find = find
|
|
189
|
+
self._search = search
|
|
190
|
+
|
|
191
|
+
def _collect( self, items: list[ I ] ) -> CollectCollection[ I, E ]:
|
|
192
|
+
"""Creates a collection from collectable items."""
|
|
193
|
+
|
|
194
|
+
return CollectCollection(
|
|
195
|
+
items, factory= self._entity, find= self._find, search= self._search
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def collection( self ) -> CollectCollection[ I, E ]:
|
|
199
|
+
"""Returns the collection."""
|
|
200
|
+
|
|
201
|
+
return await self._transform( lambda data: self._collect( data[ "items" ] ) )
|