jenkins-bot has submitted this change. (
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1358295?usp=email )
Change subject: lexeme: Reload entity state after successful saves
......................................................................
lexeme: Reload entity state after successful saves
Discard cached Lexeme fields after edits instead of assigning response
identifiers by position. Reload the baseline before a subsequent implicit
save so unsaved child objects cannot be submitted again as additions.
Callers reacquire forms, senses and claims from the Lexeme after saving.
Keep the existing response handling for other Wikibase entities behind
an overridable hook.
Bug: T189321
Change-Id: Ie6e883aee0eff2048d2857593f9c94211fc0cc90
---
M pywikibot/page/_wikibase.py
M tests/wikibase_tests.py
2 files changed, 154 insertions(+), 4 deletions(-)
Approvals:
jenkins-bot: Verified
Xqt: Looks good to me, approved
diff --git a/pywikibot/page/_wikibase.py b/pywikibot/page/_wikibase.py
index adb9daa..9bd9c59 100644
--- a/pywikibot/page/_wikibase.py
+++ b/pywikibot/page/_wikibase.py
@@ -317,6 +317,16 @@
if getattr(self, 'id', '-1') == '-1':
self.__init__(self.repo, updates['entity']['id'])
+ self._update_from_edit_response(updates['entity'], update_self)
+
+ def _update_from_edit_response(
+ self, data: dict, update_self: bool
+ ) -> None:
+ """Update cached state after a successful edit.
+
+ :param data: Entity data returned by the edit API
+ :param update_self: Whether the edit used the local entity state
+ """
# the response also contains some data under the 'entity' key
# but it is NOT the actual content
# see also [[d:Special:Diff/1356933963]]
@@ -324,12 +334,12 @@
# the content can be safely reused
if hasattr(self, '_content'):
del self._content
- self.latest_revision_id = updates['entity'].get('lastrevid')
+ self.latest_revision_id = data.get('lastrevid')
- if not update_self or 'claims' not in updates['entity']:
+ if not update_self or 'claims' not in data:
return
- updated_claims = updates['entity']['claims']
+ updated_claims = data['claims']
for claim_prop_id, statements in updated_claims.items():
for claim_index, statement in enumerate(statements):
claim = self.claims[claim_prop_id][claim_index]
@@ -2342,6 +2352,38 @@
super().__init__(site, title, entity_type='lexeme')
assert self.id == self._link.title
+ def __getattr__(self, name):
+ if name in ('language', 'lexicalCategory') \
+ and self.__dict__.get('_needs_reload', False):
+ return self.get()[name]
+ return super().__getattr__(name)
+
+ @allow_asynchronous
+ def editEntity(
+ self, data: ENTITY_DATA_TYPE | None = None, **kwargs
+ ) -> None:
+ """Edit the Lexeme and invalidate its cached data on success.
+
+ Forms, senses and claims must be reacquired from this Lexeme after
+ saving. Existing references are snapshots and are not updated with
+ server-assigned identifiers. Data is reloaded on the next access.
+
+ :param data: API changes, or None to save local changes
+ """
+ if data is None and getattr(self, '_needs_reload', False):
+ self.get()
+ super().editEntity(data, **kwargs)
+
+ def _update_from_edit_response(
+ self, data: dict, update_self: bool
+ ) -> None:
+ """Invalidate Lexeme data without matching response IDs by position."""
+ for attr in (*self.DATA_ATTRIBUTES, 'language', 'lexicalCategory',
+ '_content'):
+ self.__dict__.pop(attr, None)
+ self.latest_revision_id = data.get('lastrevid')
+ self._needs_reload = True
+
def get_data_for_new_entity(self) -> NoReturn:
"""Return data required for creation of a new lexeme."""
raise NotImplementedError # TODO
@@ -2400,6 +2442,7 @@
data['language'] = self.language
data['lexicalCategory'] = self.lexicalCategory
+ self._needs_reload = False
return data
diff --git a/tests/wikibase_tests.py b/tests/wikibase_tests.py
index f363068..2eb202e 100755
--- a/tests/wikibase_tests.py
+++ b/tests/wikibase_tests.py
@@ -11,7 +11,7 @@
import json
import unittest
from contextlib import suppress
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pywikibot
from pywikibot import pagegenerators
@@ -1200,6 +1200,113 @@
self.assertEqual(LexemeForm._normalizeData(expected), expected)
+class TestLexemeSave(WikidataTestCase):
+
+ """Test Lexeme save state without writing to a wiki."""
+
+ dry = True
+
+ def setUp(self) -> None:
+ """Provide a repository with a Lexeme namespace and cached data."""
+ super().setUp()
+ self.repo = self.get_repo()
+ namespace = Namespace(146, canonical_name='Lexeme',
+ case='first-letter',
+ defaultcontentmodel='wikibase-lexeme')
+ namespaces = dict(self.repo.namespaces)
+ namespaces[146] = namespace
+ for attribute, value in (
+ ('_namespaces', NamespacesDict(namespaces)),
+ ('_entity_namespaces', {'item': namespaces[0],
+ 'lexeme': namespace}),
+ ('entity_sources', MagicMock(return_value={})),
+ ):
+ patcher = patch.object(self.repo, attribute, value, create=True)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.content = {
+ 'id': 'L1', 'lastrevid': 1,
+ 'lemmas': {'en': {'language': 'en', 'value': 'colour'}},
+ 'language': 'Q1860', 'lexicalCategory': 'Q1084', 'claims': {},
+ 'forms': [], 'senses': [],
+ }
+ self.lexeme = LexemePage(self.repo, 'L1')
+ self.lexeme._content = copy.deepcopy(self.content)
+ self.lexeme.get()
+
+ def test_save_reload(self) -> None:
+ """Successful saves discard snapshots and reload before diffing."""
+ lexeme = self.lexeme
+ old_forms = lexeme.forms
+ response = dict(self.content, lastrevid=2)
+ response['forms'] = [{
+ 'id': 'L1-F1', 'representations': {
+ 'en': {'language': 'en', 'value': 'colours'}},
+ 'grammaticalFeatures': [], 'claims': {},
+ }]
+ with patch.object(self.repo, 'editEntity', return_value={
+ 'entity': response}) as save, patch.object(
+ self.repo, 'loadcontent',
+ side_effect=lambda *args: {'L1': copy.deepcopy(response)}) \
+ as load, patch.object(lexeme, 'toJSON', return_value={}) \
+ as serialize:
+ callback = MagicMock()
+ with patch.object(pywikibot, 'async_request',
+ side_effect=lambda func, *args, **kwargs:
+ func(*args, **kwargs)):
+ lexeme.editEntity({'lemmas': {'en': 'colour'}},
+ asynchronous=True, callback=callback)
+ callback.assert_called_once_with(lexeme, None)
+ load.assert_not_called()
+ self.assertEqual(lexeme.latest_revision_id, 2)
+ self.assertNotIn('forms', lexeme.__dict__)
+ self.assertNotIn('claims', lexeme.__dict__)
+ lexeme.editEntity()
+ load.assert_called_once()
+ serialize.assert_called_once_with(diffto=response)
+ self.assertEqual(save.call_args.kwargs['baserevid'], 2)
+ self.assertEqual(lexeme.language.id, 'Q1860')
+ self.assertIsNot(lexeme.forms, old_forms)
+ self.assertEqual(lexeme.forms[0].id, 'L1-F1')
+
+ def test_failed_save_and_reload(self) -> None:
+ """Rejected edits preserve changes; failed reloads prevent writes."""
+ lexeme = self.lexeme
+ lexeme.lemmas['en'] = 'changed'
+ with patch.object(self.repo, 'editEntity',
+ side_effect=ValueError('rejected')):
+ with self.assertRaisesRegex(ValueError, 'rejected'):
+ lexeme.editEntity({'lemmas': {'en': 'changed'}})
+ self.assertEqual(lexeme.lemmas['en'], 'changed')
+ self.assertEqual(lexeme.latest_revision_id, 1)
+ lexeme._update_from_edit_response({'lastrevid': 2}, True)
+ with patch.object(self.repo, 'loadcontent',
+ side_effect=ValueError('reload failed')), \
+ patch.object(self.repo, 'editEntity') as save:
+ with self.assertRaisesRegex(ValueError, 'reload failed'):
+ lexeme.editEntity()
+ save.assert_not_called()
+ self.assertTrue(lexeme._needs_reload)
+
+ def test_item_response(self) -> None:
+ """Other entities still receive statement IDs from edit responses."""
+ item = ItemPage(self.repo, 'Q1')
+ item._content = {'id': 'Q1', 'lastrevid': 1}
+ item.get()
+ claim = Claim(self.repo, 'P1', datatype='string')
+ claim.setTarget('value')
+ item.claims['P1'] = [claim]
+ statement = dict(claim.toJSON(), id='Q1$statement')
+ with patch.object(self.repo, 'editEntity', return_value={
+ 'entity': {'id': 'Q1', 'lastrevid': 2,
+ 'claims': {'P1': [statement]}}}):
+ item.editEntity()
+ self.assertEqual(claim.snak, 'Q1$statement')
+ self.assertIs(claim.on_item, item)
+ self.assertEqual(item.latest_revision_id, 2)
+ self.assertNotIn('_content', item.__dict__)
+
+
class TestMediaInfoNormalizeData(TestCase):
"""Test cases for MediaInfo._normalizeData and MediaInfo.toJSON."""
--
To view, visit
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1358295?usp=email
To unsubscribe, or for help writing mail filters, visit
https://gerrit.wikimedia.org/r/settings?usp=email
Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: Ie6e883aee0eff2048d2857593f9c94211fc0cc90
Gerrit-Change-Number: 1358295
Gerrit-PatchSet: 5
Gerrit-Owner: Mahveotm <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]