Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0059.py @ 4356:c9626f46b63e
plugin XEP-0059: Use Pydantic models for RSM.
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 11 Apr 2025 18:19:28 +0200 |
parents | 0d7bb4df2343 |
children |
comparison
equal
deleted
inserted
replaced
4355:01ee3b902d33 | 4356:c9626f46b63e |
---|---|
15 # GNU Affero General Public License for more details. | 15 # GNU Affero General Public License for more details. |
16 | 16 |
17 # You should have received a copy of the GNU Affero General Public License | 17 # You should have received a copy of the GNU Affero General Public License |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | 18 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
19 | 19 |
20 from typing import Optional | 20 from typing import Self |
21 from pydantic import BaseModel, Field, field_validator | |
21 from zope.interface import implementer | 22 from zope.interface import implementer |
22 from twisted.words.protocols.jabber import xmlstream | 23 from twisted.words.protocols.jabber import xmlstream |
23 from wokkel import disco | 24 from twisted.words.xish import domish |
24 from wokkel import iwokkel | 25 from wokkel import disco, iwokkel, rsm |
25 from wokkel import rsm | |
26 from libervia.backend.core.i18n import _ | 26 from libervia.backend.core.i18n import _ |
27 from libervia.backend.core.constants import Const as C | 27 from libervia.backend.core.constants import Const as C |
28 from libervia.backend.core.log import getLogger | 28 from libervia.backend.core.log import getLogger |
29 | 29 |
30 | 30 |
43 } | 43 } |
44 | 44 |
45 RSM_PREFIX = "rsm_" | 45 RSM_PREFIX = "rsm_" |
46 | 46 |
47 | 47 |
48 class XEP_0059(object): | 48 class RSMRequest(BaseModel): |
49 # XXX: RSM management is done directly in Wokkel. | 49 """Pydantic model for RSM request parameters""" |
50 | 50 max: int = Field(default=10, gt=0) |
51 def __init__(self, host): | 51 after: str | None = None |
52 log.info(_("Result Set Management plugin initialization")) | 52 before: str | None = None |
53 | 53 index: int | None = None |
54 def get_handler(self, client): | 54 |
55 @field_validator('after') | |
56 def check_after_not_empty(cls, v: str | None) -> str | None: | |
57 """Validate that after value isn't empty string | |
58 | |
59 Note: Empty before is allowed (means "last page") but empty after is not | |
60 @param v: value to validate | |
61 @return: validated value | |
62 @raise ValueError: if value is an empty string | |
63 """ | |
64 if v == "": | |
65 raise ValueError("RSM \"after\" can't be empty") | |
66 return v | |
67 | |
68 def to_wokkel_request(self) -> rsm.RSMRequest: | |
69 """Convert to wokkel RSMRequest | |
70 | |
71 @return: wokkel RSMRequest instance | |
72 """ | |
73 return rsm.RSMRequest( | |
74 max_=self.max, | |
75 after=self.after, | |
76 before=self.before, | |
77 index=self.index | |
78 ) | |
79 | |
80 @classmethod | |
81 def from_wokkel_request(cls, request: rsm.RSMRequest) -> Self: | |
82 """Create from wokkel RSMRequest | |
83 | |
84 @param request: wokkel RSMRequest to convert | |
85 @return: RSMRequestModel instance | |
86 """ | |
87 return cls( | |
88 max=request.max, | |
89 after=request.after, | |
90 before=request.before, | |
91 index=request.index | |
92 ) | |
93 | |
94 def to_element(self) -> domish.Element: | |
95 """Convert to domish.Element | |
96 | |
97 @return: XML element representing the RSM request | |
98 """ | |
99 return self.to_wokkel_request().toElement() | |
100 | |
101 @classmethod | |
102 def from_element(cls, element: domish.Element) -> Self: | |
103 """Create from domish.Element | |
104 | |
105 @param element: XML element to parse | |
106 @return: RSMRequestModel instance | |
107 @raise ValueError: if the element is invalid | |
108 """ | |
109 try: | |
110 wokkel_req = rsm.RSMRequest.fromElement(element) | |
111 except rsm.RSMNotFoundError: | |
112 raise ValueError("No RSM set element found") | |
113 except rsm.RSMError as e: | |
114 raise ValueError(str(e)) | |
115 return cls.from_wokkel_request(wokkel_req) | |
116 | |
117 | |
118 class RSMResponse(BaseModel): | |
119 """Pydantic model for RSM response parameters""" | |
120 first: str | None = None | |
121 last: str | None = None | |
122 index: int | None = None | |
123 count: int | None = None | |
124 | |
125 def to_wokkel_response(self) -> rsm.RSMResponse: | |
126 """Convert to wokkel RSMResponse | |
127 | |
128 @return: wokkel RSMResponse instance | |
129 """ | |
130 return rsm.RSMResponse( | |
131 first=self.first, | |
132 last=self.last, | |
133 index=self.index, | |
134 count=self.count | |
135 ) | |
136 | |
137 @classmethod | |
138 def from_wokkel_response(cls, response: rsm.RSMResponse) -> Self: | |
139 """Create from wokkel RSMResponse | |
140 | |
141 @param response: wokkel RSMResponse to convert | |
142 @return: RSMResponseModel instance | |
143 """ | |
144 return cls( | |
145 first=response.first, | |
146 last=response.last, | |
147 index=response.index, | |
148 count=response.count | |
149 ) | |
150 | |
151 def to_element(self) -> domish.Element: | |
152 """Convert to domish.Element | |
153 | |
154 @return: XML element representing the RSM response | |
155 """ | |
156 return self.to_wokkel_response().toElement() | |
157 | |
158 @classmethod | |
159 def from_element(cls, element: domish.Element) -> Self: | |
160 """Create from domish.Element | |
161 | |
162 @param element: XML element to parse | |
163 @return: RSMResponseModel instance | |
164 @raise ValueError: if the element is invalid | |
165 """ | |
166 try: | |
167 wokkel_resp = rsm.RSMResponse.fromElement(element) | |
168 except rsm.RSMNotFoundError: | |
169 raise ValueError("No RSM set element found") | |
170 except rsm.RSMError as e: | |
171 raise ValueError(str(e)) | |
172 return cls.from_wokkel_response(wokkel_resp) | |
173 | |
174 | |
175 class XEP_0059: | |
176 def __init__(self, host: str) -> None: | |
177 """Initialize the RSM plugin | |
178 | |
179 @param host: host instance | |
180 """ | |
181 log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.") | |
182 | |
183 def get_handler(self, client) -> 'XEP_0059_handler': | |
184 """Get the XMPP handler for this plugin | |
185 | |
186 @param client: client instance | |
187 @return: XEP_0059_handler instance | |
188 """ | |
55 return XEP_0059_handler() | 189 return XEP_0059_handler() |
56 | 190 |
57 def parse_extra(self, extra): | 191 def parse_extra(self, extra: dict[str, str]) -> rsm.RSMRequest | None: |
58 """Parse extra dictionnary to retrieve RSM arguments | 192 """Parse extra dictionnary to retrieve RSM arguments |
59 | 193 |
60 @param extra(dict): data for parse | 194 @param extra: data to parse |
61 @return (rsm.RSMRequest, None): request with parsed arguments | 195 @return: request with parsed arguments or None if no RSM arguments found |
62 or None if no RSM arguments have been found | 196 @raise ValueError: if rsm_max is negative |
63 """ | 197 """ |
64 if int(extra.get(RSM_PREFIX + "max", 0)) < 0: | 198 if int(extra.get(f"{RSM_PREFIX}max", 0)) < 0: |
65 raise ValueError(_("rsm_max can't be negative")) | 199 raise ValueError(_("rsm_max can't be negative")) |
66 | 200 |
67 rsm_args = {} | 201 rsm_args = {} |
68 for arg in ("max", "after", "before", "index"): | 202 for arg in ("max", "after", "before", "index"): |
69 try: | 203 try: |
70 argname = "max_" if arg == "max" else arg | 204 argname = "max_" if arg == "max" else arg |
71 rsm_args[argname] = extra.pop(RSM_PREFIX + arg) | 205 rsm_args[argname] = extra.pop(f"{RSM_PREFIX}{arg}") |
72 except KeyError: | 206 except KeyError: |
73 continue | 207 continue |
74 | 208 |
75 if rsm_args: | 209 return RSMRequest(**rsm_args).to_wokkel_request() if rsm_args else None |
76 return rsm.RSMRequest(**rsm_args) | 210 |
77 else: | 211 def response2dict( |
78 return None | 212 self, |
79 | 213 rsm_response: rsm.RSMResponse, |
80 def response2dict(self, rsm_response, data=None): | 214 data: dict[str, str] | None = None |
81 """Return a dict with RSM response | 215 ) -> dict[str, str]: |
216 """Return a dict with RSM response data | |
82 | 217 |
83 Key set in data can be: | 218 Key set in data can be: |
84 - rsm_first: first item id in the page | 219 - first: first item id in the page |
85 - rsm_last: last item id in the page | 220 - last: last item id in the page |
86 - rsm_index: position of the first item in the full set (may be approximate) | 221 - index: position of the first item in the full set |
87 - rsm_count: total number of items in the full set (may be approximage) | 222 - count: total number of items in the full set |
88 If a value doesn't exists, it's not set. | 223 If a value doesn't exist, it's not set. |
89 All values are set as strings. | 224 All values are set as strings. |
90 @param rsm_response(rsm.RSMResponse): response to parse | 225 |
91 @param data(dict, None): dict to update with rsm_* data. | 226 @param rsm_response: response to parse |
92 If None, a new dict is created | 227 @param data: dict to update with rsm data. If None, a new dict is created |
93 @return (dict): data dict | 228 @return: data dict with RSM values |
94 """ | 229 """ |
230 # FIXME: This method should not be used anymore, and removed once replace in | |
231 # XEP-0313 plugin. | |
95 if data is None: | 232 if data is None: |
96 data = {} | 233 data = {} |
97 if rsm_response.first is not None: | 234 model = RSMResponse.from_wokkel_response(rsm_response) |
98 data["first"] = rsm_response.first | 235 |
99 if rsm_response.last is not None: | 236 if model.first is not None: |
100 data["last"] = rsm_response.last | 237 data["first"] = model.first |
101 if rsm_response.index is not None: | 238 if model.last is not None: |
102 data["index"] = rsm_response.index | 239 data["last"] = model.last |
240 if model.index is not None: | |
241 data["index"] = str(model.index) | |
242 if model.count is not None: | |
243 data["count"] = str(model.count) | |
244 | |
103 return data | 245 return data |
104 | 246 |
105 def get_next_request( | 247 def get_next_request( |
106 self, | 248 self, |
107 rsm_request: rsm.RSMRequest, | 249 rsm_request: RSMRequest, |
108 rsm_response: rsm.RSMResponse, | 250 rsm_response: RSMResponse, |
109 log_progress: bool = True, | 251 log_progress: bool = True, |
110 ) -> Optional[rsm.RSMRequest]: | 252 ) -> RSMRequest | None: |
111 """Generate next request to paginate through all items | 253 """Generate the request for the next page of items. |
112 | 254 |
113 Page will be retrieved forward | 255 Page will be retrieved forward. |
114 @param rsm_request: last request used | 256 @param rsm_request: last request used |
115 @param rsm_response: response from the last request | 257 @param rsm_response: response from the last request |
116 @return: request to retrive next page, or None if we are at the end | 258 @param log_progress: whether to log progress information |
117 or if pagination is not possible | 259 @return: request to retrieve next page, or None if we are at the end |
260 or if pagination is not possible. | |
118 """ | 261 """ |
119 if rsm_request.max == 0: | 262 if rsm_request.max == 0: |
120 log.warning("Can't do pagination if max is 0") | 263 log.warning("Can't do pagination if max is 0") |
121 return None | 264 return None |
122 if rsm_response is None: | 265 |
123 # may happen if result set it empty, or we are at the end | |
124 return None | |
125 if rsm_response.count is not None and rsm_response.index is not None: | 266 if rsm_response.count is not None and rsm_response.index is not None: |
126 next_index = rsm_response.index + rsm_request.max | 267 next_index = rsm_response.index + rsm_request.max |
127 if next_index >= rsm_response.count: | 268 if next_index >= rsm_response.count: |
128 # we have reached the last page | 269 # We have reached the last page. |
129 return None | 270 return None |
130 | 271 |
131 if log_progress: | 272 if log_progress: |
132 log.debug( | 273 log.debug( |
133 f"retrieving items {next_index} to " | 274 f"Retrieving items {next_index} to " |
134 f"{min(next_index+rsm_request.max, rsm_response.count)} on " | 275 f"{min(next_index + rsm_request.max, rsm_response.count)} on " |
135 f"{rsm_response.count} ({next_index/rsm_response.count*100:.2f}%)" | 276 f"{rsm_response.count} ({next_index/rsm_response.count*100:.2f}%)" |
136 ) | 277 ) |
137 | 278 |
138 if rsm_response.last is None: | 279 if rsm_response.last is None: |
139 if rsm_response.count: | 280 if rsm_response.count: |
140 log.warning('Can\'t do pagination, no "last" received') | 281 log.warning('Can\'t do pagination, no "last" received.') |
141 return None | 282 return None |
142 | 283 |
143 return rsm.RSMRequest(max_=rsm_request.max, after=rsm_response.last) | 284 return RSMRequest( |
285 max=rsm_request.max, | |
286 after=rsm_response.last | |
287 ) | |
144 | 288 |
145 | 289 |
146 @implementer(iwokkel.IDisco) | 290 @implementer(iwokkel.IDisco) |
147 class XEP_0059_handler(xmlstream.XMPPHandler): | 291 class XEP_0059_handler(xmlstream.XMPPHandler): |
148 | 292 def getDiscoInfo( |
149 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | 293 self, |
294 requestor: str, | |
295 target: str, | |
296 nodeIdentifier: str = "" | |
297 ) -> list[disco.DiscoFeature]: | |
298 """Get disco info for RSM | |
299 | |
300 @param requestor: JID of the requesting entity | |
301 @param target: JID of the target entity | |
302 @param nodeIdentifier: optional node identifier | |
303 @return: list of disco features | |
304 """ | |
150 return [disco.DiscoFeature(rsm.NS_RSM)] | 305 return [disco.DiscoFeature(rsm.NS_RSM)] |
151 | 306 |
152 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | 307 def getDiscoItems( |
308 self, | |
309 requestor: str, | |
310 target: str, | |
311 nodeIdentifier: str = "" | |
312 ) -> list: | |
313 """Get disco items for RSM | |
314 | |
315 @param requestor: JID of the requesting entity | |
316 @param target: JID of the target entity | |
317 @param nodeIdentifier: optional node identifier | |
318 @return: empty list (RSM doesn't have items) | |
319 """ | |
153 return [] | 320 return [] |