"""Objects representing MediaWiki log entries."""
#
# (C) Pywikibot team, 2007-2024
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations
import datetime
from collections import UserDict
from typing import Any
import pywikibot
from pywikibot.exceptions import Error, HiddenKeyError
from pywikibot.tools import cached
[docs]
class LogEntry(UserDict):
"""Generic log entry.
LogEntry parameters may be retrieved by the corresponding method
or the LogEntry key. The following statements are equivalent:
action = logentry.action()
action = logentry['action']
action = logentry.data['action']
"""
# Log type expected. None for every type, or one of the (letype) str :
# block/patrol/etc...
# Overridden in subclasses.
_expected_type: str | None = None
def __init__(self, apidata: dict[str, Any],
site: pywikibot.site.BaseSite) -> None:
"""Initialize object from a logevent dict returned by MW API."""
super().__init__(apidata)
self.site = site
expected_type = self._expected_type
if expected_type is not None and expected_type != self.type():
raise Error(f'Wrong log type! Expecting {expected_type}, received '
f'{self.type()} instead.')
def __missing__(self, key: str) -> None:
"""Debug when the key is missing.
HiddenKeyError is raised when the user does not have permission.
KeyError is raised otherwise.
It also logs debugging information when a key is missing.
"""
pywikibot.debug(f'API log entry received:\n{self!r}')
hidden = {
'actionhidden': [
'action', 'logpage', 'ns', 'pageid', 'params', 'title',
],
'commenthidden': ['comment'],
'userhidden': ['user'],
}
for hidden_key, hidden_types in hidden.items():
if hidden_key in self and key in hidden_types:
raise HiddenKeyError(
"Log entry ({}) has a hidden '{}' key and you don't have "
"permission to view it due to '{}'"
.format(self['type'], key, hidden_key))
raise KeyError(f"Log entry ({self['type']}) has no {key!r} key")
def __repr__(self) -> str:
"""Return a string representation of LogEntry object."""
return (f'<{type(self).__name__}({self.site.sitename}, '
f'logid={self.logid()})>')
def __hash__(self) -> int:
"""Combine site and logid as the hash."""
return self.logid() ^ hash(self.site)
def __eq__(self, other: Any) -> bool:
"""Compare if self is equal to other."""
if not isinstance(other, LogEntry):
pywikibot.debug(f"'{type(self).__name__}' cannot be compared with "
f"'{type(other).__name__}'")
return False
return self.logid() == other.logid() and self.site == other.site
def __getattr__(self, item: str) -> Any:
"""Return several items from dict used as methods."""
if item in ('action', 'comment', 'logid', 'ns', 'pageid', 'type',
'user'): # TODO use specific User class for 'user'?
return lambda: self[item]
return super().__getattribute__(item)
@property
def params(self) -> dict[str, Any]:
"""Additional data for some log entry types.
.. versionadded:: 9.4
private *_param* attribute became a public property
"""
return self.get('params', {})
[docs]
@cached
def page(self) -> int | pywikibot.page.Page:
"""Page on which action was performed.
:return: page on action was performed
"""
return pywikibot.Page(self.site, self['title'])
[docs]
@cached
def timestamp(self) -> pywikibot.Timestamp:
"""Timestamp object corresponding to event timestamp."""
return pywikibot.Timestamp.fromISOformat(self['timestamp'])
[docs]
class OtherLogEntry(LogEntry):
"""A log entry class for unspecified log events."""
[docs]
class UserTargetLogEntry(LogEntry):
"""A log entry whose target is a user page."""
[docs]
@cached
def page(self) -> pywikibot.page.User:
"""Return the target user.
This returns a User object instead of the Page object returned by the
superclass method.
:return: target user
"""
return pywikibot.User(self.site, self['title'])
[docs]
class BlockEntry(LogEntry):
"""Block or unblock log entry.
It might contain a block or unblock depending on the action. The duration,
expiry and flags are not available on unblock log entries.
"""
_expected_type = 'block'
def __init__(self, apidata: dict[str, Any],
site: pywikibot.site.BaseSite) -> None:
"""Initializer."""
super().__init__(apidata, site)
# When an autoblock is removed, the "title" field is not a page title
# See bug T19781
pos = self.get('title', '').find('#')
self.isAutoblockRemoval = pos > 0
if self.isAutoblockRemoval:
self._blockid = int(self['title'][pos + 1:])
[docs]
def page(self) -> int | pywikibot.page.Page:
"""Return the blocked account or IP.
:return: the Page object of username or IP if this block action
targets a username or IP, or the blockid if this log reflects
the removal of an autoblock
"""
# TODO what for IP ranges ?
if self.isAutoblockRemoval:
return self._blockid
return super().page()
[docs]
@cached
def flags(self) -> list[str]:
"""Return a list of (str) flags associated with the block entry.
It raises an Error if the entry is an unblocking log entry.
:return: list of flags strings
"""
if self.action() == 'unblock':
return []
return self.params.get('flags', [])
[docs]
@cached
def duration(self) -> datetime.timedelta | None:
"""Return a datetime.timedelta representing the block duration.
:return: datetime.timedelta, or None if block is indefinite.
"""
# Doing the difference is easier than parsing the string
return (self.expiry() - self.timestamp()
if self.expiry() is not None else None)
[docs]
@cached
def expiry(self) -> pywikibot.Timestamp | None:
"""Return a Timestamp representing the block expiry date."""
details = self.params.get('expiry')
return pywikibot.Timestamp.fromISOformat(details) if details else None
[docs]
class RightsEntry(LogEntry):
"""Rights log entry."""
_expected_type = 'rights'
@property
def oldgroups(self) -> list[str]:
"""Return old rights groups.
.. versionchanged:: 7.5
No longer raise KeyError if `oldgroups` does not exists or
LogEntry has no additional data e.g. due to hidden data and
insufficient rights.
"""
return self.params.get('oldgroups', [])
@property
def newgroups(self) -> list[str]:
"""Return new rights groups.
.. versionchanged:: 7.5
No longer raise KeyError if `oldgroups` does not exists or
LogEntry has no additional data e.g. due to hidden data and
insufficient rights.
"""
return self.params.get('newgroups', [])
[docs]
class UploadEntry(LogEntry):
"""Upload log entry."""
_expected_type = 'upload'
[docs]
@cached
def page(self) -> pywikibot.page.FilePage:
"""Return FilePage on which action was performed."""
return pywikibot.FilePage(self.site, self['title'])
[docs]
class MoveEntry(LogEntry):
"""Move log entry."""
_expected_type = 'move'
@property
def target_ns(self) -> pywikibot.site._namespace.Namespace:
"""Return namespace object of target page."""
return self.site.namespaces[self.params['target_ns']]
@property
def target_title(self) -> str:
"""Return the target title."""
return self.params['target_title']
@property
@cached
def target_page(self) -> pywikibot.page.Page:
"""Return target page object."""
return pywikibot.Page(self.site, self.target_title)
[docs]
def suppressedredirect(self) -> bool:
"""Return True if no redirect was created during the move."""
# Introduced in MW r47901
return 'suppressedredirect' in self.params
[docs]
class PatrolEntry(LogEntry):
"""Patrol log entry."""
_expected_type = 'patrol'
@property
def current_id(self) -> int:
"""Return the current id."""
return int(self.params['curid'])
@property
def previous_id(self) -> int:
"""Return the previous id."""
return int(self.params['previd'])
@property
def auto(self) -> bool:
"""Return auto patrolled."""
return 'auto' in self.params and self.params['auto'] != 0
[docs]
class LogEntryFactory:
"""LogEntry Factory.
Only available method is create()
"""
_logtypes = {
'block': BlockEntry,
'rights': RightsEntry,
'upload': UploadEntry,
'move': MoveEntry,
'patrol': PatrolEntry,
}
def __init__(self, site: pywikibot.site.BaseSite,
logtype: str | None = None) -> None:
"""Initializer.
:param site: The site on which the log entries are created.
:param logtype: The log type of the log entries, if known in advance.
If None, the Factory will fetch the log entry from
the data to create each object.
"""
self._site = site
if logtype is None:
self._creator = self._create_from_data
else:
# Bind a Class object to self._creator:
# When called, it will initialize a new object of that class
logclass = self.get_valid_entry_class(logtype)
self._creator = lambda data: logclass(data, self._site)
[docs]
def create(self, logdata: dict[str, Any]) -> LogEntry:
"""Instantiate the LogEntry object representing logdata.
:param logdata: <item> returned by the api
:return: LogEntry object representing logdata
"""
return self._creator(logdata)
[docs]
def get_valid_entry_class(self, logtype: str) -> LogEntry:
"""Return the class corresponding to the @logtype string parameter.
:return: specified subclass of LogEntry
:raise KeyError: logtype is not valid
"""
if logtype not in self._site.logtypes:
raise KeyError(f'{logtype} is not a valid logtype')
return LogEntryFactory.get_entry_class(logtype)
[docs]
@classmethod
def get_entry_class(cls, logtype: str) -> LogEntry:
"""Return the class corresponding to the @logtype string parameter.
:return: specified subclass of LogEntry
.. note:: this class method cannot verify whether the given logtype
already exits for a given site; to verify use Site.logtypes
or use the get_valid_entry_class instance method instead.
"""
if logtype not in cls._logtypes:
if logtype is None:
cls._logtypes[logtype] = OtherLogEntry
else:
if logtype in ('newusers', 'thanks'):
bases = (UserTargetLogEntry, OtherLogEntry)
else:
bases = (OtherLogEntry,)
cls._logtypes[logtype] = type(
f'{logtype.capitalize()}Entry',
bases,
{
'__doc__': f'{logtype.capitalize()} log entry',
'_expected_type': logtype,
},
)
return cls._logtypes[logtype]
def _create_from_data(self, logdata: dict[str, Any]) -> LogEntry:
"""Check for logtype from data, and creates the correct LogEntry.
:param logdata: log entry data
"""
try:
logtype = logdata['type']
except KeyError:
pywikibot.debug(f'API log entry received:\n{logdata}')
raise Error("Log entry has no 'type' key")
return LogEntryFactory.get_entry_class(logtype)(logdata, self._site)
# For backward compatibility
ProtectEntry = LogEntryFactory.get_entry_class('protect')
DeleteEntry = LogEntryFactory.get_entry_class('delete')
ImportEntry = LogEntryFactory.get_entry_class('import')
NewUsersEntry = LogEntryFactory.get_entry_class('newusers')
ThanksEntry = LogEntryFactory.get_entry_class('thanks')