Skip to content

Commit fe39568

Browse files
committed
Add realtime replication, & optional (enableRepDump) related dump for manual validation (validated, so disabled by default). Use after_idle consistently to maintain FIFO order.
1 parent feb2b8c commit fe39568

5 files changed

Lines changed: 349 additions & 37 deletions

File tree

examples/examples_gui.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ def handleMessage(self, message: Message):
517517
by the socket loop thread, so we must use self.root.after to
518518
trigger methods which affect the GUI (such as _handleMessage).
519519
"""
520-
self.root.after(0, self._handleMessage(message))
520+
self.root.after_idle(self._handleMessage, message)
521521

522522
def _handleMessage(self, message: Message):
523523
"""Main thread Message handler.
@@ -590,7 +590,7 @@ def _connect(self):
590590
self._tcp_socket,
591591
)
592592
self._connect_thread = None
593-
self.cdi_connect_button.configure(state=tk.NORMAL)
593+
# self.cdi_connect_button.configure(state=tk.NORMAL)
594594
except Exception as ex:
595595
if self.cdi_form.getStatus() == msg:
596596
# If error wasn't shown, clear startup message.

examples/tkexamples/cdiform.py

Lines changed: 220 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from collections import deque
2121
from logging import getLogger
22-
from typing import Any, Callable, Dict, Union
22+
from typing import Any, Callable, Dict, List, Union
2323
from xml.etree import ElementTree as ET
2424

2525
from openlcb.cdivar import CLASSNAME_TYPES, CDIVar
@@ -57,6 +57,13 @@
5757
class CDIForm(ttk.Frame, XMLDataProcessor):
5858
"""A GUI frame to represent the CDI visually as a tree.
5959
60+
Attributes:
61+
enableRepDump (bool): Print XML to console while
62+
performing replication. Replication is done in this class
63+
rather than calling replicatedTree, so that widgets can be
64+
generated in real time (while downloading XML).
65+
In general, using replicatedTree is easier.
66+
6067
Args:
6168
parent (TkWidget): Typically a ttk.Frame or tk.Frame with "root"
6269
attribute set.
@@ -86,6 +93,51 @@ def __init__(self, *args, **kwargs):
8693
self.cdiSettingWidgets = [] # type: list[tk.Widget]
8794
self.cdiSettingRow = 0
8895
self.cdiSettingFrame = None # type: Union[ttk.Frame, tk.Frame, None]
96+
assert not hasattr(self, 'address'), "using redundant variable"
97+
self._parsing_address = None
98+
self._scope = [] # type: list[CDIMemo]
99+
self.multilineTags = ["segment", "group"]
100+
self.multilineTags += list(CLASSNAME_TYPES.keys())
101+
self.multilineTags += ["map", "relation"]
102+
self.enableRepDump = False
103+
104+
def scopeIndent(self, tab=" ") -> str:
105+
"""Get indent for debug lines
106+
for showing tag scope visually (as indentation) during parsing.
107+
"""
108+
return tab * len(self._scope)
109+
110+
def scopeTags(self, show_attrib=True) -> List[str]:
111+
"""Get debug info regarding XML stack
112+
(current parsing scope). It is empty after document is finished.
113+
"""
114+
items = []
115+
for cm in self._scope:
116+
tagRepr = cm.tag
117+
if show_attrib:
118+
assert cm.tag is not None
119+
tagRepr = "<" + cm.tag
120+
if cm.element is not None:
121+
for k, v in cm.element.attrib.items():
122+
tagRepr += f' {k}="{v}"'
123+
tagRepr += ">"
124+
items.append(tagRepr)
125+
return items
126+
127+
def getScopeIdx(self, tag):
128+
tag = tag.lower()
129+
for idx in reversed(range(len(self._scope))):
130+
cm = self._scope[idx]
131+
assert cm.tag is not None
132+
if cm.tag.lower() == tag:
133+
return idx
134+
return -1
135+
136+
def getScope(self, tag):
137+
idx = self.getScopeIdx(tag)
138+
if idx < 0:
139+
return None
140+
return self._scope[idx]
89141

90142
def setSettingsContainer(self, container: Union[ttk.Frame, tk.Frame]):
91143
self.cdiSettingFrame = container
@@ -154,9 +206,9 @@ def onTreeSelect(self, event: tk.Event):
154206
else:
155207
raise TypeError("Device should not specify max for {}"
156208
.format(cdivar.className))
157-
158209
v_widget = ttk.LabeledScale(self.cdiSettingFrame, variable=tkvar)
159210
# ^ widget.scale is ttk.Scale, widget.label is ttk.Label
211+
# ^ a.k.a. Slider (if not using Tk)
160212
v_widget.scale.cdivar = cdivar
161213
v_widget.scale.tip = nameLabel.tip
162214
else:
@@ -241,22 +293,156 @@ def onStatusMemo(self, cm: CDIMemo) -> bool:
241293
elif cm.done:
242294
show_status = "Done loading CDI."
243295
if show_status:
244-
self.root.after(0, self.setStatus, show_status)
296+
self.root.after_idle(self.setStatus, show_status)
245297
if cm.done:
246298
return True
247299
return False
248300

249-
def onPushScope(self, cm: CDIMemo) -> bool:
301+
def onPushScope(self, cm: CDIMemo,
302+
replication_index: Union[int, None] = None) -> bool:
303+
if self.enableRepDump:
304+
if cm.tag not in self.multilineTags:
305+
sys.stdout.write(self.scopeIndent() + cm.toXMLStart())
306+
else:
307+
print(self.scopeIndent() + cm.toXMLStart())
308+
if self._scope:
309+
if cm.parent is not self._scope[-1]:
310+
old = self._scope[-1]
311+
old_name = old.getChildContent('name')
312+
new_name = cm.parent.getChildContent('name')
313+
old_idx = old.element.attrib.get('replicated_index')
314+
new_idx = cm.parent.element.attrib.get('replicated_index')
315+
logger.info(
316+
"expected same parent"
317+
# f" {CDIMemo.to_dict(cm.parent, trim_blank=True)},"
318+
f" {old.tag} name={old_name} idx={old_idx}"
319+
f" got other parent {cm.parent.tag} name={new_name}"
320+
f" idx={new_idx},")
321+
if replication_index is not None:
322+
cm.element.attrib['replication_index'] = str(replication_index)
323+
self._scope.append(cm)
250324
if cm.element is None:
251325
raise ValueError("No element for push tag event")
252-
self.root.after(0, self._onPushScope, cm)
326+
# Parse in realtime to prevent out-of-order processing
327+
# potentially caused by the UI framework's "after" method.
328+
offset = cm.element.attrib.get('offset')
329+
if offset is not None:
330+
offset = int(offset)
331+
assert self._parsing_address is not None, \
332+
f"{cm.tag} offset before segment!"
333+
self._parsing_address += offset
334+
335+
# NOTE: _onPushScope (not onPushScope) is on main thread which
336+
# is the only thread that can affect the GUI.
337+
if cm.tag == "segment":
338+
if self.getScope("group") is not None:
339+
raise RuntimeError(
340+
"Tried to parse segment start before group end"
341+
" (or less likely, XML is non-standard"
342+
" having segment in group)")
343+
self._parsing_space = int(cm.element.attrib['space'])
344+
origin = cm.element.attrib.get('origin')
345+
if origin is None:
346+
origin = 0
347+
logger.debug(f"Defaulting segment to origin={origin}")
348+
self._parsing_address = int(origin)
349+
elif cm.tag == "group":
350+
assert self._parsing_address is not None, \
351+
f"{cm.tag} before segment!"
352+
# replication: See onPopScope (after entire size is known)
353+
elif cm.tag in CLASSNAME_TYPES:
354+
assert self._parsing_address is not None, \
355+
f"{cm.tag} before segment!"
356+
cm.space = self._parsing_space
357+
cm.address = self._parsing_address
358+
# NOTE: ^ This becomes the real address since onPopScope
359+
# performs replication and calls onPushScope again for
360+
# each (excluding first) copy.
361+
varSize = cm.getSize()
362+
assert varSize is not None, f"expected size for {cm.tag}"
363+
varSize = int(varSize)
364+
self._parsing_address += varSize
365+
self.root.after_idle(self._onPushScope, cm)
253366
self.onStatusMemo(cm)
254367
return True
255368

369+
def recursiveParse(self, cm: CDIMemo,
370+
replication_index: Union[int, None] = None):
371+
"""Push a non-XML (generated) memo, simulating recursive parsing
372+
"""
373+
self.onPushScope(cm, replication_index=replication_index)
374+
for child in cm.children:
375+
child.parent = cm
376+
self.recursiveParse(child, replication_index=replication_index)
377+
self.onPopScope(cm)
378+
256379
def onPopScope(self, cm: CDIMemo) -> bool:
380+
if cm.tag != self._scope[-1].tag:
381+
space = None
382+
origin = None
383+
if cm.element is not None:
384+
space = cm.element.get('space')
385+
origin = cm.element.get('origin')
386+
logger.warning(
387+
f"Popping </{cm.tag}> (space={space} origin={origin})"
388+
f" before </{self._scope[-1].tag}>"
389+
f" (stack: {self.scopeTags()})")
390+
topMemo = self._scope.pop()
391+
assert topMemo is not None
392+
assert cm is topMemo, \
393+
f"Got {cm.toXMLStart()} different than top {topMemo.toXMLStart()}"
394+
content = ""
395+
# Content isn't collected until end tag.
396+
if (cm.element is not None) and (cm.element.text is not None):
397+
content = cm.element.text
398+
elif cm.content is not None:
399+
content = cm.content
400+
if self.enableRepDump:
401+
sys.stdout.write(content)
402+
if cm.tag not in self.multilineTags:
403+
print(cm.toXMLEnd()) # use print even for single line tag
404+
# since this is the end of the element.
405+
else:
406+
print(self.scopeIndent() + cm.toXMLEnd())
257407
if cm.element is None:
258408
raise ValueError("No element for pop tag event")
259-
self.root.after(0, self._onPopScope, cm)
409+
# memos = [cm]
410+
replication = cm.element.attrib.get('replication')
411+
if replication is not None:
412+
# Replication must be during onPopScope since children
413+
# weren't processed until now.
414+
replication = int(replication)
415+
for i in range(replication):
416+
if i == 0:
417+
# else onPushScope was already called for [0] (original)
418+
# cm.element.attrib['replication_index'] = str(i)
419+
self.root.after_idle(self._onPopScope, cm)
420+
continue
421+
replicatedMemo = cm.copy()
422+
replicatedMemo.iid = None # Not in tree yet
423+
# (See _treeview.insert in onPushScope)
424+
# Delete replication to prevent infinite replication:
425+
assert replicatedMemo.element is not None
426+
del replicatedMemo.element.attrib['replication']
427+
replicatedMemo.element.attrib['replication_index'] = str(i)
428+
# memos.append(replicatedMemo)
429+
self.recursiveParse(
430+
replicatedMemo,
431+
replication_index=i
432+
)
433+
# self.onPushScope(replicatedMemo, replication_index=i)
434+
# if replicatedMemo.children:
435+
# assert len(replicatedMemo.children) == len(cm.children)
436+
# for cI, child in enumerate(replicatedMemo.children):
437+
# child.parent = replicatedMemo
438+
# assert (len(child.children)
439+
# == len(cm.children[cI].children))
440+
# self.recursiveParse(child, replication_index=i)
441+
# self.onPopScope(replicatedMemo)
442+
self.onStatusMemo(cm)
443+
return True
444+
445+
self.root.after_idle(self._onPopScope, cm)
260446
self.onStatusMemo(cm)
261447
return True
262448

@@ -282,29 +468,44 @@ def _onPopScope(self, cm: CDIMemo):
282468
- 'content' (str): Content (only set during this
283469
callback, not start tag).
284470
285-
Raises:
286-
NotImplementedError: _description_
287-
NotImplementedError: _description_
288471
"""
289472
if self.cursorCol != 0:
290473
self.debug()
291474
nameLower = cm.tag.lower() if cm.tag else None
292475
assert nameLower is not None # only None for done/fail events
293476
cm.content
294477
assert self._treeview is not None
295-
if nameLower == "name":
296-
parentIID = self.getParentBranch(cm)
478+
if nameLower in ("name", "repname"):
479+
parentIID = self.getParentBranch(cm) # source is cm.parent.iid
480+
# where parent is also a CDIMemo (if parent is None, then cm.iid
481+
# or "" to place at top level of tree)
297482
assert parentIID is not None, "name must be in a branch"
483+
content = cm.content
484+
if nameLower == "repname":
485+
if content is not None:
486+
content = content.strip()
487+
else:
488+
content = ""
489+
assert cm.parent is not None
490+
assert cm.parent.element is not None
491+
idx = cm.parent.element.attrib.get('replication_index')
492+
if idx is not None:
493+
idx = int(idx)
494+
content += f" #{idx+1}"
298495
if parentIID:
299-
assert cm.content is not None
300-
cm.content = cm.content.strip()
496+
# assert content is not None
301497
if cm.content is None:
302498
logger.warning(
303499
self.indent() + f"content is None for /{cm.tag}")
304500
cm.content = ""
501+
content = ""
502+
if nameLower == "repname":
503+
nameItem = self._treeview.item(parentIID)
504+
if nameItem:
505+
content = f"{nameItem['text']}: {content}"
305506
# "name" applies to parent, such as "segment" or "string"
306-
_ = self._treeview.item(
307-
parentIID, text=cm.content.strip())
507+
if content is not None:
508+
_ = self._treeview.item(parentIID, text=content)
308509
origin = cm.element.attrib.get('origin') if cm.element else None
309510
if cm.content:
310511
if cm.tag == "segment":
@@ -389,13 +590,10 @@ def _onPushScope(self, cm: CDIMemo):
389590
optional for identification and its children otherwise
390591
required (previous start tags by XMLDataProcessor)
391592
392-
Raises:
393-
NotImplementedError: _description_
394-
NotImplementedError: _description_
395593
"""
396594
# NOTE: If it is self-closing such as
397595
# `<group offset='4'/>`,
398-
# then _onPopScope will also run (see endElement such
596+
# then onPopScope will run next (via endElement such
399597
# as in python-openlcb's implementation of ContentHandler).
400598
assert cm.element is not None
401599
tag = cm.element.tag if cm.element is not None else None
@@ -441,14 +639,8 @@ def _onPushScope(self, cm: CDIMemo):
441639
self._current_iid += 1 # TODO: associate with SubElement
442640
elif tagLower == "acdi":
443641
pass # handled by superclass (sets self.acdi)
444-
elif tagLower in ("int", "string", "float"):
445-
content = ""
446-
for child in cm.element:
447-
if child.tag == "name":
448-
content = child.text
449-
if content is None:
450-
content = ""
451-
break
642+
elif tagLower in CLASSNAME_TYPES:
643+
content = "" # NOTE: name sub-tag isn't parsed yet.
452644
new_branch = self._treeview.insert(
453645
self.getParentBranch(cm),
454646
index,
@@ -462,6 +654,7 @@ def _onPushScope(self, cm: CDIMemo):
462654
cm.iid = new_branch
463655
self._current_iid += 1 # TODO: associate with SubElement
464656
# and/or set values keyword argument to create association(s)
657+
# NOTE: Can't get content of any tag such as name until onPopScope
465658

466659

467660
if __name__ == "__main__":

0 commit comments

Comments
 (0)