#
# (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'])