jenkins-bot has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/461372 )

Change subject: [FEAT] Add support for Lexicographical data
......................................................................

[FEAT] Add support for Lexicographical data

Bug: T189321
Change-Id: I808dab5c3c4ba8169423e9796bac24f39cabac52
---
M pywikibot/__init__.py
M pywikibot/data/api.py
M pywikibot/page/__init__.py
M pywikibot/page/_collections.py
M pywikibot/page/_wikibase.py
M pywikibot/site/_datasite.py
6 files changed, 603 insertions(+), 4 deletions(-)

Approvals:
  Xqt: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/pywikibot/__init__.py b/pywikibot/__init__.py
index 4456faa..348cfa7 100644
--- a/pywikibot/__init__.py
+++ b/pywikibot/__init__.py
@@ -79,7 +79,8 @@
     '__version__',
     'Bot', 'calledModuleName', 'Category', 'Claim', 'Coordinate', 'critical',
     'CurrentPageBot', 'debug', 'error', 'exception', 'FilePage', 'handle_args',
-    'html2unicode', 'input', 'input_choice', 'input_yn', 'ItemPage', 'Link',
+    'html2unicode', 'input', 'input_choice', 'input_yn', 'ItemPage',
+    'LexemeForm', 'LexemePage', 'LexemeSense', 'Link',
     'log', 'MediaInfo', 'output', 'Page', 'PropertyPage', 'showDiff',
     'show_help', 'Site', 'SiteLink', 'stdout', 'Timestamp', 'translate', 'ui',
     'url2unicode', 'User', 'warning', 'WbGeoShape', 'WbMonolingualText',
@@ -1222,6 +1223,9 @@
     Claim,
     FilePage,
     ItemPage,
+    LexemeForm,
+    LexemePage,
+    LexemeSense,
     Link,
     MediaInfo,
     Page,
diff --git a/pywikibot/data/api.py b/pywikibot/data/api.py
index c04e093..4a89f13 100644
--- a/pywikibot/data/api.py
+++ b/pywikibot/data/api.py
@@ -1031,6 +1031,8 @@
             'wbremovequalifiers', 'wbremovereferences', 'wbsetaliases',
             'wbsetclaim', 'wbsetclaimvalue', 'wbsetdescription', 'wbsetlabel',
             'wbsetqualifier', 'wbsetreference', 'wbsetsitelink',
+            'wbladdform', 'wbleditformelements', 'wblmergelexemes',
+            'wblremoveform',
         }
         # Client side verification that the request is being performed
         # by a logged in user, and warn if it isn't a config username.
diff --git a/pywikibot/page/__init__.py b/pywikibot/page/__init__.py
index 2caa740..7470180 100644
--- a/pywikibot/page/__init__.py
+++ b/pywikibot/page/__init__.py
@@ -24,6 +24,9 @@
     PropertyPage,
     WikibaseEntity,
     WikibasePage,
+    LexemePage,
+    LexemeForm,
+    LexemeSense,
 )
 from pywikibot.site import BaseSite as _BaseSite
 from pywikibot.tools import deprecated, issue_deprecation_warning
@@ -41,6 +44,9 @@
     'User',
     'WikibasePage',
     'ItemPage',
+    'LexemePage',
+    'LexemeForm',
+    'LexemeSense',
     'PropertyPage',
     'Property',
     'Claim',
diff --git a/pywikibot/page/_collections.py b/pywikibot/page/_collections.py
index 078a5cf..84ecfad 100644
--- a/pywikibot/page/_collections.py
+++ b/pywikibot/page/_collections.py
@@ -5,7 +5,7 @@
 # Distributed under the terms of the MIT license.
 #
 from collections import defaultdict
-from collections.abc import MutableMapping
+from collections.abc import MutableMapping, MutableSequence
 from typing import Optional

 import pywikibot
@@ -17,6 +17,7 @@
     'ClaimCollection',
     'LanguageDict',
     'SiteLinkCollection',
+    'SubEntityCollection',
 )


@@ -476,3 +477,101 @@
             for dbname in to_nuke:
                 del data[dbname]
         return data
+
+
+class SubEntityCollection(MutableSequence):
+
+    """Ordered collection of sub-entities indexed by their ids."""
+
+    def __init__(self, repo, data=None):
+        """
+        Initializer.
+
+        :param repo: Wikibase site
+        :type repo: pywikibot.site.DataSite
+        :param data: iterable of LexemeSubEntity
+        :type data: iterable
+        """
+        super().__init__()
+        self.repo = repo
+        self._data = []
+        self._by_key = {}
+        if data:
+            self.extend(data)
+
+    def _validate_isinstance(self, obj):
+        if not isinstance(obj, self.type_class):
+            raise TypeError(
+                '{} should only hold instances of {}, '
+                'instance of {} was provided'
+                .format(self.__class__.__name__,
+                        self.type_class.__name__,
+                        obj.__class__.__name__))
+
+    def __getitem__(self, index):
+        if isinstance(index, str):
+            try:
+                index = self._by_key[index]
+            except KeyError as e:
+                raise ValueError('No entity with id {} was found'
+                                 .format(index)) from e
+        return self._data[index]
+
+    def __setitem__(self, index, value):
+        raise NotImplementedError
+
+    def __delitem__(self, index):
+        if isinstance(index, str):
+            try:
+                index = self._by_key[index]
+            except KeyError as e:
+                raise ValueError('No entity with id {} was found'
+                                 .format(index)) from e
+        obj = self._data[index]
+        del self._data[index]
+        del self._by_key[obj.id]
+
+    def __len__(self):
+        return len(self._data)
+
+    def insert(self, index, obj):
+        """Insert a sub-entity to the collection."""
+        self._validate_isinstance(obj)
+        self._data.insert(index, obj)
+        self._by_key[obj.id] = index
+
+    @classmethod
+    def new_empty(cls, repo):
+        """Construct a new empty SubEntityCollection."""
+        return cls(repo)
+
+    @classmethod
+    def fromJSON(cls, data, repo):
+        """Construct a new SubEntityCollection from JSON."""
+        this = cls(repo)
+        for entity in data:
+            this.append(cls.type_class.fromJSON(repo, entity))
+        return this
+
+    @classmethod
+    def normalizeData(cls, data: list) -> dict:
+        """
+        Helper function to expand data into the Wikibase API structure.
+
+        :param data: Data to normalize
+        :type data: list
+
+        :return: the altered dict from parameter data.
+        """
+        raise NotImplementedError  # todo
+
+    def toJSON(self, diffto: Optional[dict] = None) -> dict:
+        """
+        Create JSON suitable for Wikibase API.
+
+        When diffto is provided, JSON representing differences
+        to the provided data is created.
+
+        :param diffto: JSON containing entity data
+        """
+        raise NotImplementedError  # todo
diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py
index f90577e..b584121 100644
--- a/pywikibot/page/_wikibase.py
+++ b/pywikibot/page/_wikibase.py
@@ -40,6 +40,7 @@
     ClaimCollection,
     LanguageDict,
     SiteLinkCollection,
+    SubEntityCollection,
 )
 from pywikibot.page._decorators import allow_asynchronous
 from pywikibot.page._pages import BasePage, FilePage
@@ -1854,3 +1855,373 @@
             'value': self._formatValue(),
             'type': self.value_types.get(self.type, self.type)
         }
+
+
+class LexemePage(WikibasePage):
+
+    """Wikibase entity of type 'lexeme'."""
+
+    _cache_attrs = WikibasePage._cache_attrs + (
+        'lemmas', 'language', 'lexicalCategory', 'forms', 'senses',
+    )
+    entity_type = 'lexeme'
+    title_pattern = r'L[1-9]\d*'
+    DATA_ATTRIBUTES = {
+        'lemmas': LanguageDict,
+        'claims': ClaimCollection,
+        # added when defined
+        # 'forms': LexemeFormCollection,
+        # 'senses': LexemeSenseCollection,
+    }
+
+    def __init__(self, site, title=None) -> None:
+        """
+        Initializer.
+
+        :param site: data repository
+        :type site: pywikibot.site.DataSite
+        :param title: identifier of lexeme, "L###",
+            -1 or None for an empty lexeme.
+        :type title: str or None
+        """
+        # Special case for empty lexeme.
+        if title is None or title == '-1':
+            super().__init__(site, '-1', entity_type='lexeme')
+            assert self.id == '-1'
+            return
+
+        # we don't want empty titles
+        if not title:
+            raise InvalidTitleError("Lexeme's title cannot be empty")
+
+        super().__init__(site, title, entity_type='lexeme')
+        assert self.id == self._link.title
+
+    def get_data_for_new_entity(self):
+        """Return data required for creation of a new lexeme."""
+        raise NotImplementedError  # todo
+
+    def toJSON(self, diffto: Optional[dict] = None) -> dict:
+        """
+        Create JSON suitable for Wikibase API.
+
+        When diffto is provided, JSON representing differences
+        to the provided data is created.
+
+        :param diffto: JSON containing entity data
+        """
+        data = super().toJSON(diffto=diffto)
+
+        for prop in ('language', 'lexicalCategory'):
+            value = getattr(self, prop, None)
+            if not value:
+                continue
+            if not diffto or diffto.get(prop) != value.getID():
+                data[prop] = value.getID()
+
+        return data
+
+    def get(self, force=False, get_redirect=False, *args, **kwargs):
+        """
+        Fetch all lexeme data, and cache it.
+
+        :param force: override caching
+        :type force: bool
+        :param get_redirect: return the lexeme content, do not follow the
+            redirect, do not raise an exception.
+        :type get_redirect: bool
+        :raise NotImplementedError: a value in args or kwargs
+        :note: dicts returned by this method are references to content
+            of this entity and their modifying may indirectly cause
+            unwanted change to the live content
+        """
+        data = super().get(force, *args, **kwargs)
+
+        if self.isRedirectPage() and not get_redirect:
+            raise IsRedirectPageError(self)
+
+        # language
+        self.language = None
+        if 'language' in self._content:
+            self.language = ItemPage(self.site, self._content['language'])
+
+        # lexicalCategory
+        self.lexicalCategory = None
+        if 'lexicalCategory' in self._content:
+            self.lexicalCategory = ItemPage(
+                self.site, self._content['lexicalCategory'])
+
+        data['language'] = self.language
+        data['lexicalCategory'] = self.lexicalCategory
+
+        return data
+
+    @classmethod
+    def _normalizeData(cls, data: dict) -> dict:
+        """
+        Helper function to expand data into the Wikibase API structure.
+
+        :param data: The dict to normalize
+        :return: the altered dict from parameter data.
+        """
+        new_data = WikibasePage._normalizeData(data)
+        for prop in ('language', 'lexicalCategory'):
+            value = new_data.get(prop)
+            if value:
+                if isinstance(value, ItemPage):
+                    new_data[prop] = value.getID()
+                else:
+                    new_data[prop] = value
+        return new_data
+
+    @allow_asynchronous
+    def add_form(self, form, **kwargs):
+        """
+        Add a form to the lexeme.
+
+        :param form: The form to add
+        :type form: Form
+        :keyword bot: Whether to flag as bot (if possible)
+        :type bot: bool
+        :keyword asynchronous: if True, launch a separate thread to add form
+            asynchronously
+        :type asynchronous: bool
+        :keyword callback: a callable object that will be called after the
+            claim has been added. It must take two arguments:
+            (1) a LexemePage object, and
+            (2) an exception instance, which will be None if the entity was
+            saved successfully. This is intended for use by bots that need to
+            keep track of which saves were successful.
+        :type callback: callable
+        """
+        if form.on_lexeme is not None:
+            raise ValueError('The provided LexemeForm instance is already '
+                             'used in an entity')
+        data = self.repo.add_form(self, form, **kwargs)
+        form.id = data['form']['id']
+        form.on_lexeme = self
+        form._content = data['form']
+        form.get()
+        self.forms.append(form)
+        self.latest_revision_id = data['lastrevid']
+
+    def remove_form(self, form, **kwargs) -> None:
+        """
+        Remove a form from the lexeme.
+
+        :param form: The form to remove
+        :type form: pywikibot.LexemeForm
+        """
+        data = self.repo.remove_form(form, **kwargs)
+        form.on_lexeme.latest_revision_id = data['lastrevid']
+        form.on_lexeme.forms.remove(form)
+        form.on_lexeme = None
+        form.id = '-1'
+
+    # todo: senses
+
+    def mergeInto(self, lexeme, **kwargs):
+        """
+        Merge the lexeme into another lexeme.
+
+        :param lexeme: The lexeme to merge into
+        :type lexeme: LexemePage
+        """
+        data = self.repo.mergeLexemes(from_lexeme=self, to_lexeme=lexeme,
+                                      **kwargs)
+        if not data.get('success', 0):
+            return
+        self.latest_revision_id = data['from']['lastrevid']
+        lexeme.latest_revision_id = data['to']['lastrevid']
+        if data.get('redirected', 0):
+            self._isredir = True
+            self._redirtarget = lexeme
+
+    def isRedirectPage(self):
+        """Return True if lexeme is redirect, False if not or not existing."""
+        if hasattr(self, '_content') and not hasattr(self, '_isredir'):
+            self._isredir = self.id != self._content.get('id', self.id)
+            return self._isredir
+        return super().isRedirectPage()
+
+
+class LexemeSubEntity(WikibaseEntity):
+
+    """Common super class for LexemeForm and LexemeSense."""
+
+    def __init__(self, repo, id_=None) -> None:
+        """Initializer."""
+        super().__init__(repo, id_)
+        self._on_lexeme = None
+
+    @classmethod
+    def fromJSON(cls, repo, data):
+        new = cls(repo, data['id'])
+        new._content = data
+        return new
+
+    def toJSON(self, diffto=None) -> dict:
+        data = super().toJSON(diffto)
+        if self.id != '-1':
+            data['id'] = self.id
+        return data
+
+    @property
+    def on_lexeme(self) -> LexemePage:
+        if self._on_lexeme is None:
+            lexeme_id = self.id.partition('-')[0]
+            self._on_lexeme = LexemePage(self.repo, lexeme_id)
+        return self._on_lexeme
+
+    @on_lexeme.setter
+    def on_lexeme(self, lexeme):
+        self._on_lexeme = lexeme
+
+    @on_lexeme.deleter
+    def on_lexeme(self):
+        self._on_lexeme = None
+
+    @allow_asynchronous
+    def addClaim(self, claim, **kwargs):
+        """
+        Add a claim to the form.
+
+        :param claim: The claim to add
+        :type claim: Claim
+        :keyword bot: Whether to flag as bot (if possible)
+        :type bot: bool
+        :keyword asynchronous: if True, launch a separate thread to add claim
+            asynchronously
+        :type asynchronous: bool
+        :keyword callback: a callable object that will be called after the
+            claim has been added. It must take two arguments: (1) a Form
+            object, and (2) an exception instance, which will be None if the
+            form was saved successfully. This is intended for use by bots that
+            need to keep track of which saves were successful.
+        :type callback: callable
+        """
+        self.repo.addClaim(self, claim, **kwargs)
+        claim.on_item = self
+
+    def removeClaims(self, claims, **kwargs) -> None:
+        """
+        Remove the claims from the form.
+
+        :param claims: list of claims to be removed
+        :type claims: list or pywikibot.Claim
+        """
+        # this check allows single claims to be removed by pushing them into a
+        # list of length one.
+        if isinstance(claims, pywikibot.Claim):
+            claims = [claims]
+        data = self.repo.removeClaims(claims, **kwargs)
+        for claim in claims:
+            claim.on_item.latest_revision_id = data['pageinfo']['lastrevid']
+            claim.on_item = None
+            claim.snak = None
+
+
+class LexemeForm(LexemeSubEntity):
+
+    """Wikibase lexeme form."""
+
+    entity_type = 'form'
+    title_pattern = LexemePage.title_pattern + r'-F[1-9]\d*'
+    DATA_ATTRIBUTES = {
+        'representations': LanguageDict,
+        'claims': ClaimCollection,
+    }
+
+    def toJSON(self, diffto: Optional[dict] = None) -> dict:
+        """Create dict suitable for the MediaWiki API."""
+        data = super().toJSON(diffto=diffto)
+
+        key = 'grammaticalFeatures'
+        if getattr(self, key, None):
+            # could also avoid if no change wrt. diffto
+            data[key] = [value.getID() for value in self.grammaticalFeatures]
+
+        return data
+
+    @classmethod
+    def _normalizeData(cls, data):
+        new_data = LexemeSubEntity._normalizeData(data)
+        if 'grammaticalFeatures' in data:
+            value = []
+            for feat in data['grammaticalFeatures']:
+                if isinstance(feat, ItemPage):
+                    value.append(feat.getID())
+                else:
+                    value.append(feat)
+            new_data['grammaticalFeatures'] = value
+        return new_data
+
+    def get(self, force: bool = False) -> dict:
+        """
+        Fetch all form data, and cache it.
+
+        :param force: override caching
+        :note: dicts returned by this method are references to content
+            of this entity and their modifying may indirectly cause
+            unwanted change to the live content
+        """
+        data = super().get(force=force)
+
+        # grammaticalFeatures
+        self.grammaticalFeatures = set()
+        for value in self._content.get('grammaticalFeatures', []):
+            self.grammaticalFeatures.add(ItemPage(self.repo, value))
+
+        data['grammaticalFeatures'] = self.grammaticalFeatures
+
+        return data
+
+    def edit_elements(self, data: dict, **kwargs) -> None:
+        """
+        Update form elements.
+
+        :param data: Data to be saved
+        """
+        if self.id == '-1':
+            # Update only locally
+            if 'representations' in data:
+                self.representations = LanguageDict(data['representations'])
+
+            if 'grammaticalFeatures' in data:
+                self.grammaticalFeatures = set()
+                for value in data['grammaticalFeatures']:
+                    if not isinstance(value, ItemPage):
+                        value = ItemPage(self.repo, value)
+                    self.grammaticalFeatures.add(value)
+        else:
+            data = self._normalizeData(data)
+            updates = self.repo.edit_form_elements(self, data, **kwargs)
+            self._content = updates['form']
+
+
+class LexemeSense(LexemeSubEntity):
+
+    """Wikibase lexeme sense."""
+
+    entity_type = 'sense'
+    title_pattern = LexemePage.title_pattern + r'-S[1-9]\d*'
+    DATA_ATTRIBUTES = {
+        'glosses': LanguageDict,
+        'claims': ClaimCollection,
+    }
+
+
+class LexemeFormCollection(SubEntityCollection):
+
+    type_class = LexemeForm
+
+
+class LexemeSenseCollection(SubEntityCollection):
+
+    type_class = LexemeSense
+
+
+LexemePage.DATA_ATTRIBUTES.update({
+    'forms': LexemeFormCollection,
+    'senses': LexemeSenseCollection,
+})
diff --git a/pywikibot/site/_datasite.py b/pywikibot/site/_datasite.py
index 7662092..b729f25 100644
--- a/pywikibot/site/_datasite.py
+++ b/pywikibot/site/_datasite.py
@@ -21,7 +21,7 @@
     NoWikibaseEntityError,
 )
 from pywikibot.site._apisite import APISite
-from pywikibot.site._decorators import need_right, need_version
+from pywikibot.site._decorators import need_extension, need_right, need_version
 from pywikibot.tools import itergroup, merge_unique_dicts, remove_last_args


@@ -42,6 +42,9 @@
             'item': pywikibot.ItemPage,
             'property': pywikibot.PropertyPage,
             'mediainfo': pywikibot.MediaInfo,
+            'lexeme': pywikibot.LexemePage,
+            'form': pywikibot.LexemeForm,
+            'sense': pywikibot.LexemeSense,
         }

     def _cache_entity_namespaces(self) -> None:
@@ -69,7 +72,8 @@
         if entity_type in self._entity_namespaces:
             return self._entity_namespaces[entity_type]
         raise EntityTypeUnknownError(
-            '{!r} does not support entity type "{}"'
+            '{!r} does not support entity type "{}" '
+            "or it doesn't have its own namespace"
             .format(self, entity_type))

     @property
@@ -626,6 +630,35 @@
         req = self.simple_request(**params)
         return req.submit()

+    @need_right('item-merge')
+    @need_extension('WikibaseLexeme')
+    def mergeLexemes(self, from_lexeme, to_lexeme, summary=None, *,
+                     bot: bool = True) -> dict:
+        """
+        Merge two lexemes together.
+
+        :param from_lexeme: Lexeme to merge from
+        :type from_lexeme: pywikibot.LexemePage
+        :param to_lexeme: Lexeme to merge into
+        :type to_lexeme: pywikibot.LexemePage
+        :param summary: Edit summary
+        :type summary: str
+        :keyword bot: Whether to mark the edit as a bot edit
+        :return: dict API output
+        """
+        params = {
+            'action': 'wblmergelexemes',
+            'source': from_lexeme.getID(),
+            'target': to_lexeme.getID(),
+            'token': self.tokens['edit'],
+            'summary': summary,
+        }
+        if bot:
+            params['bot'] = 1
+        req = self._simple_request(**params)
+        data = req.submit()
+        return data
+
     @need_right('item-redirect')
     def set_redirect_target(self, from_item, to_item, bot: bool = True):
         """
@@ -823,3 +856,87 @@
         See self._wbset_action() for parameters
         """
         return self._wbset_action(itemdef, 'wbsetsitelink', sitelink, **kwargs)
+
+    @need_right('edit')
+    @need_extension('WikibaseLexeme')
+    def add_form(self, lexeme, form, *, bot: bool = True,
+                 baserevid=None) -> dict:
+        """
+        Add a form.
+
+        :param lexeme: Lexeme to modify
+        :type lexeme: pywikibot.LexemePage
+        :param form: Form to be added
+        :type form: pywikibot.LexemeForm
+        :keyword bot: Whether to mark the edit as a bot edit
+        :keyword baserevid: Base revision id override, used to detect
+            conflicts.
+        :type baserevid: long
+        """
+        params = {
+            'action': 'wbladdform',
+            'lexemeId': lexeme.getID(),
+            'data': json.dumps(form.toJSON()),
+            'bot': bot,
+            'token': self.tokens['edit'],
+        }
+        if baserevid:
+            params['baserevid'] = baserevid
+        req = self._simple_request(**params)
+        data = req.submit()
+        return data
+
+    @need_right('edit')
+    @need_extension('WikibaseLexeme')
+    def remove_form(self, form, *, bot: bool = True, baserevid=None) -> dict:
+        """
+        Remove a form.
+
+        :param form: Form to be removed
+        :type form: pywikibot.LexemeForm
+        :keyword bot: Whether to mark the edit as a bot edit
+        :keyword baserevid: Base revision id override, used to detect
+            conflicts.
+        :type baserevid: long
+        """
+        params = {
+            'action': 'wblremoveform',
+            'id': form.getID(),
+            'bot': bot,
+            'token': self.tokens['edit'],
+        }
+        if baserevid:
+            params['baserevid'] = baserevid
+        req = self._simple_request(**params)
+        data = req.submit()
+        return data
+
+    @need_right('edit')
+    @need_extension('WikibaseLexeme')
+    def edit_form_elements(self, form, data, *, bot: bool = True,
+                           baserevid=None) -> dict:
+        """
+        Edit lexeme form elements.
+
+        :param form: Form
+        :type form: pywikibot.LexemeForm
+        :param data: data updates
+        :type data: dict
+        :keyword bot: Whether to mark the edit as a bot edit
+        :keyword baserevid: Base revision id override, used to detect
+            conflicts.
+        :type baserevid: long
+        :return: New form data
+        """
+        params = {
+            'action': 'wbleditformelements',
+            'formId': form.getID(),
+            'data': json.dumps(data),
+            'bot': bot,
+            'token': self.tokens['edit'],
+        }
+        if baserevid:
+            params['baserevid'] = baserevid
+        req = self._simple_request(**params)
+        data = req.submit()
+        return data

--
To view, visit https://gerrit.wikimedia.org/r/c/pywikibot/core/+/461372
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings

Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: I808dab5c3c4ba8169423e9796bac24f39cabac52
Gerrit-Change-Number: 461372
Gerrit-PatchSet: 21
Gerrit-Owner: Rua <[email protected]>
Gerrit-Assignee: Matěj Suchánek <[email protected]>
Gerrit-Reviewer: John Vandenberg <[email protected]>
Gerrit-Reviewer: Matěj Suchánek <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
Gerrit-CC: Lokal Profil <[email protected]>
Gerrit-CC: Welcome, new contributor! <[email protected]>
Gerrit-MessageType: merged
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to