1919
2020from collections import deque
2121from logging import getLogger
22- from typing import Any , Callable , Dict , Union
22+ from typing import Any , Callable , Dict , List , Union
2323from xml .etree import ElementTree as ET
2424
2525from openlcb .cdivar import CLASSNAME_TYPES , CDIVar
5757class 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
467660if __name__ == "__main__" :
0 commit comments