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 []