"""Objects representing Namespaces of MediaWiki site."""
#
# (C) Pywikibot team, 2008-2024
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations
from abc import ABCMeta
from collections.abc import Iterable, Mapping
from enum import IntEnum
from typing import Union
from pywikibot.backports import Iterable as IterableType
from pywikibot.tools import ComparableMixin, classproperty
SingleNamespaceType = Union[int, str, 'Namespace']
NamespaceArgType = Union[
SingleNamespaceType,
IterableType[SingleNamespaceType],
None,
]
[docs]
class BuiltinNamespace(IntEnum):
"""Builtin namespace enum."""
MEDIA = -2
SPECIAL = -1
MAIN = 0
TALK = 1
USER = 2
USER_TALK = 3
PROJECT = 4
PROJECT_TALK = 5
FILE = 6
FILE_TALK = 7
MEDIAWIKI = 8
MEDIAWIKI_TALK = 9
TEMPLATE = 10
TEMPLATE_TALK = 11
HELP = 12
HELP_TALK = 13
CATEGORY = 14
CATEGORY_TALK = 15
@property
def canonical(self) -> str:
"""Canonical form of MediaWiki built-in namespace.
.. versionadded:: 7.1
"""
name = '' if self == 0 else self.name.capitalize().replace('_', ' ')
return name.replace('Mediawiki', 'MediaWiki')
[docs]
class Namespace(Iterable, ComparableMixin, metaclass=MetaNamespace):
"""Namespace site data object.
This is backwards compatible with the structure of entries
in site._namespaces which were a list of::
[customised namespace,
canonical namespace name?,
namespace alias*]
If the canonical_name is not provided for a namespace between -2
and 15, the MediaWiki built-in names are used.
Image and File are aliases of each other by default.
If only one of canonical_name and custom_name are available, both
properties will have the same value.
.. versionchanged:: 9.0
metaclass from :class:`MetaNamespace`
"""
def __init__(self, id,
canonical_name: str | None = None,
custom_name: str | None = None,
aliases: list[str] | None = None,
**kwargs) -> None:
"""Initializer.
:param canonical_name: Canonical name
:param custom_name: Name defined in server LocalSettings.php
:param aliases: Aliases
"""
self.id = id
canonical_name = canonical_name or BuiltinNamespace(self.id).canonical
assert custom_name is not None or canonical_name is not None, \
'Namespace needs to have at least one name'
self.custom_name = custom_name \
if custom_name is not None else canonical_name
self.canonical_name = canonical_name \
if canonical_name is not None else custom_name
if aliases:
self.aliases = aliases
elif id in (BuiltinNamespace.FILE, BuiltinNamespace.FILE_TALK):
alias = 'Image'
if id == BuiltinNamespace.FILE_TALK:
alias += ' talk'
self.aliases = [alias]
else:
self.aliases = []
for key, value in kwargs.items():
setattr(self, key, value)
@classproperty
def canonical_namespaces(cls) -> dict[int, str]:
"""Return the canonical forms of MediaWiki built-in namespaces.
.. versionchanged:: 7.1
implemented as classproperty using BuiltinNamespace IntEnum.
"""
return {item.value: item.canonical for item in BuiltinNamespace}
def _distinct(self):
if self.custom_name == self.canonical_name:
return [self.canonical_name, *self.aliases]
return [self.custom_name, self.canonical_name, *self.aliases]
def _contains_lowercase_name(self, name):
"""Determine a lowercase normalised name is a name of this namespace.
:rtype: bool
"""
return name in (x.lower() for x in self._distinct())
def __contains__(self, item: str) -> bool:
"""Determine if item is a name of this namespace.
The comparison is case insensitive, and item may have a single
colon on one or both sides of the name.
:param item: name to check
"""
if item == '' and self.id == BuiltinNamespace.MAIN:
return True
name = Namespace.normalize_name(item)
if not name:
return False
return self._contains_lowercase_name(name.lower())
def __bool__(self) -> bool:
"""Obtain boolean method for Namepace class.
This method is implemented to be independent from __len__ method.
.. versionadded:: 7.0
:return: Always return True like generic object class.
"""
return True
def __len__(self) -> int:
"""Obtain length of the iterable."""
return len(self._distinct())
def __iter__(self):
"""Return an iterator."""
return iter(self._distinct())
def __getitem__(self, index):
"""Obtain an item from the iterable."""
if self.custom_name != self.canonical_name:
if index == 0:
return self.custom_name
index -= 1
return self.canonical_name if index == 0 else self.aliases[index - 1]
@staticmethod
def _colons(id, name):
"""Return the name with required colons, depending on the ID."""
if id == BuiltinNamespace.MAIN:
return ':'
if id in (BuiltinNamespace.FILE, BuiltinNamespace.CATEGORY):
return ':' + name + ':'
return name + ':'
def __str__(self) -> str:
"""Return the canonical string representation."""
return self.canonical_prefix()
[docs]
def canonical_prefix(self):
"""Return the canonical name with required colons."""
return Namespace._colons(self.id, self.canonical_name)
[docs]
def custom_prefix(self):
"""Return the custom name with required colons."""
return Namespace._colons(self.id, self.custom_name)
def __int__(self) -> int:
"""Return the namespace id."""
return self.id
def __index__(self) -> int:
"""Return the namespace id."""
return self.id
def __hash__(self):
"""Return the namespace id."""
return self.id
def __eq__(self, other):
"""Compare whether two namespace objects are equal."""
if isinstance(other, int):
return self.id == other
if isinstance(other, Namespace):
return self.id == other.id
if isinstance(other, str):
return other in self
return False
def __ne__(self, other):
"""Compare whether two namespace objects are not equal."""
return not self.__eq__(other)
def __mod__(self, other):
"""Apply modulo on the namespace id."""
return self.id.__mod__(other)
def __sub__(self, other):
"""Apply subtraction on the namespace id."""
return self.id - other
def __add__(self, other):
"""Apply addition on the namespace id."""
return self.id + other
def _cmpkey(self):
"""Return the ID as a comparison key."""
return self.id
def __repr__(self) -> str:
"""Return a reconstructable representation."""
standard_attr = ['id', 'custom_name', 'canonical_name', 'aliases']
extra = [(key, self.__dict__[key])
for key in sorted(self.__dict__)
if key not in standard_attr]
if extra:
kwargs = ', ' + ', '.join(
key + f'={value!r}' for key, value in extra)
else:
kwargs = ''
return (f'{self.__class__.__name__}(id={self.id}, '
f'custom_name={self.custom_name!r}, '
f'canonical_name={self.canonical_name!r}, '
f'aliases={self.aliases!r}{kwargs})')
[docs]
@staticmethod
def default_case(id, default_case=None):
"""Return the default fixed case value for the namespace ID."""
# https://www.mediawiki.org/wiki/Manual:$wgCapitalLinkOverrides#Warning
if id in (BuiltinNamespace.SPECIAL,
BuiltinNamespace.USER, BuiltinNamespace.USER_TALK,
BuiltinNamespace.MEDIAWIKI, BuiltinNamespace.MEDIAWIKI_TALK,
):
return 'first-letter'
return default_case
[docs]
@classmethod
def builtin_namespaces(cls, case: str = 'first-letter'):
"""Return a dict of the builtin namespaces."""
return {e.value: cls(e.value, case=cls.default_case(e.value, case))
for e in BuiltinNamespace}
[docs]
@staticmethod
def normalize_name(name):
"""Remove an optional colon before and after name.
TODO: reject illegal characters.
"""
if name == '':
return ''
name = name.replace('_', ' ')
parts = name.split(':', 4)
count = len(parts)
if count > 3 or (count == 3 and parts[2]):
return False
# Discard leading colon
if count >= 2 and not parts[0] and parts[1]:
return parts[1].strip()
if parts[0]:
return parts[0].strip()
return False
[docs]
class NamespacesDict(Mapping):
"""An immutable dictionary containing the Namespace instances.
It adds a deprecation message when called as the 'namespaces' property of
APISite was callable.
"""
def __init__(self, namespaces) -> None:
"""Create new dict using the given namespaces."""
super().__init__()
self._namespaces = namespaces
self._namespace_names = {}
for namespace in self._namespaces.values():
for name in namespace:
self._namespace_names[name.lower()] = namespace
def __iter__(self):
"""Iterate over all namespaces."""
return iter(self._namespaces)
def __getitem__(self, key: Namespace | int | str) -> Namespace:
"""Get the namespace with the given key.
:param key: namespace key
"""
if isinstance(key, (Namespace, int)):
try:
return self._namespaces[key]
except KeyError:
raise KeyError(f'{key} is not a known namespace. Maybe you'
' should clear the api cache.')
namespace = self.lookup_name(key)
if namespace:
return namespace
return super().__getitem__(key)
def __getattr__(self, attr: Namespace | int | str) -> Namespace:
"""Get the namespace with the given key.
:param attr: namespace key
"""
# lookup_name access _namespaces
if attr.isupper():
if attr == 'MAIN':
return self[0]
namespace = self.lookup_name(attr)
if namespace:
return namespace
return self.__getattribute__(attr)
def __len__(self) -> int:
"""Get the number of namespaces."""
return len(self._namespaces)
[docs]
def lookup_name(self, name: str) -> Namespace | None:
"""Find the Namespace for a name also checking aliases.
:param name: Name of the namespace.
"""
name = Namespace.normalize_name(name)
if name is False:
return None
return self.lookup_normalized_name(name.lower())
[docs]
def lookup_normalized_name(self, name: str) -> Namespace | None:
"""Find the Namespace for a name also checking aliases.
The name has to be normalized and must be lower case.
:param name: Name of the namespace.
"""
return self._namespace_names.get(name)
[docs]
def resolve(self, identifiers) -> list[Namespace]:
"""Resolve namespace identifiers to obtain Namespace objects.
Identifiers may be any value for which int() produces a valid
namespace id, except bool, or any string which Namespace.lookup_name
successfully finds. A numerical string is resolved as an integer.
:param identifiers: namespace identifiers
:type identifiers: iterable of str or Namespace key,
or a single instance of those types
:return: list of Namespace objects in the same order as the
identifiers
:raises KeyError: a namespace identifier was not resolved
:raises TypeError: a namespace identifier has an inappropriate
type such as NoneType or bool
"""
if isinstance(identifiers, (str, Namespace)):
identifiers = [identifiers]
else:
# convert non-iterators to single item list
try:
iter(identifiers)
except TypeError:
identifiers = [identifiers]
# lookup namespace names, and assume anything else is a key.
# int(None) raises TypeError; however, bool needs special handling.
namespaces = self._namespaces
result = [NotImplemented if isinstance(ns, bool)
else self._lookup_name(ns)
if isinstance(ns, str) and not ns.lstrip('-').isdigit()
else namespaces.get(int(ns))
for ns in identifiers]
if NotImplemented in result:
raise TypeError(
f'identifiers contains inappropriate types: {identifiers!r}'
)
# Namespace.lookup_name returns None if the name is not recognised
if None in result:
raise KeyError(
'Namespace identifier(s) not recognised: {}'
.format(','.join(str(identifier)
for identifier, ns in zip(identifiers, result)
if ns is None)))
return result
def _lookup_name(self, name):
name = Namespace.normalize_name(name)
if name is False:
return None
name = name.lower()
for namespace in self._namespaces.values():
if namespace._contains_lowercase_name(name):
return namespace
return None