flet 0.70.0.dev5774__py3-none-any.whl → 0.70.0.dev5835__py3-none-any.whl

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.

Potentially problematic release.


This version of flet might be problematic. Click here for more details.

Files changed (96) hide show
  1. flet/__init__.py +32 -4
  2. flet/components/__init__.py +0 -0
  3. flet/components/component.py +346 -0
  4. flet/components/component_decorator.py +24 -0
  5. flet/components/component_owned.py +22 -0
  6. flet/components/hooks/__init__.py +0 -0
  7. flet/components/hooks/hook.py +12 -0
  8. flet/components/hooks/use_callback.py +28 -0
  9. flet/components/hooks/use_context.py +91 -0
  10. flet/components/hooks/use_effect.py +104 -0
  11. flet/components/hooks/use_memo.py +52 -0
  12. flet/components/hooks/use_state.py +58 -0
  13. flet/components/memo.py +34 -0
  14. flet/components/observable.py +269 -0
  15. flet/components/public_utils.py +10 -0
  16. flet/components/utils.py +85 -0
  17. flet/controls/base_control.py +34 -10
  18. flet/controls/base_page.py +44 -40
  19. flet/controls/context.py +22 -1
  20. flet/controls/control.py +12 -6
  21. flet/controls/control_event.py +19 -2
  22. flet/controls/core/animated_switcher.py +3 -2
  23. flet/controls/core/autofill_group.py +6 -2
  24. flet/controls/core/column.py +5 -0
  25. flet/controls/core/dismissible.py +12 -10
  26. flet/controls/core/drag_target.py +20 -10
  27. flet/controls/core/draggable.py +9 -9
  28. flet/controls/core/icon.py +16 -12
  29. flet/controls/core/interactive_viewer.py +24 -23
  30. flet/controls/core/pagelet.py +3 -2
  31. flet/controls/core/reorderable_draggable.py +3 -2
  32. flet/controls/core/row.py +5 -0
  33. flet/controls/core/safe_area.py +3 -2
  34. flet/controls/core/text_span.py +5 -3
  35. flet/controls/core/view.py +6 -6
  36. flet/controls/core/window_drag_area.py +3 -2
  37. flet/controls/cupertino/cupertino_action_sheet.py +10 -5
  38. flet/controls/cupertino/cupertino_action_sheet_action.py +3 -4
  39. flet/controls/cupertino/cupertino_activity_indicator.py +4 -3
  40. flet/controls/cupertino/cupertino_alert_dialog.py +6 -3
  41. flet/controls/cupertino/cupertino_button.py +6 -5
  42. flet/controls/cupertino/cupertino_context_menu.py +8 -4
  43. flet/controls/cupertino/cupertino_context_menu_action.py +3 -4
  44. flet/controls/cupertino/cupertino_date_picker.py +44 -28
  45. flet/controls/cupertino/cupertino_dialog_action.py +3 -4
  46. flet/controls/cupertino/cupertino_list_tile.py +3 -4
  47. flet/controls/cupertino/cupertino_navigation_bar.py +6 -5
  48. flet/controls/cupertino/cupertino_picker.py +14 -10
  49. flet/controls/cupertino/cupertino_segmented_button.py +6 -5
  50. flet/controls/cupertino/cupertino_slider.py +16 -12
  51. flet/controls/cupertino/cupertino_sliding_segmented_button.py +6 -5
  52. flet/controls/cupertino/cupertino_timer_picker.py +38 -31
  53. flet/controls/id_counter.py +24 -0
  54. flet/controls/material/alert_dialog.py +6 -5
  55. flet/controls/material/app_bar.py +17 -14
  56. flet/controls/material/banner.py +13 -11
  57. flet/controls/material/bottom_app_bar.py +5 -4
  58. flet/controls/material/bottom_sheet.py +5 -4
  59. flet/controls/material/button.py +12 -4
  60. flet/controls/material/chip.py +13 -12
  61. flet/controls/material/circle_avatar.py +17 -13
  62. flet/controls/material/datatable.py +48 -41
  63. flet/controls/material/divider.py +30 -14
  64. flet/controls/material/dropdown.py +5 -3
  65. flet/controls/material/expansion_tile.py +11 -22
  66. flet/controls/material/floating_action_button.py +32 -23
  67. flet/controls/material/icon_button.py +7 -3
  68. flet/controls/material/navigation_rail.py +14 -11
  69. flet/controls/material/outlined_button.py +7 -3
  70. flet/controls/material/progress_bar.py +18 -10
  71. flet/controls/material/radio_group.py +5 -1
  72. flet/controls/material/range_slider.py +13 -13
  73. flet/controls/material/segmented_button.py +21 -17
  74. flet/controls/material/selection_area.py +3 -2
  75. flet/controls/material/slider.py +16 -12
  76. flet/controls/material/snack_bar.py +18 -10
  77. flet/controls/material/switch.py +6 -5
  78. flet/controls/material/tabs.py +18 -14
  79. flet/controls/material/textfield.py +32 -15
  80. flet/controls/material/vertical_divider.py +20 -12
  81. flet/controls/object_patch.py +434 -197
  82. flet/controls/page.py +205 -85
  83. flet/controls/services/haptic_feedback.py +0 -3
  84. flet/controls/services/shake_detector.py +0 -3
  85. flet/messaging/flet_socket_server.py +13 -6
  86. flet/messaging/session.py +103 -10
  87. flet/{controls/session_storage.py → messaging/session_store.py} +2 -2
  88. flet/version.py +1 -1
  89. {flet-0.70.0.dev5774.dist-info → flet-0.70.0.dev5835.dist-info}/METADATA +5 -5
  90. {flet-0.70.0.dev5774.dist-info → flet-0.70.0.dev5835.dist-info}/RECORD +93 -80
  91. flet/controls/cache.py +0 -87
  92. flet/controls/control_id.py +0 -22
  93. flet/controls/core/state_view.py +0 -60
  94. {flet-0.70.0.dev5774.dist-info → flet-0.70.0.dev5835.dist-info}/WHEEL +0 -0
  95. {flet-0.70.0.dev5774.dist-info → flet-0.70.0.dev5835.dist-info}/entry_points.txt +0 -0
  96. {flet-0.70.0.dev5774.dist-info → flet-0.70.0.dev5835.dist-info}/top_level.txt +0 -0
@@ -30,11 +30,16 @@
30
30
  #
31
31
 
32
32
  import dataclasses
33
+ import logging
33
34
  import weakref
34
35
  from enum import Enum
36
+ from typing import Any, Optional
35
37
 
36
38
  from flet.controls.keys import Key
37
39
 
40
+ logger = logging.getLogger("flet_object_patch")
41
+ logger.setLevel(logging.INFO)
42
+
38
43
  _ST_ADD = 0
39
44
  _ST_REMOVE = 1
40
45
 
@@ -228,13 +233,16 @@ class ObjectPatch:
228
233
  src,
229
234
  dst,
230
235
  control_cls,
236
+ parent: Any = None,
237
+ path: Optional[list[Any]] = None,
238
+ frozen: bool = False,
231
239
  ):
232
240
  builder = DiffBuilder(
233
241
  src,
234
242
  dst,
235
243
  control_cls=control_cls,
236
244
  )
237
- builder._compare_values(None, [], None, src, dst, False)
245
+ builder._compare_values(parent, path or [], None, src, dst, frozen=frozen)
238
246
 
239
247
  ops = list(builder.execute())
240
248
  added = list(builder.get_added_controls())
@@ -429,119 +437,155 @@ class DiffBuilder:
429
437
  yield curr[2].operation
430
438
  curr = curr[1]
431
439
 
440
+ def _index_key(self, item, item_key, path):
441
+ """
442
+ Return the composite key used to pair add/remove
443
+ (by control key if present).
444
+ """
445
+ return item_key if item_key is not None else item
446
+
447
+ def _maybe_compare_dataclasses(self, parent, path, src, dst, frozen):
448
+ """
449
+ Compare dataclasses only when both are dataclasses and
450
+ identity/"frozen" rules allow it.
451
+ """
452
+ if (
453
+ dataclasses.is_dataclass(src)
454
+ and dataclasses.is_dataclass(dst)
455
+ and ((not frozen and src is dst) or (frozen and src is not dst))
456
+ ):
457
+ self._compare_dataclasses(parent, path, src, dst, frozen)
458
+
459
+ def _affected_is_list(self, op):
460
+ """
461
+ Return True if the item the op affects lives in a list in the destination doc.
462
+ We must not rely on op.key’s type because PatchOperation coerces to int.
463
+ """
464
+ container = op.to_last(self.dst_doc)
465
+ return isinstance(container, list)
466
+
467
+ def _emit_move(self, from_loc, to_loc):
468
+ """Create and insert a move operation."""
469
+ self.insert(MoveOperation({"op": "move", "from": from_loc, "path": to_loc}))
470
+
432
471
  def _item_added(self, parent, path, key, item, item_key=None, frozen=False):
433
- # print("\n\n_item_added:", path, key, item, item_key)
434
- index_key = item_key if item_key is not None else item
435
- index = self.take_index(index_key, _ST_REMOVE)
436
- if index is not None:
437
- op = index[2]
438
- # print("\n\n_ST_REMOVE:", op.__dict__, item)
439
-
440
- # compare moved item
441
- src = op.operation["value"]
472
+ logger.debug(
473
+ f"_item_added: path={path} key={key} item={item} item_key={item_key}"
474
+ )
475
+
476
+ index_key = self._index_key(item, item_key, path)
477
+ paired_idx = self.take_index(index_key, _ST_REMOVE)
478
+
479
+ # A matching 'remove' exists: it's a move (or an in-place update)
480
+ if paired_idx is not None:
481
+ rem_op: RemoveOperation = paired_idx[2] # the earlier remove
482
+ src = rem_op.operation["value"]
442
483
  dst = item
443
484
 
485
+ # undo bookkeeping for the removed dataclass (it’s coming back)
444
486
  self._undo_dataclass_removed(src)
445
487
 
446
- if (
447
- dataclasses.is_dataclass(src)
448
- and dataclasses.is_dataclass(dst)
449
- and ((not frozen and src is dst) or (frozen and src is not dst))
450
- ):
451
- self._compare_dataclasses(
452
- src.parent,
453
- _path_join(path, key),
454
- src,
455
- dst,
456
- frozen,
457
- )
488
+ # If the affected sequence is a list, adjust later op indices after removal
489
+ if isinstance(rem_op.key, int) and isinstance(key, int):
490
+ for v in self.iter_from(paired_idx):
491
+ rem_op.key = v._on_undo_remove(rem_op.path, rem_op.key)
492
+
493
+ # Drop the paired remove from the chain: we’re going to emit either
494
+ # compares or a move
495
+ self.remove(paired_idx)
496
+
497
+ src_loc = rem_op.location
498
+ dst_loc = _path_join(path, key)
499
+
500
+ # Compare first (for dataclasses), anchored at source or dest depending
501
+ # on whether we move
502
+ if src_loc != dst_loc:
503
+ # Compare on the source location before we move (matches your tests’
504
+ # expectations)
505
+ self._maybe_compare_dataclasses(parent, src_loc, src, dst, frozen)
506
+ # Then emit the move
507
+ self._emit_move(src_loc, dst_loc)
508
+ return
509
+ else:
510
+ # No move, just in-place updates
511
+ self._maybe_compare_dataclasses(parent, dst_loc, src, dst, frozen)
512
+ return
458
513
 
459
- if isinstance(op.key, int) and isinstance(key, int):
460
- for v in self.iter_from(index):
461
- op.key = v._on_undo_remove(op.path, op.key)
462
-
463
- self.remove(index)
464
- if op.location != _path_join(path, key):
465
- new_op = MoveOperation(
466
- {
467
- "op": "move",
468
- "from": op.location,
469
- "path": _path_join(path, key),
470
- }
471
- )
472
- self.insert(new_op)
473
- else:
474
- new_op = AddOperation(
475
- {
476
- "op": "add",
477
- "path": _path_join(path, key),
478
- "value": item,
479
- }
480
- )
481
- new_index = self.insert(new_op)
482
- self.store_index(index_key, new_index, _ST_ADD)
483
- self._dataclass_added(item, parent, frozen)
514
+ # No matching remove: this is a plain add
515
+ add_op = AddOperation(
516
+ {"op": "add", "path": _path_join(path, key), "value": item}
517
+ )
518
+ add_idx = self.insert(add_op)
519
+ self.store_index(index_key, add_idx, _ST_ADD)
520
+ self._dataclass_added(item, parent, frozen)
484
521
 
485
522
  def _item_removed(self, path, key, item, item_key=None, frozen=False):
486
- # print("\n\n_item_removed:", path, key, item, item_key)
487
- new_op = RemoveOperation(
523
+ logger.debug(
524
+ f"_item_removed: path={path} key={key} item={item} item_key={item_key}"
525
+ )
526
+
527
+ index_key = self._index_key(item, item_key, path)
528
+ rem_op = RemoveOperation(
488
529
  {"op": "remove", "path": _path_join(path, key), "value": item}
489
530
  )
490
- index_key = item_key if item_key is not None else item
491
- index = self.take_index(index_key, _ST_ADD)
492
- new_index = self.insert(new_op)
493
- if index is not None:
494
- op = index[2]
495
- # print("\n\n_ST_ADD:", op.__dict__)
496
-
497
- # compare moved item
531
+ rem_idx = self.insert(rem_op)
532
+
533
+ paired_idx = self.take_index(index_key, _ST_ADD)
534
+
535
+ # A matching 'add' exists: it's a move (or an in-place update)
536
+ if paired_idx is not None:
537
+ add_op: AddOperation = paired_idx[2] # the earlier add
498
538
  src = item
499
- dst = op.operation["value"]
539
+ dst = add_op.operation["value"]
500
540
 
541
+ # undo bookkeeping for the added dataclass (it’s being consumed by the move)
501
542
  self._undo_dataclass_added(dst)
502
543
 
503
- if (
504
- dataclasses.is_dataclass(src)
505
- and dataclasses.is_dataclass(dst)
506
- and ((not frozen and src is dst) or (frozen and src is not dst))
507
- ):
508
- self._compare_dataclasses(
509
- dst.parent,
510
- _path_join(op.path, op.key),
544
+ # If the added op affects a list, adjust later ops after that add
545
+ if self._affected_is_list(add_op):
546
+ for v in self.iter_from(paired_idx):
547
+ add_op.key = v._on_undo_add(add_op.path, add_op.key)
548
+
549
+ src_loc = rem_op.location
550
+ dst_loc = add_op.location
551
+
552
+ # The earlier add no longer stands on its own
553
+ self.remove(paired_idx)
554
+
555
+ if src_loc != dst_loc:
556
+ # If we’re moving, compare anchored at the source BEFORE the move
557
+ # (matches your tests)
558
+ self._maybe_compare_dataclasses(
559
+ dst.parent if hasattr(dst, "parent") else None,
560
+ src_loc,
511
561
  src,
512
562
  dst,
513
563
  frozen,
514
564
  )
515
-
516
- # We can't rely on the op.key type since PatchOperation casts
517
- # the .key property to int and this path wrongly ends up being taken
518
- # for numeric string dict keys while the intention is to only handle lists.
519
- # So we do an explicit check on the item affected by the op instead.
520
- added_item = op.to_last(self.dst_doc)
521
- if isinstance(added_item, list):
522
- for v in self.iter_from(index):
523
- op.key = v._on_undo_add(op.path, op.key)
524
-
525
- self.remove(index)
526
- if new_op.location != op.location:
527
- new_op = MoveOperation(
528
- {
529
- "op": "move",
530
- "from": new_op.location,
531
- "path": op.location,
532
- }
565
+ # Turn the just-inserted remove into a move (reuse node)
566
+ rem_idx[2] = MoveOperation(
567
+ {"op": "move", "from": src_loc, "path": dst_loc}
533
568
  )
534
- new_index[2] = new_op
535
-
569
+ return
536
570
  else:
537
- self.remove(new_index)
571
+ # No move after all; drop the remove from the chain (pair consumed)
572
+ self.remove(rem_idx)
573
+ # In-place updates only
574
+ self._maybe_compare_dataclasses(
575
+ dst.parent if hasattr(dst, "parent") else None,
576
+ _path_join(add_op.path, add_op.key),
577
+ src,
578
+ dst,
579
+ frozen,
580
+ )
581
+ return
538
582
 
539
- else:
540
- self.store_index(index_key, new_index, _ST_REMOVE)
541
- self._dataclass_removed(item)
583
+ # No matching add: keep the remove and remember this dataclass
584
+ self.store_index(index_key, rem_idx, _ST_REMOVE)
585
+ self._dataclass_removed(item)
542
586
 
543
587
  def _item_replaced(self, path, key, item):
544
- # print("_item_replaced:", path, key, item, frozen)
588
+ logger.debug("_item_replaced: %s %s %s:", path, key, item)
545
589
  self.insert(
546
590
  ReplaceOperation(
547
591
  {
@@ -553,7 +597,7 @@ class DiffBuilder:
553
597
  )
554
598
 
555
599
  def _compare_dicts(self, parent, path, src, dst, frozen):
556
- # print("\n_compare_dicts:", path, src, dst)
600
+ logger.debug("\n_compare_dicts: %s %s %s", path, src, dst)
557
601
 
558
602
  src_keys = set(src.keys())
559
603
  dst_keys = set(dst.keys())
@@ -570,106 +614,246 @@ class DiffBuilder:
570
614
  self._compare_values(parent, path, key, src[key], dst[key], frozen)
571
615
 
572
616
  def _compare_lists(self, parent, path, src, dst, frozen):
573
- # print("\n_compare_lists:", path, src, dst)
617
+ logger.debug(f"\n_compare_lists: {path} {src} {dst}")
574
618
 
575
- len_src, len_dst = len(src), len(dst)
576
- max_len = max(len_src, len_dst)
577
- min_len = min(len_src, len_dst)
578
- for key in range(max_len):
579
- if key < min_len:
580
- old, new = src[key], dst[key]
581
- # print("\n\nCOMPARE LIST ITEM:", key, "\n\nOLD:", old, "\n\nNEW:", new)
619
+ # ----- helper: get keys quickly -----
620
+ def k(obj):
621
+ return get_control_key(obj)
582
622
 
583
- if isinstance(old, dict) and isinstance(new, dict):
584
- self._compare_dicts(parent, _path_join(path, key), old, new, frozen)
585
-
586
- elif isinstance(old, list) and isinstance(new, list):
587
- self._compare_lists(parent, _path_join(path, key), old, new, frozen)
623
+ src_keys = [k(item) for item in src]
624
+ dst_keys = [k(item) for item in dst]
588
625
 
589
- elif dataclasses.is_dataclass(old) and dataclasses.is_dataclass(new):
590
- frozen = (
591
- (old is not None and hasattr(old, "_frozen"))
592
- or (new is not None and hasattr(new, "_frozen"))
593
- or frozen
626
+ # Use keyed algorithm only when every element provides a key on both sides
627
+ all_keyed = (
628
+ src_keys
629
+ and dst_keys
630
+ and all(key is not None for key in src_keys)
631
+ and all(key is not None for key in dst_keys)
632
+ )
633
+ # print("list info", path, len(src_keys), len(dst_keys), all_keyed)
634
+ if not all_keyed:
635
+ # fall back to your existing element-wise logic
636
+ len_src, len_dst = len(src), len(dst)
637
+ max_len = max(len_src, len_dst)
638
+ min_len = min(len_src, len_dst)
639
+ for key in range(max_len):
640
+ if key < min_len:
641
+ old, new = src[key], dst[key]
642
+ logger.debug(
643
+ f"\n\nCOMPARE LIST ITEM: {key}\n\nOLD: {old}\n\nNEW: {new}"
594
644
  )
595
645
 
596
- old_control_key = get_control_key(old)
597
- new_control_key = get_control_key(new)
598
-
599
- if (not frozen and old is new) or (
600
- frozen
601
- and old is not new # not a cached control tree
602
- and type(old) is type(new) # iteams are of the same type
603
- and (
604
- old_control_key is None
605
- or new_control_key is None
606
- or old_control_key == new_control_key
607
- ) # same list key or both None
608
- ):
609
- # print("\n\ncompare list dataclasses:", new)
610
- self._compare_dataclasses(
646
+ if isinstance(old, dict) and isinstance(new, dict):
647
+ self._compare_dicts(
611
648
  parent, _path_join(path, key), old, new, frozen
612
649
  )
613
- elif (not frozen and old is not new) or (frozen and old is not new):
614
- # print(
615
- # "\n\ndataclass removed and added:",
616
- # "\n\nOLD:",
617
- # old,
618
- # "\n\nNEW:",
619
- # new,
620
- # )
621
- self._item_removed(
622
- path,
623
- key,
624
- old,
625
- item_key=(old_control_key, path)
626
- if old_control_key is not None
627
- else old,
628
- frozen=frozen,
650
+
651
+ elif isinstance(old, list) and isinstance(new, list):
652
+ self._compare_lists(
653
+ parent, _path_join(path, key), old, new, frozen
629
654
  )
630
- self._item_added(
631
- parent,
632
- path,
633
- key,
634
- new,
635
- item_key=(new_control_key, path)
636
- if new_control_key is not None
637
- else new,
638
- frozen=frozen,
655
+
656
+ elif dataclasses.is_dataclass(old) and dataclasses.is_dataclass(
657
+ new
658
+ ):
659
+ frozen_local = (
660
+ (old is not None and hasattr(old, "_frozen"))
661
+ or (new is not None and hasattr(new, "_frozen"))
662
+ or frozen
639
663
  )
664
+ old_control_key = k(old)
665
+ new_control_key = k(new)
666
+ if (not frozen_local and old is new) or (
667
+ frozen_local
668
+ and old is not new
669
+ and type(old) is type(new)
670
+ and (
671
+ old_control_key is None
672
+ or new_control_key is None
673
+ or old_control_key == new_control_key
674
+ )
675
+ ):
676
+ self._compare_dataclasses(
677
+ parent, _path_join(path, key), old, new, frozen_local
678
+ )
679
+ else:
680
+ self._item_removed(path, key, old, frozen=frozen)
681
+ self._item_added(parent, path, key, new, frozen=frozen)
682
+
683
+ elif type(old) is not type(new) or old != new:
684
+ self._item_removed(path, key, old, frozen=frozen)
685
+ self._item_added(parent, path, key, new, frozen=frozen)
686
+
687
+ elif len_src > len_dst:
688
+ control_key = k(src[key])
689
+ self._item_removed(
690
+ path,
691
+ len_dst,
692
+ src[key],
693
+ item_key=(control_key, path)
694
+ if control_key is not None
695
+ else src[key],
696
+ frozen=frozen,
697
+ )
698
+ else:
699
+ control_key = k(dst[key])
700
+ self._item_added(
701
+ parent,
702
+ path,
703
+ key,
704
+ dst[key],
705
+ item_key=(control_key, path)
706
+ if control_key is not None
707
+ else dst[key],
708
+ frozen=frozen,
709
+ )
710
+ return
640
711
 
712
+ if src_keys == dst_keys:
713
+ # print("keyed fast path", path, len(src))
714
+ # Keys are identical and in the same order: treat as positional diff
715
+ for idx, (old, new) in enumerate(zip(src, dst)):
716
+ if isinstance(old, dict) and isinstance(new, dict):
717
+ self._compare_dicts(parent, _path_join(path, idx), old, new, frozen)
718
+ elif isinstance(old, list) and isinstance(new, list):
719
+ self._compare_lists(parent, _path_join(path, idx), old, new, frozen)
720
+ elif dataclasses.is_dataclass(old) and dataclasses.is_dataclass(new):
721
+ self._compare_dataclasses(
722
+ parent, _path_join(path, idx), old, new, frozen
723
+ )
641
724
  elif type(old) is not type(new) or old != new:
642
- # print("removed and added:", old, new)
643
- self._item_removed(path, key, old, frozen=frozen)
644
- self._item_added(parent, path, key, new, frozen=frozen)
645
-
646
- elif len_src > len_dst:
647
- control_key = get_control_key(src[key])
648
- self._item_removed(
649
- path,
650
- len_dst,
651
- src[key],
652
- item_key=(control_key, path)
653
- if control_key is not None
654
- else src[key],
655
- frozen=frozen,
725
+ self._item_replaced(path, idx, new)
726
+ return
727
+
728
+ # -------- Keyed, React-style reconciliation --------
729
+ # We’ll mutate a working copy of the old list so emitted indices are “live”.
730
+ work = list(src)
731
+ work_keys = src_keys[:]
732
+ # Map key -> current index in `work`
733
+ pos = {key: i for i, key in enumerate(work_keys)}
734
+ dst_keys = dst_keys # renamed for clarity
735
+ new_index_by_key = {key: i for i, key in enumerate(dst_keys)}
736
+ new_keys_set = set(new_index_by_key.keys())
737
+
738
+ def _reindex(start_idx: int) -> None:
739
+ for j in range(start_idx, len(work_keys)):
740
+ pos[work_keys[j]] = j
741
+
742
+ def _remove_from_work(idx: int):
743
+ removed_item = work.pop(idx)
744
+ removed_key = work_keys.pop(idx)
745
+ pos.pop(removed_key, None)
746
+ if idx < len(work_keys):
747
+ _reindex(idx)
748
+ return removed_item, removed_key
749
+
750
+ def _insert_into_work(idx: int, item, key):
751
+ work.insert(idx, item)
752
+ work_keys.insert(idx, key)
753
+ pos[key] = idx
754
+ if idx + 1 <= len(work_keys):
755
+ _reindex(idx + 1)
756
+
757
+ def emit_replace_at(idx, old_item, new_item):
758
+ # For dataclasses we delegate to your existing field diff.
759
+ if (
760
+ dataclasses.is_dataclass(old_item)
761
+ and dataclasses.is_dataclass(new_item)
762
+ and old_item is not new_item
763
+ ):
764
+ # Force field-wise compare even if different instances:
765
+ self._compare_dataclasses(
766
+ parent, _path_join(path, idx), old_item, new_item, True
656
767
  )
768
+ elif type(old_item) is not type(new_item) or old_item != new_item:
769
+ self._item_replaced(path, idx, new_item)
770
+
771
+ # Scan forward through desired new order.
772
+ i = 0
773
+ while i < len(dst):
774
+ target_key = dst_keys[i]
775
+
776
+ # First, delete any items currently at position i that should NOT be here:
777
+ # - keys that don't exist anymore
778
+ # - or keys that exist but must appear BEFORE i in the new order
779
+ # (they are out of place here)
780
+ while i < len(work):
781
+ cur_key = work_keys[i]
782
+ if cur_key not in new_keys_set:
783
+ # remove disappearing item at i
784
+ self._item_removed(
785
+ path, i, work[i], item_key=(cur_key, path), frozen=True
786
+ )
787
+ _remove_from_work(i)
788
+ continue
789
+ desired_pos = new_index_by_key[cur_key]
790
+ if desired_pos < i:
791
+ # this item belongs earlier; it should have already been moved
792
+ # before.
793
+ # remove it here; it will be re-inserted (moved) where needed.
794
+ self._item_removed(
795
+ path, i, work[i], item_key=(cur_key, path), frozen=True
796
+ )
797
+ _remove_from_work(i)
798
+ continue
799
+ break # current slot is ok to fill with target
800
+
801
+ if target_key in pos:
802
+ cur_idx = pos[target_key]
803
+ old_item = work[cur_idx]
804
+ new_item = dst[i]
805
+
806
+ if cur_idx == i:
807
+ # in place: just emit updates
808
+ emit_replace_at(i, old_item, new_item)
809
+ else:
810
+ # move from cur_idx -> i
811
+ # Emit updates anchored at SOURCE (cur_idx) before the move
812
+ # (matches your tests)
813
+ emit_replace_at(cur_idx, old_item, new_item)
814
+
815
+ # Emit move
816
+ move_op = MoveOperation(
817
+ {
818
+ "op": "move",
819
+ "from": _path_join(path, cur_idx),
820
+ "path": _path_join(path, i),
821
+ }
822
+ )
823
+ self.insert(move_op)
657
824
 
825
+ # Apply the move in our working model
826
+ moved_item, _ = _remove_from_work(cur_idx)
827
+ insert_idx = i if cur_idx >= i else i
828
+ _insert_into_work(insert_idx, moved_item, target_key)
658
829
  else:
659
- control_key = get_control_key(dst[key])
830
+ # brand-new key: add at i
660
831
  self._item_added(
661
832
  parent,
662
833
  path,
663
- key,
664
- dst[key],
665
- item_key=(control_key, path)
666
- if control_key is not None
667
- else dst[key],
668
- frozen=frozen,
834
+ i,
835
+ dst[i],
836
+ item_key=(target_key, path),
837
+ frozen=True,
669
838
  )
839
+ _insert_into_work(i, dst[i], target_key)
840
+
841
+ i += 1
842
+
843
+ # Finally, remove any trailing leftovers (present in old but not in new)
844
+ # We remove from the end so indices stay valid.
845
+ j = len(work) - 1
846
+ while j >= 0:
847
+ key_j = work_keys[j]
848
+ if key_j not in new_keys_set:
849
+ self._item_removed(
850
+ path, j, work[j], item_key=(key_j, path), frozen=True
851
+ )
852
+ _remove_from_work(j)
853
+ j -= 1
670
854
 
671
855
  def _compare_dataclasses(self, parent, path, src, dst, frozen):
672
- # print("\n_compare_dataclasses:", path, src, dst, frozen)
856
+ logger.debug(f"\n_compare_dataclasses: {path} \n\n{src}\n{dst}\n")
673
857
 
674
858
  if (
675
859
  self.control_cls
@@ -681,18 +865,16 @@ class DiffBuilder:
681
865
 
682
866
  if self.control_cls and isinstance(dst, self.control_cls):
683
867
  if frozen and hasattr(src, "_i"):
684
- dst._i = src._i
868
+ dst._migrate_state(src)
685
869
  if not hasattr(dst, "_initialized"):
686
870
  orig_frozen = getattr(dst, "_frozen", None)
687
871
  if orig_frozen is not None:
688
872
  del dst._frozen
689
873
  dst.build()
690
- dst.before_update()
691
874
  if orig_frozen is not None:
692
875
  object.__setattr__(dst, "_frozen", orig_frozen)
693
876
  object.__setattr__(dst, "_initialized", True)
694
- elif not frozen:
695
- dst.before_update()
877
+ dst._before_update_safe()
696
878
 
697
879
  if not frozen:
698
880
  # in-place comparison
@@ -708,10 +890,17 @@ class DiffBuilder:
708
890
  old = change[0]
709
891
  new = change[1]
710
892
 
711
- # print("_compare_values:changes", old, new)
893
+ logger.debug("\n\n_compare_values:changes %s %s", old, new)
712
894
 
713
895
  self._compare_values(dst, path, field_name, old, new, frozen)
714
896
 
897
+ if field_name in prev_lists:
898
+ del prev_lists[field_name]
899
+ if field_name in prev_dicts:
900
+ del prev_dicts[field_name]
901
+ if field_name in prev_classes:
902
+ del prev_classes[field_name]
903
+
715
904
  # update prev value
716
905
  if isinstance(new, list):
717
906
  new = new[:]
@@ -729,8 +918,11 @@ class DiffBuilder:
729
918
  del prev_lists[field_name]
730
919
  continue
731
920
  new = getattr(dst, field_name)
921
+ if not isinstance(new, list):
922
+ del prev_lists[field_name]
923
+ else:
924
+ prev_lists[field_name] = new[:]
732
925
  self._compare_values(dst, path, field_name, old, new, frozen)
733
- prev_lists[field_name] = new[:]
734
926
 
735
927
  # compare dicts
736
928
  for field_name, old in list(prev_dicts.items()):
@@ -739,8 +931,11 @@ class DiffBuilder:
739
931
  del prev_dicts[field_name]
740
932
  continue
741
933
  new = getattr(dst, field_name)
934
+ if not isinstance(new, dict):
935
+ del prev_dicts[field_name]
936
+ else:
937
+ prev_dicts[field_name] = new.copy()
742
938
  self._compare_values(dst, path, field_name, old, new, frozen)
743
- prev_dicts[field_name] = new.copy()
744
939
 
745
940
  # compare dataclasses
746
941
  for field_name, old in list(prev_classes.items()):
@@ -749,20 +944,21 @@ class DiffBuilder:
749
944
  del prev_classes[field_name]
750
945
  continue
751
946
  new = getattr(dst, field_name)
947
+ if not dataclasses.is_dataclass(new):
948
+ del prev_classes[field_name]
949
+ else:
950
+ prev_classes[field_name] = new
752
951
  self._compare_values(dst, path, field_name, old, new, frozen)
753
- prev_classes[field_name] = new
754
952
 
755
953
  changes.clear()
756
954
  else:
757
955
  # frozen comparison
758
- # print(
759
- # "\nfrozen dataclass compare:",
760
- # src,
761
- # "\n\ndst:",
762
- # dst,
763
- # "\n\nparent:",
764
- # parent,
765
- # )
956
+ logger.debug(
957
+ "\nfrozen dataclass compare:%s\n\n dst:%s\n\n parent:%s",
958
+ src,
959
+ dst,
960
+ parent,
961
+ )
766
962
  for field in dataclasses.fields(dst):
767
963
  if "skip" not in field.metadata:
768
964
  old = getattr(src, field.name)
@@ -775,7 +971,14 @@ class DiffBuilder:
775
971
  self._dataclass_added(dst, parent, frozen)
776
972
 
777
973
  def _compare_values(self, parent, path, key, src, dst, frozen):
778
- # print("\n_compare_values:", path, key, src, dst, frozen)
974
+ logger.debug(
975
+ "\n_compare_values: %s %s (Frozen: %s)\n\n%s\n%s\n",
976
+ path,
977
+ key,
978
+ frozen,
979
+ src,
980
+ dst,
981
+ )
779
982
 
780
983
  if isinstance(src, dict) and isinstance(dst, dict):
781
984
  self._compare_dicts(parent, _path_join(path, key), src, dst, frozen)
@@ -795,7 +998,9 @@ class DiffBuilder:
795
998
  or frozen
796
999
  )
797
1000
 
798
- # print("\n_compare_values:dataclasses", src, dst, frozen)
1001
+ logger.debug(
1002
+ "\n_compare_values:dataclasses (Frozen: %s) %s %s", frozen, src, dst
1003
+ )
799
1004
 
800
1005
  if (not frozen and src is dst) or (
801
1006
  frozen and src is not dst and type(src) is type(dst)
@@ -811,20 +1016,50 @@ class DiffBuilder:
811
1016
  self._dataclass_added(dst, parent, frozen)
812
1017
 
813
1018
  elif type(src) is not type(dst) or src != dst:
1019
+ logger.debug(
1020
+ "\n_compare_values:replaced %s %s %s\n\n%s %s",
1021
+ path,
1022
+ key,
1023
+ src,
1024
+ dst,
1025
+ frozen,
1026
+ )
814
1027
  self._item_replaced(path, key, dst)
815
1028
  self._dataclass_removed(src)
816
1029
  self._dataclass_added(dst, parent, frozen)
817
1030
 
1031
+ if not frozen:
1032
+ prev_lists = getattr(dst, "__prev_lists", {})
1033
+ prev_dicts = getattr(dst, "__prev_dicts", {})
1034
+ prev_classes = getattr(dst, "__prev_classes", {})
1035
+
1036
+ if isinstance(src, list) and key in prev_lists:
1037
+ del prev_lists[key]
1038
+ if isinstance(src, dict) and key in prev_dicts:
1039
+ del prev_dicts[key]
1040
+ if dataclasses.is_dataclass(src) and key in prev_classes:
1041
+ del prev_classes[key]
1042
+ if isinstance(dst, list) and key is not None:
1043
+ prev_lists[key] = dst[:]
1044
+ if isinstance(dst, dict) and key is not None:
1045
+ prev_dicts[key] = dst.copy()
1046
+ if dataclasses.is_dataclass(dst) and key is not None:
1047
+ prev_classes[key] = dst
1048
+
818
1049
  def _dataclass_added(self, item, parent, frozen):
1050
+ logger.debug("\n\nDataclass added: %s %s %s", item, parent, frozen)
819
1051
  if dataclasses.is_dataclass(item):
820
1052
  if parent:
1053
+ logger.debug("\n\nAdding parent %s to item: %s", parent, item)
821
1054
  if parent is item:
822
1055
  raise Exception(f"Parent is the same as item: {item}")
823
1056
  item._parent = weakref.ref(parent)
1057
+ else:
1058
+ logger.debug("\n\nSkip adding parent to item: %s", item)
824
1059
  if frozen:
825
1060
  item._frozen = frozen
826
1061
 
827
- # print("\n_dataclass_added:", self._get_dataclass_key(item))
1062
+ logger.debug("\n_dataclass_added: %s", self._get_dataclass_key(item))
828
1063
  self._added_dataclasses[self._get_dataclass_key(item)] = item
829
1064
 
830
1065
  elif isinstance(item, dict):
@@ -836,12 +1071,11 @@ class DiffBuilder:
836
1071
  self._dataclass_added(v, parent, frozen)
837
1072
 
838
1073
  def _undo_dataclass_added(self, item):
839
- # print("\n_undo_dataclass_added:", self._get_dataclass_key(item))
840
1074
  self._added_dataclasses.pop(self._get_dataclass_key(item), None)
841
1075
 
842
1076
  def _dataclass_removed(self, item):
1077
+ logger.debug("\n\nDataclass removed: %s", item)
843
1078
  if dataclasses.is_dataclass(item):
844
- # print("\n_dataclass_removed:", self._get_dataclass_key(item))
845
1079
  self._removed_dataclasses[self._get_dataclass_key(item)] = item
846
1080
 
847
1081
  elif isinstance(item, dict):
@@ -854,7 +1088,6 @@ class DiffBuilder:
854
1088
 
855
1089
  def _undo_dataclass_removed(self, item):
856
1090
  if dataclasses.is_dataclass(item):
857
- # print("\n_undo_dataclass_removed:", self._get_dataclass_key(item))
858
1091
  self._removed_dataclasses.pop(self._get_dataclass_key(item), None)
859
1092
 
860
1093
  def _get_dataclass_key(self, item):
@@ -866,7 +1099,9 @@ class DiffBuilder:
866
1099
 
867
1100
  def _configure_dataclass(self, item, parent, frozen, configure_setattr_only=False):
868
1101
  if dataclasses.is_dataclass(item):
869
- # print("\n_configure_dataclass:", item, frozen, configure_setattr_only)
1102
+ logger.debug(
1103
+ "\n_configure_dataclass: %s %s %s", item, frozen, configure_setattr_only
1104
+ )
870
1105
 
871
1106
  if parent:
872
1107
  if parent is item:
@@ -897,12 +1132,14 @@ class DiffBuilder:
897
1132
  value if not name.startswith("on_") else value is not None
898
1133
  )
899
1134
  if old_value != new_value:
900
- # print(
1135
+ # logger.debug(
901
1136
  # f"\n\nset_attr: {obj.__class__.__name__}.{name} = "
902
1137
  # f"{new_value}, old: {old_value}"
903
1138
  # )
904
1139
  changes = getattr(obj, "__changes")
905
1140
  changes[name] = (old_value, new_value)
1141
+ if hasattr(obj, "_notify"):
1142
+ obj._notify(name, new_value)
906
1143
  object.__setattr__(obj, name, value)
907
1144
 
908
1145
  item.__class__.__setattr__ = control_setattr # type: ignore
@@ -910,7 +1147,7 @@ class DiffBuilder:
910
1147
  if self.control_cls and isinstance(item, self.control_cls):
911
1148
  if not configure_setattr_only:
912
1149
  item.build()
913
- item.before_update()
1150
+ item._before_update_safe()
914
1151
  object.__setattr__(item, "_initialized", True)
915
1152
  yield item
916
1153