reacton 1.10.1__tar.gz → 1.10.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {reacton-1.10.1 → reacton-1.10.3}/PKG-INFO +2 -2
  2. reacton-1.10.3/reacton/_version.py +1 -0
  3. {reacton-1.10.1 → reacton-1.10.3}/reacton/core.py +110 -30
  4. {reacton-1.10.1 → reacton-1.10.3}/reacton/core_test.py +219 -0
  5. reacton-1.10.1/reacton/_version.py +0 -1
  6. {reacton-1.10.1 → reacton-1.10.3}/.gitignore +0 -0
  7. {reacton-1.10.1 → reacton-1.10.3}/LICENSE +0 -0
  8. {reacton-1.10.1 → reacton-1.10.3}/pyproject.toml +0 -0
  9. {reacton-1.10.1 → reacton-1.10.3}/react_ipywidgets/__init__.py +0 -0
  10. {reacton-1.10.1 → reacton-1.10.3}/react_ipywidgets/core.py +0 -0
  11. {reacton-1.10.1 → reacton-1.10.3}/reacton/__init__.py +0 -0
  12. {reacton-1.10.1 → reacton-1.10.3}/reacton/bqplot.py +0 -0
  13. {reacton-1.10.1 → reacton-1.10.3}/reacton/deprecated/__init__.py +0 -0
  14. {reacton-1.10.1 → reacton-1.10.3}/reacton/deprecated/ipyvuetify.py +0 -0
  15. {reacton-1.10.1 → reacton-1.10.3}/reacton/find.py +0 -0
  16. {reacton-1.10.1 → reacton-1.10.3}/reacton/find_test.py +0 -0
  17. {reacton-1.10.1 → reacton-1.10.3}/reacton/generate.py +0 -0
  18. {reacton-1.10.1 → reacton-1.10.3}/reacton/generate_test.py +0 -0
  19. {reacton-1.10.1 → reacton-1.10.3}/reacton/ipycanvas.py +0 -0
  20. {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvue.py +0 -0
  21. {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvue_test.py +0 -0
  22. {reacton-1.10.1 → reacton-1.10.3}/reacton/ipyvuetify.py +0 -0
  23. {reacton-1.10.1 → reacton-1.10.3}/reacton/ipywidgets.py +0 -0
  24. {reacton-1.10.1 → reacton-1.10.3}/reacton/logging.py +0 -0
  25. {reacton-1.10.1 → reacton-1.10.3}/reacton/patch.py +0 -0
  26. {reacton-1.10.1 → reacton-1.10.3}/reacton/patch_display.py +0 -0
  27. {reacton-1.10.1 → reacton-1.10.3}/reacton/py.typed +0 -0
  28. {reacton-1.10.1 → reacton-1.10.3}/reacton/rx.py +0 -0
  29. {reacton-1.10.1 → reacton-1.10.3}/reacton/test.vue +0 -0
  30. {reacton-1.10.1 → reacton-1.10.3}/reacton/test_rx.py +0 -0
  31. {reacton-1.10.1 → reacton-1.10.3}/reacton/utils.py +0 -0
  32. {reacton-1.10.1 → reacton-1.10.3}/reacton/utils_test.py +0 -0
  33. {reacton-1.10.1 → reacton-1.10.3}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: reacton
3
- Version: 1.10.1
3
+ Version: 1.10.3
4
4
  Dynamic: Summary
5
5
  Project-URL: Home, https://www.github.com/widgetti/reacton
6
6
  Project-URL: Documentation, https://reacton.solara.dev
@@ -0,0 +1 @@
1
+ __version__ = "1.10.3"
@@ -170,6 +170,26 @@ def close_widget(widget: widgets.Widget):
170
170
  logger.warning("Widget %r does not have a close method, possibly a close trait was added", widget)
171
171
 
172
172
 
173
+ def _is_shared_ipyvue_template(widget: widgets.Widget) -> bool:
174
+ """Is this widget an ipyvue Template that is shared between VueTemplate instances?
175
+
176
+ ipyvue keeps a per-file registry of Template widgets (ipyvue/Template.py), so several
177
+ VueTemplate widgets can point at the same Template. Such a Template outlives the
178
+ VueTemplate that created it, and we must not close it as an orphan.
179
+ ipyvue 3 only puts a Template in that registry when there is a real comm, so an
180
+ unregistered Template belongs to a single VueTemplate and is a normal orphan.
181
+ """
182
+ cls = widget.__class__
183
+ if cls.__name__ != "Template" or cls.__module__ != "ipyvue.Template":
184
+ return False
185
+ module = sys.modules.get(cls.__module__)
186
+ registry = getattr(module, "template_registry", None)
187
+ if registry is None:
188
+ # unknown ipyvue version: keep the old, conservative behaviour
189
+ return True
190
+ return any(template is widget for template in registry.values())
191
+
192
+
173
193
  def _event_handler_exception_wrapper(f):
174
194
  """Wrap an event handler to catch exceptions and put them in a reacton context.
175
195
 
@@ -1322,29 +1342,33 @@ class _RenderContext:
1322
1342
  self._remove_element(self.element, default_key="/", parent_key=ROOT_KEY)
1323
1343
  logger.info("Removing elements done.")
1324
1344
  assert self.context is self.context_root
1325
- if self.container:
1326
- self.container.close()
1327
- if isinstance(self.container, widgets.DOMWidget) and self.container.layout is not None:
1328
- self.container.layout.close()
1329
- if self._shared_elements:
1330
- raise RuntimeError(f"Element not cleaned up: {self._shared_elements}")
1331
- if self._orphans:
1332
- orphan_widgets = set([_get_widgets_dict()[k] for k in self._orphans])
1333
- raise RuntimeError(f"Orphan widgets not cleaned up for widgets: {orphan_widgets}")
1334
- exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1335
- # break the reference cycles through the tree (see _teardown_component_context);
1336
- # _closing stays True, making stray setters and event handlers no-ops
1337
- for context in all_contexts:
1338
- _teardown_component_context(context)
1339
- self.context = None
1340
- self.context_root = None # type: ignore
1341
- # the root element, container and root widget keep the widget tree alive, and
1342
- # widgets reference their elements, whose kwargs hold user callbacks - which
1343
- # capture use_state setters and therefore this render context
1344
- self.element = None # type: ignore
1345
- self.container = None
1346
- self.last_root_widget = None
1347
- self._old_element_ids.clear()
1345
+ # everything below used to run outside the lock: a render() that was
1346
+ # blocked on the lock could then interleave with this teardown and
1347
+ # find self.context None mid-render (AssertionError in
1348
+ # Element.__exit__, seen in production behind a prompt kernel close)
1349
+ if self.container:
1350
+ self.container.close()
1351
+ if isinstance(self.container, widgets.DOMWidget) and self.container.layout is not None:
1352
+ self.container.layout.close()
1353
+ if self._shared_elements:
1354
+ raise RuntimeError(f"Element not cleaned up: {self._shared_elements}")
1355
+ if self._orphans:
1356
+ orphan_widgets = set([_get_widgets_dict()[k] for k in self._orphans])
1357
+ raise RuntimeError(f"Orphan widgets not cleaned up for widgets: {orphan_widgets}")
1358
+ exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
1359
+ # break the reference cycles through the tree (see _teardown_component_context);
1360
+ # _closing stays True, making stray setters and event handlers no-ops
1361
+ for context in all_contexts:
1362
+ _teardown_component_context(context)
1363
+ self.context = None
1364
+ self.context_root = None # type: ignore
1365
+ # the root element, container and root widget keep the widget tree alive, and
1366
+ # widgets reference their elements, whose kwargs hold user callbacks - which
1367
+ # capture use_state setters and therefore this render context
1368
+ self.element = None # type: ignore
1369
+ self.container = None
1370
+ self.last_root_widget = None
1371
+ self._old_element_ids.clear()
1348
1372
  if exceptions:
1349
1373
  raise exceptions[0]
1350
1374
 
@@ -1528,6 +1552,35 @@ class _RenderContext:
1528
1552
  else:
1529
1553
  logger.info("No render phase triggered, already rendering")
1530
1554
 
1555
+ def _discard_aborted_pass(self):
1556
+ """Forget everything a render pass staged when it raised before reconciliation.
1557
+
1558
+ The *_next bookkeeping and the chained effects (previous_effect.next) of every
1559
+ context belong to that pass. Mark every context dirty, so the next render()
1560
+ re-executes the components and re-queues their effects from a consistent state.
1561
+ Only for aborts in the render phase: a reconciliation that raised halfway already
1562
+ created widgets, and those need the normal removal path.
1563
+ """
1564
+ # children_next is kept: it also holds contexts pre-created by state_set() (restored
1565
+ # state), and a context created by the aborted pass owns no widgets yet, it is simply
1566
+ # reused or pruned by the next render
1567
+ contexts: List[ComponentContext] = [self.context_root]
1568
+ while contexts:
1569
+ context = contexts.pop()
1570
+ for effect in context.effects:
1571
+ effect.next = None
1572
+ context.root_element_next = None
1573
+ context.elements_next = {}
1574
+ context.exceptions_self = []
1575
+ context.exceptions_children = []
1576
+ context.needs_render = True
1577
+ context.needs_render_descendant = True
1578
+ context.clean_subtree = False
1579
+ contexts.extend(context.children.values())
1580
+ contexts.extend(context.children_next.values())
1581
+ self._shared_elements_next = set()
1582
+ self.context = self.context_root
1583
+
1531
1584
  def render(self, element: Element, container: widgets.Widget = None):
1532
1585
  # render + consolidate
1533
1586
  widget = None
@@ -1547,7 +1600,15 @@ class _RenderContext:
1547
1600
  self._lock_thread = threading.current_thread()
1548
1601
  if was_locked:
1549
1602
  logger.info("Mutex released, continuing render phase")
1603
+ if self._closing or self.context is None:
1604
+ # close() won the race for the lock (a disconnect can close the
1605
+ # kernel while an update was waiting to render): the tree is
1606
+ # torn down, there is nothing to render into anymore
1607
+ logger.info("Render requested on a closing/closed render context, ignoring")
1608
+ return container
1550
1609
  prev_rc = getattr(local, "rc", None)
1610
+ # an exception that escapes while this is True aborted a render pass (see the except below)
1611
+ in_render_phase = True
1551
1612
  try:
1552
1613
  local.rc = self
1553
1614
  self.element = element
@@ -1623,10 +1684,12 @@ class _RenderContext:
1623
1684
 
1624
1685
  logger.info("Render reconsolidate...")
1625
1686
  self.reconsolidating = True
1687
+ in_render_phase = False
1626
1688
  try:
1627
1689
  widget = self._reconsolidate(self.element, default_key="/", parent_key=ROOT_KEY)
1628
1690
  finally:
1629
1691
  self.reconsolidating = False
1692
+ in_render_phase = True
1630
1693
  logger.info("Render reconsolidate done")
1631
1694
  self.context.root_element = self.context.root_element_next
1632
1695
  self.context.root_element_next = None
@@ -1675,7 +1738,16 @@ class _RenderContext:
1675
1738
  self._is_rendering = False
1676
1739
  self.context = context_prev
1677
1740
  logger.info("Done with render phase: %r", render_count)
1678
- except Exception as e:
1741
+ except BaseException as e:
1742
+ # Exceptions raised by components are collected in exceptions_self, so an
1743
+ # exception here comes from the render machinery itself (duplicate key,
1744
+ # hook misuse, ...) or is a cancellation, and aborted a pass halfway. Drop
1745
+ # what that pass staged: otherwise the next render reconciles the last
1746
+ # committed elements, but runs the effects the aborted pass chained, closed
1747
+ # over elements that were never reconciled (get_widget then fails with
1748
+ # "found in a previous render").
1749
+ if in_render_phase:
1750
+ self._discard_aborted_pass()
1679
1751
  if DEBUG:
1680
1752
  # construct a fake traceback (showing how the elements were constructed)
1681
1753
  if not self.tracebacks:
@@ -1815,8 +1887,13 @@ class _RenderContext:
1815
1887
  needs_render = context.needs_render
1816
1888
  if not needs_render:
1817
1889
  if el_prev is not None and context_previous is context:
1818
- assert not isinstance(el_prev.component, ComponentWidget)
1819
- needs_render = el._arguments_changed(el_prev)
1890
+ if isinstance(el_prev.component, ComponentWidget):
1891
+ # an earlier pass of this render() call put a widget element at this
1892
+ # slot (elements_next), while the context is from the last reconciled
1893
+ # render: nothing to compare the arguments against, so render
1894
+ needs_render = True
1895
+ else:
1896
+ needs_render = el._arguments_changed(el_prev)
1820
1897
  if context.exceptions_children:
1821
1898
  # we have exceptions, so we need to render
1822
1899
  needs_render = True
@@ -2156,7 +2233,7 @@ class _RenderContext:
2156
2233
  if orphan_ids:
2157
2234
  for orphan_widget in orphan_widgets:
2158
2235
  # these are shared widgets
2159
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2236
+ if _is_shared_ipyvue_template(orphan_widget):
2160
2237
  orphan_ids -= {orphan_widget.model_id}
2161
2238
  if el.is_shared:
2162
2239
  widget = self._shared_widgets[el]
@@ -2475,8 +2552,11 @@ class _RenderContextFast(_RenderContext):
2475
2552
 
2476
2553
  needs_render = context.needs_render
2477
2554
  if not needs_render and el_prev is not None and context_previous is context:
2478
- assert not isinstance(el_prev.component, ComponentWidget)
2479
- needs_render = el._arguments_changed(el_prev)
2555
+ if isinstance(el_prev.component, ComponentWidget):
2556
+ # see the classic renderer: a widget element from an earlier pass of this call
2557
+ needs_render = True
2558
+ else:
2559
+ needs_render = el._arguments_changed(el_prev)
2480
2560
  if not needs_render and context.exceptions_children:
2481
2561
  # we have exceptions, so we need to render
2482
2562
  needs_render = True
@@ -2740,7 +2820,7 @@ class _RenderContextFast(_RenderContext):
2740
2820
  orphan_widgets = set([_get_widgets_dict()[k] for k in orphan_ids])
2741
2821
  for orphan_widget in orphan_widgets:
2742
2822
  # these are shared between widgets
2743
- if orphan_widget.__class__.__name__ == "Template" and orphan_widget.__class__.__module__ == "ipyvue.Template":
2823
+ if _is_shared_ipyvue_template(orphan_widget):
2744
2824
  orphan_ids -= {orphan_widget.model_id}
2745
2825
  widget = self._shared_widgets[el] if el.is_shared else context.widgets[key]
2746
2826
  if widget.model_id not in self._orphans:
@@ -2963,6 +2963,66 @@ def test_close_when_overridden():
2963
2963
  rc.close()
2964
2964
 
2965
2965
 
2966
+ def test_render_after_close_is_noop():
2967
+ # A disconnect can close the kernel while an update is waiting on the render
2968
+ # lock: the late render used to proceed into the torn-down tree and hit
2969
+ # "assert rc.context is not None" in Element.__exit__ (seen in production).
2970
+ # A render on a closed/closing context must be a no-op instead.
2971
+ @react.component
2972
+ def App():
2973
+ value, _set_value = react.use_state(1)
2974
+ return w.Label(value=str(value))
2975
+
2976
+ box, rc = react.render(App(), handle_error=False)
2977
+ rc.close()
2978
+ rc.render(App(), box) # must not raise, must not render
2979
+
2980
+
2981
+ def test_close_render_race():
2982
+ # close() in one thread while renders queue behind the lock in another:
2983
+ # neither thread may raise (the close teardown must not interleave with a
2984
+ # render that was blocked on the lock).
2985
+ import threading
2986
+
2987
+ for _ in range(20):
2988
+
2989
+ @react.component
2990
+ def App():
2991
+ value, _set_value = react.use_state(1)
2992
+ return w.Label(value=str(value))
2993
+
2994
+ box, rc = react.render(App(), handle_error=False)
2995
+ el = App()
2996
+ errors: List[BaseException] = []
2997
+ barrier = threading.Barrier(2)
2998
+
2999
+ def do_render(rc=rc, el=el, box=box, errors=errors, barrier=barrier):
3000
+ try:
3001
+ barrier.wait()
3002
+ rc.render(el, box)
3003
+ except BaseException as e: # noqa
3004
+ errors.append(e)
3005
+
3006
+ def do_close(rc=rc, errors=errors, barrier=barrier):
3007
+ try:
3008
+ barrier.wait()
3009
+ rc.close()
3010
+ except BaseException as e: # noqa
3011
+ errors.append(e)
3012
+
3013
+ t1 = threading.Thread(target=do_render)
3014
+ t2 = threading.Thread(target=do_close)
3015
+ t1.start()
3016
+ t2.start()
3017
+ t1.join()
3018
+ t2.join()
3019
+ # whichever won the lock, the loser must not blow up; if render won,
3020
+ # close ran after and the context must still close cleanly
3021
+ if not rc._closing:
3022
+ rc.close()
3023
+ assert not errors, errors
3024
+
3025
+
2966
3026
  def test_render_perf_child_only():
2967
3027
  render_child = unittest.mock.Mock()
2968
3028
  render_main = unittest.mock.Mock()
@@ -3228,3 +3288,162 @@ def test_key_mutate_protection(Container):
3228
3288
  set_state(1)
3229
3289
  rc.render(w.HTML(value="recover").key("HTML"))
3230
3290
  rc.close()
3291
+
3292
+
3293
+ def test_render_nested_pass_widget_to_component_flip():
3294
+ # One render() call, two passes: the first pass puts a widget element at a
3295
+ # slot, a state write during that pass opens a nested pass, and the nested
3296
+ # pass puts back the function component that held the slot in the last
3297
+ # committed render. _render then compares the component element against
3298
+ # the widget element left in elements_next by the first pass and trips
3299
+ # `assert not isinstance(el_prev.component, ComponentWidget)`.
3300
+ set_mode = cast(Callable[[str], None], None)
3301
+
3302
+ set_clicks = cast(Callable[[int], None], None)
3303
+
3304
+ @react.component
3305
+ def Child():
3306
+ nonlocal set_clicks
3307
+ clicks, set_clicks = react.use_state(0)
3308
+ return w.Button(description=f"child {clicks}")
3309
+
3310
+ @react.component
3311
+ def Parent():
3312
+ nonlocal set_mode
3313
+ mode, set_mode = react.use_state("component")
3314
+ if mode == "widget":
3315
+ # a set during render opens a nested pass in the same render() call
3316
+ set_mode("component")
3317
+ return w.Label(description="loading")
3318
+ return Child()
3319
+
3320
+ box, rc = react.render(Parent(), handle_error=False)
3321
+ set_clicks(3)
3322
+ assert box.children[0].description == "child 3"
3323
+ set_mode("widget")
3324
+ assert isinstance(box.children[0], widgets.Button)
3325
+ # nothing was committed in between, so Child was never unmounted and keeps its state
3326
+ assert box.children[0].description == "child 3"
3327
+ rc.close()
3328
+
3329
+
3330
+ def test_render_nested_pass_flip_strands_get_widget_effect():
3331
+ # The production chain behind the flip above (Grotto PENG-1257, through
3332
+ # solara's RoutingProvider): the AssertionError escapes render(), and a
3333
+ # parent that re-executed in the aborted nested pass already chained a new
3334
+ # effect closure (previous_effect.next) over its new root element, while
3335
+ # its root_element_next was never set. The next render that does not
3336
+ # re-execute the parent runs that closure, and get_widget on the never
3337
+ # reconciled root raises "was found to be in a previous render".
3338
+ set_mode = cast(Callable[[str], None], None)
3339
+ set_counter = cast(Callable[[int], None], None)
3340
+ set_other = cast(Callable[[int], None], None)
3341
+
3342
+ @react.component
3343
+ def Child():
3344
+ return w.Button(description="child")
3345
+
3346
+ @react.component
3347
+ def Flipper():
3348
+ nonlocal set_mode
3349
+ mode, set_mode = react.use_state("component")
3350
+ if mode == "widget":
3351
+ # a set during render opens a nested pass, and the parent's state
3352
+ # changes too, so the parent re-executes in that nested pass
3353
+ # (in production: a router.push from a subscriber)
3354
+ set_counter(1)
3355
+ set_mode("component")
3356
+ return w.Label(description="loading")
3357
+ return Child()
3358
+
3359
+ @react.component
3360
+ def Other():
3361
+ nonlocal set_other
3362
+ value, set_other = react.use_state(0)
3363
+ return w.Label(description=f"other {value}")
3364
+
3365
+ @react.component
3366
+ def Parent():
3367
+ nonlocal set_counter
3368
+ _counter, set_counter = react.use_state(0)
3369
+
3370
+ def effect():
3371
+ react.get_widget(root)
3372
+
3373
+ react.use_effect(effect)
3374
+ root = w.VBox(children=[Flipper(), Other()])
3375
+ return root
3376
+
3377
+ box, rc = react.render(Parent(), handle_error=False)
3378
+ root = box.children[0]
3379
+ assert root.children[1].description == "other 0"
3380
+ try:
3381
+ set_mode("widget")
3382
+ except AssertionError:
3383
+ # the first stage, covered by test_render_nested_pass_widget_to_component_flip
3384
+ pass
3385
+ # an unrelated state change must not run a stale effect closure
3386
+ set_other(1)
3387
+ assert root.children[1].description == "other 1"
3388
+ rc.close()
3389
+
3390
+
3391
+ @pytest.mark.parametrize("abort_in", ["first_pass", "nested_pass"])
3392
+ def test_render_escaped_exception_strands_chained_effect(abort_in):
3393
+ # An exception raised by reacton's own bookkeeping inside _render (here a
3394
+ # duplicate key) escapes render() and leaves the tree half updated: a
3395
+ # component that re-executed in the aborted pass already chained a new
3396
+ # effect closure (previous_effect.next) over the elements of that pass, but
3397
+ # its root_element_next was never set. The next render that does not
3398
+ # re-execute that component runs the chained effect against elements that
3399
+ # were never reconciled; with get_widget that is the
3400
+ # "was found to be in a previous render" KeyError.
3401
+ # (Seen in production through solara's RoutingProvider, which calls
3402
+ # get_widget(main) from an effect without dependencies.)
3403
+ set_dup = cast(Callable[[bool], None], None)
3404
+ set_other = cast(Callable[[int], None], None)
3405
+ set_counter = cast(Callable[[int], None], None)
3406
+
3407
+ @react.component
3408
+ def Other():
3409
+ nonlocal set_other
3410
+ value, set_other = react.use_state(0)
3411
+ return w.Label(description=f"other {value}")
3412
+
3413
+ @react.component
3414
+ def Dup(dup: bool, counter: int):
3415
+ aborted = react.use_ref(False)
3416
+ if dup and counter == 0 and abort_in == "nested_pass":
3417
+ # make the parent re-execute in a nested pass of this render() call
3418
+ set_counter(1)
3419
+ abort_now = counter == (1 if abort_in == "nested_pass" else 0)
3420
+ if dup and abort_now and not aborted.current:
3421
+ # abort this pass with an exception from reacton's bookkeeping
3422
+ aborted.current = True
3423
+ return w.VBox(children=[w.Label(description="a").key("dup"), w.Label(description="b").key("dup")])
3424
+ return w.Label(description="no dup")
3425
+
3426
+ @react.component
3427
+ def Parent():
3428
+ nonlocal set_dup, set_counter
3429
+ dup, set_dup = react.use_state(False)
3430
+ counter, set_counter = react.use_state(0)
3431
+
3432
+ def effect():
3433
+ react.get_widget(root)
3434
+
3435
+ react.use_effect(effect)
3436
+ root = w.VBox(children=[Other(), Dup(dup, counter)])
3437
+ return root
3438
+
3439
+ box, rc = react.render(Parent(), handle_error=False)
3440
+ root = box.children[0]
3441
+ assert root.children[0].description == "other 0"
3442
+ # pass 1: Dup bumps the counter, nested pass: Parent re-executes and chains
3443
+ # a new effect closure over its new root, then Dup aborts the pass
3444
+ with pytest.raises(KeyError, match="Duplicate key"):
3445
+ set_dup(True)
3446
+ # an unrelated state change must not run a stale effect closure
3447
+ set_other(1)
3448
+ assert root.children[0].description == "other 1"
3449
+ rc.close()
@@ -1 +0,0 @@
1
- __version__ = "1.10.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes