Source code for pywikibot.page._user

#
# (C) Pywikibot team, 2009-2026
#
# Distributed under the terms of the MIT license.
#
"""Object representing a Wiki user."""
from __future__ import annotations

from collections.abc import Generator, Iterable
from typing import Any

import pywikibot
from pywikibot.exceptions import (
    APIError,
    AutoblockUserError,
    NoRenameTargetError,
    NotEmailableError,
    UnexpectedAPIDataError,
    UserRightsError,
)
from pywikibot.page._links import Link
from pywikibot.page._page import Page
from pywikibot.page._revision import Revision
from pywikibot.time import Timestamp
from pywikibot.tools import is_ip_address, is_ip_network
from pywikibot.tools.collections import DataRecord


__all__ = ('Contribution', 'User')


[docs] class Contribution(DataRecord): """A structure holding information about a user contribution. In addition to the API result, it provides the ``site`` and ``page`` for the current Site and Page object of the contribution. .. version-added:: 11.6 """
[docs] @staticmethod def normalize(data: dict[str, Any]) -> None: """Upcast dictionary values.""" if 'timestamp' in data: data['timestamp'] = Timestamp.fromISOformat(data['timestamp']) if 'title' in data: data['page'] = Page(data['site'], data['title'], data['ns'])
[docs] class User(Page): """A class that represents a Wiki user. This class also represents the Wiki page ``User:<username>`` A user object represents a user account, which may be: - named (regular) account (see: :meth:`is_named`) - temporary account (see: :meth:`is_temporary`) - anonymous IP user (see: :meth:`isAnonymous`) - CIDR range (see: :meth:`is_CIDR`) .. note:: This class inherits from :class:`Page`. Therefore, :meth:`exists()<BasePage.exists>` determines whether the wiki page exists, not whether the user account exists. Use :meth:`is_named`, :meth:`is_temporary` :meth:`isAnonymous`, :meth:`is_CIDR` or :meth:`isRegistered` to determine the account type. """ def __init__(self, source, title: str = '') -> None: """Initializer for a User object. All parameters are the same as for ``Page()`` Initializer. """ self._isAutoblock = True if title.startswith('#'): title = title[1:] elif ':#' in title: title = title.replace(':#', ':') else: self._isAutoblock = False super().__init__(source, title, ns=2) if self.namespace() != 2: raise ValueError(f"'{self.title()}' is not in the user namespace!") if self._isAutoblock: # This user is probably being queried for purpose of lifting # an autoblock. pywikibot.info( 'This is an autoblock ID, you can only use to unblock it.') @property def username(self) -> str: """The username. Convenience method that returns the title of the page with namespace prefix omitted, which is the username. """ if self._isAutoblock: return '#' + self.title(with_ns=False) return self.title(with_ns=False)
[docs] def isRegistered(self, force: bool = False) -> bool: # noqa: N802 """Determine if the user is registered on the site. It is possible to have a page named ``User:xyz`` and not have a corresponding user with username xyz. This method checks whether the username corresponds to a valid named or temporary account. The user page does not need to exist for this method to return True. Use :meth:`exists() <BasePage.exists>` to check whether the user page exists. .. seealso:: - :meth:`isAnonymous` - :meth:`is_temporary` - :meth:`is_named` :param force: If True, forces reloading the data from API :return: True if the user is either a named (regular) user or a temporary account. """ # T135828: the registration timestamp may be None but the key exists return (not self.isAnonymous() and 'registration' in self.getprops(force))
[docs] def isAnonymous(self) -> bool: # noqa: N802 """Determine if the user is editing as an IP address. .. seealso:: - :meth:`isRegistered` - :meth:`is_temporary` - :meth:`is_named` - :meth:`is_CIDR` - :func:`tools.is_ip_address` """ return is_ip_address(self.username)
[docs] def is_CIDR(self) -> bool: # noqa: N802 """Determine if the input refers to a range of IP addresses. .. version-added:: 9.0 .. seealso:: - :meth:`isRegistered` - :meth:`isAnonymous` - :func:`tools.is_ip_network` """ return is_ip_network(self.username)
[docs] def is_named(self, *, force: bool = False) -> bool: """Determine if the user is a regular named account. A named account is neither an IP nor a temporary account. .. version-added:: 11.4 .. seealso:: - :meth:`isRegistered` - :meth:`isAnonymous` - :meth:`is_temporary` :param force: If True, forces reloading the data from API """ return self.isRegistered(force) and not self.is_temporary()
[docs] def is_temporary(self) -> bool: """Determine if the user is a temporary account. .. version-added:: 11.4 .. seealso:: - :meth:`isRegistered` - :meth:`isAnonymous` - :meth:`is_named` - :meth:`temp_expired` """ return 'temp' in self.groups()
[docs] def temp_expired(self, force: bool = False) -> bool | None: """Indicates whether the temporary account has expired or not. If account isn't temporary, None is returned. .. version-added:: 11.4 .. seealso:: :meth:`is_temporary` :param force: If True, forces reloading the data from API """ if not self.is_temporary(): return None return 'tempexpired' in self.getprops(force, ['tempexpired'])
[docs] def getprops( self, force: bool = False, extra_props: Iterable[str] = () ) -> dict[str, Any]: """Return user properties. .. version-changed:: 9.0 detect range blocks .. version-changed:: 11.4 Added the *extra_props* parameter. :param force: If True, forces reloading the data from API :param extra_props: Additional user properties to request. """ if not hasattr(self, '_additional_props'): self._additional_props: set[str] = set() new_props = False if extra_props: missing = set(extra_props) - self._additional_props new_props = bool(missing) self._additional_props.update(missing) if (force or new_props) and hasattr(self, '_userprops'): self._userprops: dict[str, Any] del self._userprops if not hasattr(self, '_userprops'): self._userprops = next( self.site.users([self.username], self._additional_props)) if self.isAnonymous() or self.is_CIDR(): r = next(self.site.blocks(iprange=self.username, total=1), None) if r: self._userprops['blockedby'] = r['by'] self._userprops['blockreason'] = r['reason'] return self._userprops
[docs] def registration(self, force: bool = False) -> pywikibot.Timestamp | None: """Fetch registration date for this user. :param force: If True, forces reloading the data from API """ if not self.isAnonymous(): reg = self.getprops(force).get('registration') if reg: return pywikibot.Timestamp.fromISOformat(reg) return None
[docs] def editCount(self, force: bool = False) -> int: # noqa: N802 """Return edit count for a registered user. Always returns 0 for 'anonymous' users. :param force: If True, forces reloading the data from API """ return self.getprops(force).get('editcount', 0)
[docs] def is_blocked(self, force: bool = False) -> bool: """Determine whether the user is currently blocked. .. seealso:: - :meth:`is_partial_blocked` - :meth:`get_block_info` .. version-changed:: 7.0 renamed from :meth:`isBlocked` method .. version-changed:: 9.0 can also detect range blocks. :param force: If True, forces reloading the data from API """ return 'blockedby' in self.getprops(force)
[docs] def is_partial_blocked(self, *, force: bool = False) -> bool: """Return True if this user is partially blocked, False otherwise. .. seealso:: - :meth:`get_block_info` - :meth:`APISite.is_partial_blocked() <pywikibot.site._apisite.APISite.is_partial_blocked>` .. version-added:: 11.0 :param force: If True, forces reloading the data from API """ return 'blockpartial' in self.getprops(force)
[docs] def get_block_info(self, *, force: bool = False) -> dict[str, Any] | None: """Return a dictionary of block information if the user is blocked. Returns None if the user is not blocked. The returned dictionary contains keys like: - blockid - blockedby - blockreason - blockexpiry - blockpartial (only if partial block) .. seealso:: - :meth:`getprops` - :meth:`is_partial_blocked` .. version-added:: 11.0 :param force: If True, forces reloading the data from API """ props = self.getprops(force) if 'blockid' not in props: return None return {k: v for k, v in props.items() if k.startswith('block')}
[docs] def is_locked(self, force: bool = False) -> bool: """Determine whether the user is currently locked globally. .. version-added:: 7.0 :param force: If True, forces reloading the data from API """ return self.site.is_locked(self.username, force)
[docs] def isEmailable(self, force: bool = False) -> bool: # noqa: N802 """Determine whether emails may be send to this user through MediaWiki. :param force: If True, forces reloading the data from API """ return not self.isAnonymous() and 'emailable' in self.getprops(force)
[docs] def groups(self, force: bool = False) -> list: """Return a list of groups to which this user belongs. The list of groups may be empty. :param force: If True, forces reloading the data from API :return: Groups property """ return self.getprops(force).get('groups', [])
[docs] def gender(self, force: bool = False) -> str: """Return the gender of the user. :param force: If True, forces reloading the data from API :return: Return 'male', 'female', or 'unknown' """ if self.isAnonymous(): return 'unknown' return self.getprops(force).get('gender', 'unknown')
[docs] def rights(self, force: bool = False) -> list: """Return user rights. :param force: If True, forces reloading the data from API :return: Return user rights """ return self.getprops(force).get('rights', [])
[docs] def getUserPage(self, subpage: str = '') -> Page: # noqa: N802 """Return a Page object relative to this user's main page. :param subpage: Subpage part to be appended to the main page title (optional) :return: Page object of user page or user subpage """ if self._isAutoblock: # This user is probably being queried for purpose of lifting # an autoblock, so has no user pages per se. raise AutoblockUserError( 'This is an autoblock ID, you can only use to unblock it.') if subpage: subpage = '/' + subpage return Page(Link(self.title() + subpage, self.site))
[docs] def getUserTalkPage(self, subpage: str = '') -> Page: # noqa: N802 """Return a Page object relative to this user's main talk page. :param subpage: Subpage part to be appended to the main talk page title (optional) :return: Page object of user talk page or user talk subpage """ if self._isAutoblock: # This user is probably being queried for purpose of lifting # an autoblock, so has no user talk pages per se. raise AutoblockUserError( 'This is an autoblock ID, you can only use to unblock it.') if subpage: subpage = '/' + subpage return Page(Link(self.username + subpage, self.site, default_namespace=3))
[docs] def send_email(self, subject: str, text: str, ccme: bool = False) -> bool: """Send an email to this user via MediaWiki's email interface. :param subject: The subject header of the mail :param text: Mail body :param ccme: If True, sends a copy of this email to the bot :raises NotEmailableError: The user of this User is not emailable :raises UserRightsError: Logged in user does not have 'sendemail' right :return: Operation successful indicator """ if not self.isEmailable(): raise NotEmailableError(self) if not self.site.has_right('sendemail'): raise UserRightsError("You don't have permission to send mail") params = { 'action': 'emailuser', 'target': self.username, 'token': self.site.tokens['csrf'], 'subject': subject, 'text': text, } if ccme: params['ccme'] = 1 mailrequest = self.site.simple_request(**params) maildata = mailrequest.submit() return ('emailuser' in maildata and maildata['emailuser']['result'] == 'Success')
[docs] def block(self, *args, **kwargs) -> None: """Block user. Refer :py:obj:`APISite.blockuser` method for parameters. """ try: self.site.blockuser(self, *args, **kwargs) except APIError as err: if err.code == 'invalidrange': raise ValueError(f'{self.username} is not a valid IP range.') raise
[docs] def unblock(self, reason: str | None = None) -> None: """Remove the block for the user. :param reason: Reason for the unblock. """ self.site.unblockuser(self, reason)
[docs] def logevents(self, **kwargs) -> Generator[pywikibot.logentries.LogEntry]: """Yield user activities. :keyword str logtype: Only iterate entries of this type (see mediawiki api documentation for available types) :keyword page: Only iterate entries affecting this page :type page: Page or str :keyword namespace: Namespace to retrieve logevents from :type namespace: int or Namespace :keyword start: Only iterate entries from and after this Timestamp :type start: Timestamp or ISO date string :keyword end: Only iterate entries up to and through this Timestamp :type end: Timestamp or ISO date string :keyword bool reverse: If True, iterate oldest entries first (default: newest) :keyword str tag: Only iterate entries tagged with this tag :keyword int total: Maximum number of events to iterate :rtype: iterable """ return self.site.logevents(user=self.username, **kwargs)
@property def last_event(self) -> pywikibot.logentries.LogEntry | None: """Return last user log event. :return: Last user log entry """ return next(self.logevents(total=1), None) @property def last_activity(self) -> pywikibot.Timestamp | None: """Return timestamp of last user activity. This includes the last log event, last edit, last deleted contribution, and last abuse log entry. .. version-added:: 11.0 :return: Timestamp of last user activity """ last = set() if last_event := self.last_event: last.add(last_event.timestamp()) if last_edit := self.last_edit: last.add(last_edit[2]) if last_deleted_contrib := self.deleted_contributions(total=1): last.add(next(last_deleted_contrib)[1]['timestamp']) if last_abuse_log := self.site.abuselog( user=self.username, total=1, aflprop='timestamp' ): last.add( pywikibot.Timestamp.fromISOformat( next(last_abuse_log)['timestamp'] ) ) return max(last) if last else None
[docs] def contributions( self, total: int | None = 500, **kwargs ) -> Generator[tuple[Page, int, pywikibot.Timestamp, str | None]]: """Yield tuples describing this user edits. Each tuple is composed of a pywikibot.Page object, the revision id, the edit timestamp and the comment. Pages returned are not guaranteed to be unique. Use :meth:`contribs` if you need additional revision information. Example: >>> site = pywikibot.Site('wikipedia:test') >>> user = pywikibot.User(site, 'pywikibot-test') >>> contrib = next(user.contributions(reverse=True)) >>> len(contrib) 4 >>> contrib[0].title() 'User:John Vandenberg/appendtext test' >>> contrib[1] 504588 >>> str(contrib[2]) '2022-03-04T17:36:02Z' >>> contrib[3] '' .. version-changed:: 3.0.20200609 The *showMinor* parameter was renamed to *minor*. .. version-changed:: 11.6 The keyword *top_only* was renamed to *top*. This parameter now accepts ``None`` to iterate both latest and non-latest contributions. ``False`` now iterates only non-latest contributions. Default is ``None``. .. seealso:: - :meth:`contribs` - :meth:`Site.usercontribs() <pywikibot.site._generators.GeneratorsMixin.usercontribs>` - :api:`Usercontribs` :param total: Limit result to this number of pages :keyword start: Iterate contributions starting at this Timestamp :keyword end: Iterate contributions ending at this Timestamp :keyword reverse: Iterate oldest contributions first (default: newest) :keyword namespaces: Only iterate pages in these namespaces :type namespaces: Iterable of str or Namespace key, or a single instance of those types. May be a '|' separated list of namespace identifiers. :keyword minor: If True, iterate only minor edits; if False and not None, iterate only non-minor edits (default: iterate both) :param top: if ``True``, iterate only edits which are the latest revision; if ``False``, do not iterate last revision edits; ``None`` to iterate both (default: ``None``) :return: Tuple of pywikibot.Page, revid, pywikibot.Timestamp, comment """ prop = ('comment', 'ids', 'timestamp', 'title') for c in self.contribs(total=total, prop=prop, **kwargs): yield c.page, c.revid, c.timestamp, c.comment # type: ignore[attr-defined] # noqa: E501
[docs] def contribs(self, **kwargs) -> Generator[Contribution]: """Yield :class:`Contribution` items describing this user edits. Refer :meth:`APISite.usercontribs() <pywikibot.site._generators.GeneratorsMixin.usercontribs>` method for for keyword parameters except of `user`and `userprefix`. .. version-added:: 11.6 Usage: >>> site = pywikibot.Site('wikipedia:test') >>> user = pywikibot.User(site, 'Pywikibot-oauth') >>> prop = ['title', 'tags', 'flags'] >>> uc = list(user.contribs(total=8, reverse=True, prop=prop)) >>> contrib = uc[-1] >>> contrib.user == user True >>> str(contrib.site) 'wikipedia:test' >>> contrib.title 'User:Pywikibot-oauth/edit test' >>> contrib.page.title() == contrib.title True >>> contrib.top False >>> contrib.tags # attribute access ['OAuth CID: 281'] >>> contrib['tags'] # key access ['OAuth CID: 281'] .. seealso:: - :meth:`contributions` - :meth:`Site.usercontribs() <pywikibot.site._generators.GeneratorsMixin.usercontribs>` - :api:`Usercontribs` :keyword start: Iterate contributions starting at this Timestamp :keyword end: Iterate contributions ending at this Timestamp :keyword reverse: Iterate oldest contributions first (default: newest) :keyword namespaces: Only iterate pages in these namespaces :keyword minor: If ``True``, iterate only minor edits; if ``False`` and not ``None``, iterate only non-minor edits (default: iterate both) :keyword total: Limit result to this number of pages :keyword top: if ``True``, iterate only edits which are the latest revision; if ``False``, do not iterate last revision edits; ``None`` to iterate both (default: ``None``) :keyword prop: Include additional pieces of information. Refer :api:`Usercontribs` for the elements and the default setting. :return: For each entry return a tuple of Page, Revision """ for contrib in self.site.usercontribs( user=self.username, formatversion=2, **kwargs): if {'site', 'page'} & contrib.keys() or 'user' not in contrib: raise UnexpectedAPIDataError( "API response contains reserved keys 'site' or 'page' " "or 'user' is missing" ) contrib.pop('user') yield Contribution(site=self.site, user=self, **contrib)
@property def first_edit( self ) -> tuple[Page, int, pywikibot.Timestamp, str | None] | None: """Return first user contribution. :return: First user contribution entry :return: Tuple of pywikibot.Page, revid, pywikibot.Timestamp, comment """ return next(self.contributions(reverse=True, total=1), None) @property def last_edit( self ) -> tuple[Page, int, pywikibot.Timestamp, str | None] | None: """Return last user contribution. :return: Last user contribution entry :return: Tuple of pywikibot.Page, revid, pywikibot.Timestamp, comment """ return next(self.contributions(total=1), None)
[docs] def deleted_contributions( self, *, total: int | None = 500, **kwargs, ) -> Generator[tuple[Page, Revision]]: """Yield tuples describing this user's deleted edits. .. version-added:: 5.5 :param total: Limit results to this number of pages :keyword start: Iterate contributions starting at this Timestamp :keyword end: Iterate contributions ending at this Timestamp :keyword reverse: Iterate oldest contributions first (default: newest) :keyword namespaces: Only iterate pages in these namespaces """ for data in self.site.alldeletedrevisions(user=self.username, total=total, **kwargs): page = Page(self.site, data['title'], data['ns']) for contrib in data['revisions']: yield page, Revision(**contrib)
[docs] def uploadedImages(self, total: int = 10): # noqa: N802 """Yield tuples describing files uploaded by this user. Each tuple is composed of a pywikibot.Page, the timestamp (str in ISO8601 format), comment (str) and a bool for pageid > 0. Pages returned are not guaranteed to be unique. :param total: Limit result to this number of pages """ if not self.is_named(): return for item in self.logevents(logtype='upload', total=total): yield (item.page(), str(item.timestamp()), item.comment(), item.pageid() > 0)
@property def is_thankable(self) -> bool: """Determine if the user has thanks notifications enabled. .. note:: This doesn't accurately determine if thanks is enabled for user. Privacy of thanks preferences is under discussion, please see :phab:`T57401#2216861` and :phab:`T120753#1863894`. """ return self.isRegistered() and 'bot' not in self.groups()
[docs] def renamed_target(self) -> User: """Return a User object for the target this user was renamed to. If this user was not renamed, it will raise a :exc:`NoRenameTargetError`. **Usage:** >>> site = pywikibot.Site('wikipedia:de') >>> user = pywikibot.User(site, 'Foo') >>> user.isRegistered() False >>> target = user.renamed_target() >>> target.isRegistered() True >>> target.title(with_ns=False) 'Foo~dewiki' >>> target.renamed_target() Traceback (most recent call last): ... pywikibot.exceptions.NoRenameTargetError: Rename target user ... .. seealso:: * :meth:`BasePage.moved_target` * :meth:`BasePage.getRedirectTarget` .. version-added:: 9.4 :raises NoRenameTargetError: User was not renamed """ gen = iter(self.site.logevents(logtype='renameuser', page=self, total=1)) try: renamed = next(gen) except StopIteration: raise NoRenameTargetError(self) return User(self.site, renamed.params['newuser'])