home · contact · privacy
Move into_endnode_updates into ClientDb.
authorChristian Heller <c.heller@plomlompom.de>
Thu, 18 Sep 2025 00:45:16 +0000 (02:45 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 18 Sep 2025 00:45:16 +0000 (02:45 +0200)
ircplom/client.py
ircplom/client_tui.py
test.txt

index cb5c575cfb1de75c286058535dc129084bf705f1..14274b65dd3b82e7222efb81809006011a718241 100644 (file)
@@ -93,41 +93,41 @@ class _Dict(Dict[DictItem]):
 
 
 class _Completable(ABC):
-    _completed: Any
+    completed: Any
 
     @abstractmethod
     def complete(self) -> None:
-        'Set ._completed to "complete" value if possible of current state.'
+        'Set .completed to "complete" value if possible of current state.'
 
 
 class _CompletableStringsCollection(_Completable, Collection):
     _collected: Collection[str]
-    _completed: Optional[Collection[str]] = None
+    completed: Optional[Collection[str]] = None
 
     @abstractmethod
     def _copy_collected(self) -> Collection[str]:
         pass
 
     def complete(self) -> None:
-        self._completed = self._copy_collected()
+        self.completed = self._copy_collected()
 
     def __len__(self) -> int:
-        assert self._completed is not None
-        return len(self._completed)
+        assert self.completed is not None
+        return len(self.completed)
 
     def __contains__(self, item) -> bool:
-        assert self._completed is not None
+        assert self.completed is not None
         assert isinstance(item, str)
-        return item in self._completed
+        return item in self.completed
 
     def __iter__(self) -> Iterator[str]:
-        assert self._completed is not None
-        yield from self._completed
+        assert self.completed is not None
+        yield from self.completed
 
 
 class _CompletableStringsSet(_CompletableStringsCollection, Set):
     _collected: set[str]
-    _completed: Optional[set[str]] = None
+    completed: Optional[set[str]] = None
 
     def __init__(self) -> None:
         self._collected = set()
@@ -139,13 +139,13 @@ class _CompletableStringsSet(_CompletableStringsCollection, Set):
                 ) -> None:
         method = getattr(self._collected, m_name)
         if on_complete:
-            assert self._completed is not None
-            assert (item in self._completed) == exists
+            assert self.completed is not None
+            assert (item in self.completed) == exists
             assert (item in self._collected) == exists
             method(item)
             self.complete()
         else:
-            assert self._completed is None
+            assert self.completed is None
             assert (item in self._collected) == exists
             method(item)
 
@@ -159,8 +159,8 @@ class _CompletableStringsSet(_CompletableStringsCollection, Set):
 
     def intersection(self, *others: Iterable[str]) -> set[str]:
         'Compare self to other set(s).'
-        assert self._completed is not None
-        result = self._completed
+        assert self.completed is not None
+        result = self.completed
         for other in others:
             assert isinstance(other, set)
             result = result.intersection(other)
@@ -169,7 +169,7 @@ class _CompletableStringsSet(_CompletableStringsCollection, Set):
 
 class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection):
     _collected: tuple[str, ...] = tuple()
-    _completed: Optional[tuple[str, ...]] = tuple()
+    completed: Optional[tuple[str, ...]] = tuple()
 
     def _copy_collected(self) -> tuple[str, ...]:
         return tuple(self._collected)
@@ -185,57 +185,14 @@ class _CompletableStringsOrdered(_Clearable, _CompletableStringsCollection):
         self.complete()
 
 
-class IntoEndnodeUpdatesMixin(AutoAttrMixin):
-    'Provides .into_endnode_updates exportable for module-external consumers.'
-    _cache: dict[tuple[str, ...], Any] = {}
-
-    def into_endnode_updates(self,
-                             client_id: str,
-                             path: tuple[str, ...]
-                             ) -> list[tuple[tuple[str, ...], Any]]:
-        'Return path-value pairs for any update-worthy sub-elements.'
-
-        def update_unless_cached(path: tuple[str, ...], val: Any):
-            steps_so_far: list[str] = []
-            for step in path[:-1]:
-                cache_path = tuple(client_id) + tuple(steps_so_far)
-                if cache_path in self._cache:
-                    del self._cache[cache_path]
-                steps_so_far += [step]
-            cache_path = tuple(client_id) + path
-            if cache_path not in self._cache or val != self._cache[cache_path]:
-                self._cache[cache_path] = val
-                return [(path, val)]
-            return []
-
-        if isinstance(self, _Completable):
-            if self._completed is not None:
-                return update_unless_cached(path, self._completed)
-            return []
-
-        if isinstance(self, Dict) and not self._dict:
-            return update_unless_cached(path, None)
-
-        updates = []
-        for key, val in (self._dict.items() if isinstance(self, Dict)
-                         else ((k, getattr(self, k))
-                               for k in self._deep_annotations())):
-            p = path + (key,)
-            if isinstance(val, IntoEndnodeUpdatesMixin):
-                updates += val.into_endnode_updates(client_id, p)
-            else:
-                updates += update_unless_cached(p, val)
-        return updates
-
-
-class _UpdatingMixin(IntoEndnodeUpdatesMixin):
+class _UpdatingMixin:
 
     def __init__(self, on_update: Callable, **kwargs) -> None:
         super().__init__(**kwargs)
         self._on_update = on_update
 
 
-class _UpdatingAttrsMixin(_UpdatingMixin):
+class _UpdatingAttrsMixin(_UpdatingMixin, AutoAttrMixin):
 
     def _make_attr(self, cls: Callable, key: str):
         return cls(on_update=lambda *steps: self._on_update(key, *steps))
@@ -245,10 +202,17 @@ class _UpdatingAttrsMixin(_UpdatingMixin):
         if hasattr(self, '_on_update') and key in self._deep_annotations():
             self._on_update(key)
 
+    def attr_names(self) -> tuple[str, ...]:
+        'Names of (deeply) annotated attributes.'
+        return tuple(self._deep_annotations().keys())
+
 
 class _UpdatingDict(_UpdatingMixin, _Dict[DictItem]):
     _create_if_none: Optional[dict[str, Any]] = None
 
+    def __bool__(self) -> bool:
+        return bool(self._dict)
+
     def __getitem__(self, key: str) -> DictItem:
         if key not in self._dict:
             if self._create_if_none is not None:
@@ -395,13 +359,13 @@ class IrcConnection(BaseIrcConnection, _ClientIdMixin):
 
 
 class _CompletableTopic(_Completable, Topic):
-    _completed: Optional[Topic] = None
+    completed: Optional[Topic] = None
 
     def complete(self) -> None:
         assert self.who is not None
         copy = _NickUserHost.from_str(str(self.who))
-        self._completed = Topic(self.what,
-                                NickUserHost(copy.nick, copy.user, copy.host))
+        self.completed = Topic(self.what,
+                               NickUserHost(copy.nick, copy.user, copy.host))
 
 
 class _Channel(Channel):
@@ -627,6 +591,7 @@ class _UpdatingIsupportDict(_UpdatingDict[str]):
 
 
 class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields):
+    _updates_cache: dict[tuple[str, ...], Any] = {}
     _keep_on_clear = set(IrcConnSetup.__annotations__.keys())
     caps: _UpdatingDict[_UpdatingServerCapability]
     channels: _UpdatingChannelsDict
@@ -651,6 +616,53 @@ class _ClientDb(_Clearable, _UpdatingAttrsMixin, SharedClientDbFields):
             attr._create_if_none = {}
         return attr
 
+    def into_endnode_updates(self, path: tuple[str, ...]
+                             ) -> list[tuple[tuple[str, ...], Any]]:
+        'Return path-value pairs for update-worthy (sub-)elements at path.'
+
+        def update_unless_cached(path: tuple[str, ...], val: Any
+                                 ) -> list[tuple[tuple[str, ...], Any]]:
+            if path not in self._updates_cache\
+                    or val != self._updates_cache[path]:
+                self._updates_cache[path] = val
+                return [(path, val)]
+            return []
+
+        parent = self
+        cache_path: tuple[str, ...] = tuple()
+        for step in path[:-1]:
+            cache_path = cache_path + (step,)
+            if cache_path in self._updates_cache:
+                del self._updates_cache[cache_path]
+            parent = (parent[step] if isinstance(parent, Dict)
+                      else getattr(parent, step))
+        last_step = path[-1]
+        val_at_path = ((parent[last_step] if last_step in parent.keys()
+                        else None) if isinstance(parent, Dict)
+                       else getattr(parent, last_step))
+        if not isinstance(val_at_path, _UpdatingMixin):
+            return update_unless_cached(path, val_at_path)
+        if isinstance(val_at_path, _Completable):
+            if val_at_path.completed is not None:
+                return update_unless_cached(path, val_at_path.completed)
+            return []
+        if isinstance(val_at_path, Dict) and not val_at_path.keys():
+            for cache_path in [p for p in self._updates_cache
+                               if len(p) > len(path)
+                               and p[:len(path)] == path]:
+                del self._updates_cache[cache_path]
+            return update_unless_cached(path, None)
+        if isinstance(val_at_path, Dict):
+            sub_items = val_at_path.keys()
+        else:
+            assert isinstance(val_at_path, _UpdatingAttrsMixin)
+            sub_items = val_at_path.attr_names()
+        updates = []
+        for key in sub_items:
+            p = path + (key,)
+            updates += self.into_endnode_updates(p)
+        return updates
+
     def clear(self) -> None:
         for key, value in [(k, v) for k, v in self._deep_annotations().items()
                            if k not in self._keep_on_clear]:
index 9b41e9579d2e0ff33f6ae67ab489095967c83545..ba7a2f58d294c2fabbf9782f0550594875aa8f05 100644 (file)
@@ -7,8 +7,8 @@ from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
                               CMD_SHORTCUTS)
 from ircplom.client import (
         AutoAttrMixin, Channel, Client, ClientQueueMixin, Dict, DictItem,
-        IntoEndnodeUpdatesMixin, IrcConnSetup, LogScope, NewClientEvent,
-        NickUserHost, ServerCapability, SharedClientDbFields, User)
+        IrcConnSetup, LogScope, NewClientEvent, NickUserHost, ServerCapability,
+        SharedClientDbFields, User)
 
 CMD_SHORTCUTS['disconnect'] = 'window.disconnect'
 CMD_SHORTCUTS['join'] = 'window.join'
@@ -505,15 +505,5 @@ class ClientKnowingTui(Client):
                     f.write(('>' if kwargs['out'] else '<') + f' {msg}\n')
 
     def _on_update(self, *path) -> None:
-        parent = self.db
-        for step in path[:-1]:
-            parent = (parent[step] if isinstance(parent, Dict)
-                      else getattr(parent, step))
-        last_step = path[-1]
-        value = ((parent[last_step] if last_step in parent.keys()
-                  else None) if isinstance(parent, Dict)
-                 else getattr(parent, last_step))
-        for path, value in (value.into_endnode_updates(self.client_id, path)
-                            if isinstance(value, IntoEndnodeUpdatesMixin)
-                            else [(path, value)]):
+        for path, value in self.db.into_endnode_updates(path):
             self._client_tui_trigger('update_db', update=_Update(path, value))
index e5a7011a64a92b444f4363499937f7d94b8c6fad..7f52a0209972502baa039044d212eec4054cb080 100644 (file)
--- a/test.txt
+++ b/test.txt
@@ -52,9 +52,9 @@
 1,2 $ port set to: [6697]
 
 1,2 $ connection_state set to: [connecting]
+
 1,2 $ channels cleared
 1,2 $ users cleared
-
 1,2 $ ?!?@? renames ?
 1,2 $ users:me:user set to: [plom]
 1,2 $ connection_state set to: [connected]
 1,2 $$$ *** Found your hostname (baz.bar.foo)
 
 2 < :*.?.net CAP * LS : foo bar sasl=PLAIN,EXTERNAL baz
-
 1,2 $ isupport:CHANTYPES set to: [#&]
 1,2 $ isupport:PREFIX set to: [(ov)@+]
-
 2 > CAP REQ :sasl
 2 > CAP :LIST
 
 2 < :bazbaz!~baz@baz.baz PART :#test
 4 $ bazbaz!~baz@baz.baz parts
 1,2 $ users:2 cleared
+, $ ?!?@? renames ?
 2 < :bazbaz!~baz@baz.baz JOIN :#test
 , $ ?!?@? renames ?
 , $ ?!?@? renames bazbaz
 4 $ bazbaz!~baz@baz.baz quits: Client Quit
 1,2 $ users:2 cleared
 1,2 $ users:3 cleared
+, $ ?!?@? renames ?
 2 < :foo!~plom@baz.bar.foo PART :#test
 1,2,3,4 $ foo!~plom@baz.bar.foo parts
 1,2 $ users:3 cleared
 2 < :*.?.net CAP * LS : foo bar sasl=PLAIN,EXTERNAL baz
 2 > CAP REQ :sasl
 2 > CAP :LIST
+
 # NB: missing are default settings of isupport:CHANTYPES, :PREFIX
+
 2 < PING :?
 2 > PONG :?
 2 < :*.?.net CAP ? ACK :sasl
 1,2 $ caps:bar:data set to: []
 1,2 $ caps:baz:data set to: []
 1,2 $ caps:foo:data set to: []
+1,2 $ caps:sasl:data set to: []
 1,2 $ caps:sasl:data set to: [PLAIN,EXTERNAL]
 1,2 $ caps:sasl:enabled set to: [True]
 1,2 $ sasl_auth_state set to: [attempting]