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.
@@ -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" ] ) )