1"""Objects representing API interface to MediaWiki site extenstions.""" 2# 3# (C) Pywikibot team, 2008-2021 4# 5# Distributed under the terms of the MIT license. 6# 7import pywikibot 8import pywikibot.family 9from pywikibot.data import api 10from pywikibot.echo import Notification 11from pywikibot.exceptions import ( 12 APIError, 13 InconsistentTitleError, 14 SiteDefinitionError, 15) 16from pywikibot.site._decorators import need_extension, need_right 17from pywikibot.tools import deprecate_arg, deprecated_args, merge_unique_dicts 18 19 20class EchoMixin: 21 22 """APISite mixin for Echo extension.""" 23 24 @need_extension('Echo') 25 def notifications(self, **kwargs): 26 """Yield Notification objects from the Echo extension. 27 28 :keyword format: If specified, notifications will be returned formatted 29 this way. Its value is either 'model', 'special' or None. Default 30 is 'special'. 31 :type format: str or None 32 33 Refer API reference for other keywords. 34 """ 35 params = { 36 'action': 'query', 37 'meta': 'notifications', 38 'notformat': 'special', 39 } 40 41 for key, value in kwargs.items(): 42 params['not' + key] = value 43 44 data = self._simple_request(**params).submit() 45 notifications = data['query']['notifications']['list'] 46 47 # Support API before 1.27.0-wmf.22 48 if hasattr(notifications, 'values'): 49 notifications = notifications.values() 50 51 return (Notification.fromJSON(self, notification) 52 for notification in notifications) 53 54 @need_extension('Echo') 55 def notifications_mark_read(self, **kwargs): 56 """Mark selected notifications as read. 57 58 :return: whether the action was successful 59 :rtype: bool 60 """ 61 # TODO: ensure that the 'echomarkread' action 62 # is supported by the site 63 kwargs = merge_unique_dicts(kwargs, action='echomarkread', 64 token=self.tokens['edit']) 65 req = self._simple_request(**kwargs) 66 data = req.submit() 67 try: 68 return data['query']['echomarkread']['result'] == 'success' 69 except KeyError: 70 return False 71 72 73class ProofreadPageMixin: 74 75 """APISite mixin for ProofreadPage extension.""" 76 77 @need_extension('ProofreadPage') 78 def _cache_proofreadinfo(self, expiry=False): 79 """Retrieve proofreadinfo from site and cache response. 80 81 Applicable only to sites with ProofreadPage extension installed. 82 83 The following info is returned by the query and cached: 84 - self._proofread_index_ns: Index Namespace 85 - self._proofread_page_ns: Page Namespace 86 - self._proofread_levels: a dictionary with: 87 keys: int in the range [0, 1, ..., 4] 88 values: category name corresponding to the 'key' quality level 89 e.g. on en.wikisource: 90 {0: 'Without text', 1: 'Not proofread', 2: 'Problematic', 91 3: 'Proofread', 4: 'Validated'} 92 93 :param expiry: either a number of days or a datetime.timedelta object 94 :type expiry: int (days), :py:obj:`datetime.timedelta`, False (config) 95 :return: A tuple containing _proofread_index_ns, 96 self._proofread_page_ns and self._proofread_levels. 97 :rtype: Namespace, Namespace, dict 98 """ 99 if (not hasattr(self, '_proofread_index_ns') 100 or not hasattr(self, '_proofread_page_ns') 101 or not hasattr(self, '_proofread_levels')): 102 103 pirequest = self._request( 104 expiry=pywikibot.config.API_config_expiry 105 if expiry is False else expiry, 106 parameters={'action': 'query', 'meta': 'proofreadinfo'} 107 ) 108 109 pidata = pirequest.submit() 110 ns_id = pidata['query']['proofreadnamespaces']['index']['id'] 111 self._proofread_index_ns = self.namespaces[ns_id] 112 113 ns_id = pidata['query']['proofreadnamespaces']['page']['id'] 114 self._proofread_page_ns = self.namespaces[ns_id] 115 116 self._proofread_levels = {} 117 for ql in pidata['query']['proofreadqualitylevels']: 118 self._proofread_levels[ql['id']] = ql['category'] 119 120 @property 121 def proofread_index_ns(self): 122 """Return Index namespace for the ProofreadPage extension.""" 123 if not hasattr(self, '_proofread_index_ns'): 124 self._cache_proofreadinfo() 125 return self._proofread_index_ns 126 127 @property 128 def proofread_page_ns(self): 129 """Return Page namespace for the ProofreadPage extension.""" 130 if not hasattr(self, '_proofread_page_ns'): 131 self._cache_proofreadinfo() 132 return self._proofread_page_ns 133 134 @property 135 def proofread_levels(self): 136 """Return Quality Levels for the ProofreadPage extension.""" 137 if not hasattr(self, '_proofread_levels'): 138 self._cache_proofreadinfo() 139 return self._proofread_levels 140 141 142class GeoDataMixin: 143 144 """APISite mixin for GeoData extension.""" 145 146 @need_extension('GeoData') 147 def loadcoordinfo(self, page): 148 """Load [[mw:Extension:GeoData]] info.""" 149 title = page.title(with_section=False) 150 query = self._generator(api.PropertyGenerator, 151 type_arg='coordinates', 152 titles=title.encode(self.encoding()), 153 coprop=['type', 'name', 'dim', 154 'country', 'region', 155 'globe'], 156 coprimary='all') 157 self._update_page(page, query) 158 159 160class PageImagesMixin: 161 162 """APISite mixin for PageImages extension.""" 163 164 @need_extension('PageImages') 165 def loadpageimage(self, page): 166 """ 167 Load [[mw:Extension:PageImages]] info. 168 169 :param page: The page for which to obtain the image 170 :type page: pywikibot.Page 171 172 :raises APIError: PageImages extension is not installed 173 """ 174 title = page.title(with_section=False) 175 query = self._generator(api.PropertyGenerator, 176 type_arg='pageimages', 177 titles=title.encode(self.encoding()), 178 piprop=['name']) 179 self._update_page(page, query) 180 181 182class GlobalUsageMixin: 183 184 """APISite mixin for Global Usage extension.""" 185 186 @need_extension('Global Usage') 187 def globalusage(self, page, total=None): 188 """Iterate global image usage for a given FilePage. 189 190 :param page: the page to return global image usage for. 191 :type page: pywikibot.FilePage 192 :param total: iterate no more than this number of pages in total. 193 :raises TypeError: input page is not a FilePage. 194 :raises pywikibot.exceptions.SiteDefinitionError: Site could not be 195 defined for a returned entry in API response. 196 """ 197 if not isinstance(page, pywikibot.FilePage): 198 raise TypeError('Page {} must be a FilePage.'.format(page)) 199 200 title = page.title(with_section=False) 201 args = {'titles': title, 202 'gufilterlocal': False, 203 } 204 query = self._generator(api.PropertyGenerator, 205 type_arg='globalusage', 206 guprop=['url', 'pageid', 'namespace'], 207 total=total, # will set gulimit=total in api, 208 **args) 209 210 for pageitem in query: 211 if not self.sametitle(pageitem['title'], 212 page.title(with_section=False)): 213 raise InconsistentTitleError(page, pageitem['title']) 214 215 api.update_page(page, pageitem, query.props) 216 217 assert 'globalusage' in pageitem, \ 218 "API globalusage response lacks 'globalusage' key" 219 for entry in pageitem['globalusage']: 220 try: 221 gu_site = pywikibot.Site(url=entry['url']) 222 except SiteDefinitionError: 223 pywikibot.warning( 224 'Site could not be defined for global' 225 ' usage for {}: {}.'.format(page, entry)) 226 continue 227 gu_page = pywikibot.Page(gu_site, entry['title']) 228 yield gu_page 229 230 231class WikibaseClientMixin: 232 233 """APISite mixin for WikibaseClient extension.""" 234 235 @deprecated_args(step=True) 236 @need_extension('WikibaseClient') 237 def unconnected_pages(self, total=None): 238 """Yield Page objects from Special:UnconnectedPages. 239 240 :param total: number of pages to return 241 """ 242 return self.querypage('UnconnectedPages', total) 243 244 245class LinterMixin: 246 247 """APISite mixin for Linter extension.""" 248 249 @need_extension('Linter') 250 def linter_pages(self, lint_categories=None, total=None, 251 namespaces=None, pageids=None, lint_from=None): 252 """Return a generator to pages containing linter errors. 253 254 :param lint_categories: categories of lint errors 255 :type lint_categories: an iterable that returns values (str), 256 or a pipe-separated string of values. 257 258 :param total: if not None, yielding this many items in total 259 :type total: int 260 261 :param namespaces: only iterate pages in these namespaces 262 :type namespaces: iterable of str or Namespace key, 263 or a single instance of those types. May be a '|' separated 264 list of namespace identifiers. 265 266 :param pageids: only include lint errors from the specified pageids 267 :type pageids: an iterable that returns pageids (str or int), 268 or a comma- or pipe-separated string of pageids 269 (e.g. '945097,1483753, 956608' or '945097|483753|956608') 270 271 :param lint_from: Lint ID to start querying from 272 :type lint_from: str representing digit or integer 273 274 :return: pages with Linter errors. 275 :rtype: typing.Iterable[pywikibot.Page] 276 """ 277 query = self._generator(api.ListGenerator, type_arg='linterrors', 278 total=total, # Will set lntlimit 279 namespaces=namespaces) 280 281 if lint_categories: 282 if isinstance(lint_categories, str): 283 lint_categories = lint_categories.split('|') 284 lint_categories = [p.strip() for p in lint_categories] 285 query.request['lntcategories'] = '|'.join(lint_categories) 286 287 if pageids: 288 if isinstance(pageids, str): 289 pageids = pageids.split('|') 290 pageids = [p.strip() for p in pageids] 291 # Validate pageids. 292 pageids = (str(int(p)) for p in pageids if int(p) > 0) 293 query.request['lntpageid'] = '|'.join(pageids) 294 295 if lint_from: 296 query.request['lntfrom'] = int(lint_from) 297 298 for pageitem in query: 299 page = pywikibot.Page(self, pageitem['title']) 300 api.update_page(page, pageitem) 301 yield page 302 303 304class ThanksMixin: 305 306 """APISite mixin for Thanks extension.""" 307 308 @need_extension('Thanks') 309 def thank_revision(self, revid, source=None): 310 """Corresponding method to the 'action=thank' API action. 311 312 :param revid: Revision ID for the revision to be thanked. 313 :type revid: int 314 :param source: A source for the thanking operation. 315 :type source: str 316 :raise APIError: On thanking oneself or other API errors. 317 :return: The API response. 318 """ 319 token = self.tokens['csrf'] 320 req = self._simple_request(action='thank', rev=revid, token=token, 321 source=source) 322 data = req.submit() 323 if data['result']['success'] != 1: 324 raise APIError('Thanking unsuccessful', '') 325 return data 326 327 328class ThanksFlowMixin: 329 330 """APISite mixin for Thanks and Flow extension.""" 331 332 @need_extension('Flow') 333 @need_extension('Thanks') 334 def thank_post(self, post): 335 """Corresponding method to the 'action=flowthank' API action. 336 337 :param post: The post to be thanked for. 338 :type post: Post 339 :raise APIError: On thanking oneself or other API errors. 340 :return: The API response. 341 """ 342 post_id = post.uuid 343 token = self.tokens['csrf'] 344 req = self._simple_request(action='flowthank', 345 postid=post_id, token=token) 346 data = req.submit() 347 if data['result']['success'] != 1: 348 raise APIError('Thanking unsuccessful', '') 349 return data 350 351 352class FlowMixin: 353 354 """APISite mixin for Flow extension.""" 355 356 @need_extension('Flow') 357 def load_board(self, page): 358 """ 359 Retrieve the data for a Flow board. 360 361 :param page: A Flow board 362 :type page: Board 363 :return: A dict representing the board's metadata. 364 :rtype: dict 365 """ 366 req = self._simple_request(action='flow', page=page, 367 submodule='view-topiclist', 368 vtllimit=1) 369 data = req.submit() 370 return data['flow']['view-topiclist']['result']['topiclist'] 371 372 @need_extension('Flow') 373 @deprecate_arg('format', 'content_format') 374 def load_topiclist(self, page, content_format: str = 'wikitext', limit=100, 375 sortby='newest', toconly=False, offset=None, 376 offset_id=None, reverse=False, include_offset=False): 377 """ 378 Retrieve the topiclist of a Flow board. 379 380 :param page: A Flow board 381 :type page: Board 382 :param content_format: The content format to request the data in. 383 must be either 'wikitext', 'html', or 'fixed-html' 384 :param limit: The number of topics to fetch in each request. 385 :type limit: int 386 :param sortby: Algorithm to sort topics by. 387 :type sortby: str (either 'newest' or 'updated') 388 :param toconly: Whether to only include information for the TOC. 389 :type toconly: bool 390 :param offset: The timestamp to start at (when sortby is 'updated'). 391 :type offset: Timestamp or equivalent str 392 :param offset_id: The topic UUID to start at (when sortby is 'newest'). 393 :type offset_id: str (in the form of a UUID) 394 :param reverse: Whether to reverse the topic ordering. 395 :type reverse: bool 396 :param include_offset: Whether to include the offset topic. 397 :type include_offset: bool 398 :return: A dict representing the board's topiclist. 399 :rtype: dict 400 """ 401 if offset: 402 offset = pywikibot.Timestamp.fromtimestampformat(offset) 403 offset_dir = 'rev' if reverse else 'fwd' 404 405 params = {'action': 'flow', 'submodule': 'view-topiclist', 406 'page': page, 407 'vtlformat': content_format, 'vtlsortby': sortby, 408 'vtllimit': limit, 'vtloffset-dir': offset_dir, 409 'vtloffset': offset, 'vtloffset-id': offset_id, 410 'vtlinclude-offset': include_offset, 'vtltoconly': toconly} 411 req = self._request(parameters=params) 412 data = req.submit() 413 return data['flow']['view-topiclist']['result']['topiclist'] 414 415 @need_extension('Flow') 416 @deprecate_arg('format', 'content_format') 417 def load_topic(self, page, content_format: str): 418 """ 419 Retrieve the data for a Flow topic. 420 421 :param page: A Flow topic 422 :type page: Topic 423 :param content_format: The content format to request the data in. 424 Must ne either 'wikitext', 'html', or 'fixed-html' 425 :return: A dict representing the topic's data. 426 :rtype: dict 427 """ 428 req = self._simple_request(action='flow', page=page, 429 submodule='view-topic', 430 vtformat=content_format) 431 data = req.submit() 432 return data['flow']['view-topic']['result']['topic'] 433 434 @need_extension('Flow') 435 @deprecate_arg('format', 'content_format') 436 def load_post_current_revision(self, page, post_id, content_format: str): 437 """ 438 Retrieve the data for a post to a Flow topic. 439 440 :param page: A Flow topic 441 :type page: Topic 442 :param post_id: The UUID of the Post 443 :type post_id: str 444 :param content_format: The content format used for the returned 445 content; must be either 'wikitext', 'html', or 'fixed-html' 446 :return: A dict representing the post data for the given UUID. 447 :rtype: dict 448 """ 449 req = self._simple_request(action='flow', page=page, 450 submodule='view-post', vppostId=post_id, 451 vpformat=content_format) 452 data = req.submit() 453 return data['flow']['view-post']['result']['topic'] 454 455 @need_right('edit') 456 @need_extension('Flow') 457 @deprecate_arg('format', 'content_format') 458 def create_new_topic(self, page, title, content, content_format): 459 """ 460 Create a new topic on a Flow board. 461 462 :param page: A Flow board 463 :type page: Board 464 :param title: The title of the new topic (must be in plaintext) 465 :type title: str 466 :param content: The content of the topic's initial post 467 :type content: str 468 :param content_format: The content format of the supplied content 469 :type content_format: str (either 'wikitext' or 'html') 470 :return: The metadata of the new topic 471 :rtype: dict 472 """ 473 token = self.tokens['csrf'] 474 params = {'action': 'flow', 'page': page, 'token': token, 475 'submodule': 'new-topic', 'ntformat': content_format, 476 'nttopic': title, 'ntcontent': content} 477 req = self._request(parameters=params, use_get=False) 478 data = req.submit() 479 return data['flow']['new-topic']['committed']['topiclist'] 480 481 @need_right('edit') 482 @need_extension('Flow') 483 @deprecate_arg('format', 'content_format') 484 def reply_to_post(self, page, reply_to_uuid: str, content: str, 485 content_format: str) -> dict: 486 """Reply to a post on a Flow topic. 487 488 :param page: A Flow topic 489 :type page: Topic 490 :param reply_to_uuid: The UUID of the Post to create a reply to 491 :param content: The content of the reply 492 :param content_format: The content format used for the supplied 493 content; must be either 'wikitext' or 'html' 494 :return: Metadata returned by the API 495 """ 496 token = self.tokens['csrf'] 497 params = {'action': 'flow', 'page': page, 'token': token, 498 'submodule': 'reply', 'repreplyTo': reply_to_uuid, 499 'repcontent': content, 'repformat': content_format} 500 req = self._request(parameters=params, use_get=False) 501 data = req.submit() 502 return data['flow']['reply']['committed']['topic'] 503 504 @need_right('flow-lock') 505 @need_extension('Flow') 506 def lock_topic(self, page, lock, reason): 507 """ 508 Lock or unlock a Flow topic. 509 510 :param page: A Flow topic 511 :type page: Topic 512 :param lock: Whether to lock or unlock the topic 513 :type lock: bool (True corresponds to locking the topic.) 514 :param reason: The reason to lock or unlock the topic 515 :type reason: str 516 :return: Metadata returned by the API 517 :rtype: dict 518 """ 519 status = 'lock' if lock else 'unlock' 520 token = self.tokens['csrf'] 521 params = {'action': 'flow', 'page': page, 'token': token, 522 'submodule': 'lock-topic', 'cotreason': reason, 523 'cotmoderationState': status} 524 req = self._request(parameters=params, use_get=False) 525 data = req.submit() 526 return data['flow']['lock-topic']['committed']['topic'] 527 528 @need_right('edit') 529 @need_extension('Flow') 530 def moderate_topic(self, page, state, reason): 531 """ 532 Moderate a Flow topic. 533 534 :param page: A Flow topic 535 :type page: Topic 536 :param state: The new moderation state 537 :type state: str 538 :param reason: The reason to moderate the topic 539 :type reason: str 540 :return: Metadata returned by the API 541 :rtype: dict 542 """ 543 token = self.tokens['csrf'] 544 params = {'action': 'flow', 'page': page, 'token': token, 545 'submodule': 'moderate-topic', 'mtreason': reason, 546 'mtmoderationState': state} 547 req = self._request(parameters=params, use_get=False) 548 data = req.submit() 549 return data['flow']['moderate-topic']['committed']['topic'] 550 551 @need_right('flow-delete') 552 @need_extension('Flow') 553 def delete_topic(self, page, reason): 554 """ 555 Delete a Flow topic. 556 557 :param page: A Flow topic 558 :type page: Topic 559 :param reason: The reason to delete the topic 560 :type reason: str 561 :return: Metadata returned by the API 562 :rtype: dict 563 """ 564 return self.moderate_topic(page, 'delete', reason) 565 566 @need_right('flow-hide') 567 @need_extension('Flow') 568 def hide_topic(self, page, reason): 569 """ 570 Hide a Flow topic. 571 572 :param page: A Flow topic 573 :type page: Topic 574 :param reason: The reason to hide the topic 575 :type reason: str 576 :return: Metadata returned by the API 577 :rtype: dict 578 """ 579 return self.moderate_topic(page, 'hide', reason) 580 581 @need_right('flow-suppress') 582 @need_extension('Flow') 583 def suppress_topic(self, page, reason): 584 """ 585 Suppress a Flow topic. 586 587 :param page: A Flow topic 588 :type page: Topic 589 :param reason: The reason to suppress the topic 590 :type reason: str 591 :return: Metadata returned by the API 592 :rtype: dict 593 """ 594 return self.moderate_topic(page, 'suppress', reason) 595 596 @need_right('edit') 597 @need_extension('Flow') 598 def restore_topic(self, page, reason): 599 """ 600 Restore a Flow topic. 601 602 :param page: A Flow topic 603 :type page: Topic 604 :param reason: The reason to restore the topic 605 :type reason: str 606 :return: Metadata returned by the API 607 :rtype: dict 608 """ 609 return self.moderate_topic(page, 'restore', reason) 610 611 @need_right('edit') 612 @need_extension('Flow') 613 def moderate_post(self, post, state, reason): 614 """ 615 Moderate a Flow post. 616 617 :param post: A Flow post 618 :type post: Post 619 :param state: The new moderation state 620 :type state: str 621 :param reason: The reason to moderate the topic 622 :type reason: str 623 :return: Metadata returned by the API 624 :rtype: dict 625 """ 626 page = post.page 627 uuid = post.uuid 628 token = self.tokens['csrf'] 629 params = {'action': 'flow', 'page': page, 'token': token, 630 'submodule': 'moderate-post', 'mpreason': reason, 631 'mpmoderationState': state, 'mppostId': uuid} 632 req = self._request(parameters=params, use_get=False) 633 data = req.submit() 634 return data['flow']['moderate-post']['committed']['topic'] 635 636 @need_right('flow-delete') 637 @need_extension('Flow') 638 def delete_post(self, post, reason): 639 """ 640 Delete a Flow post. 641 642 :param post: A Flow post 643 :type post: Post 644 :param reason: The reason to delete the post 645 :type reason: str 646 :return: Metadata returned by the API 647 :rtype: dict 648 """ 649 return self.moderate_post(post, 'delete', reason) 650 651 @need_right('flow-hide') 652 @need_extension('Flow') 653 def hide_post(self, post, reason): 654 """ 655 Hide a Flow post. 656 657 :param post: A Flow post 658 :type post: Post 659 :param reason: The reason to hide the post 660 :type reason: str 661 :return: Metadata returned by the API 662 :rtype: dict 663 """ 664 return self.moderate_post(post, 'hide', reason) 665 666 @need_right('flow-suppress') 667 @need_extension('Flow') 668 def suppress_post(self, post, reason): 669 """ 670 Suppress a Flow post. 671 672 :param post: A Flow post 673 :type post: Post 674 :param reason: The reason to suppress the post 675 :type reason: str 676 :return: Metadata returned by the API 677 :rtype: dict 678 """ 679 return self.moderate_post(post, 'suppress', reason) 680 681 @need_right('edit') 682 @need_extension('Flow') 683 def restore_post(self, post, reason): 684 """ 685 Restore a Flow post. 686 687 :param post: A Flow post 688 :type post: Post 689 :param reason: The reason to restore the post 690 :type reason: str 691 :return: Metadata returned by the API 692 :rtype: dict 693 """ 694 return self.moderate_post(post, 'restore', reason) 695 696 697class UrlShortenerMixin: 698 699 """APISite mixin for UrlShortener extension.""" 700 701 @need_extension('UrlShortener') 702 def create_short_link(self, url): 703 """ 704 Return a shortened link. 705 706 Note that on Wikimedia wikis only metawiki supports this action, 707 and this wiki can process links to all WM domains. 708 709 :param url: The link to reduce, with propotol prefix. 710 :type url: str 711 :return: The reduced link, without protocol prefix. 712 :rtype: str 713 """ 714 req = self._simple_request(action='shortenurl', url=url) 715 data = req.submit() 716 return data['shortenurl']['shorturl'] 717