pycascadeui 3.12.2__tar.gz → 3.12.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 (146) hide show
  1. {pycascadeui-3.12.2/pycascadeui.egg-info → pycascadeui-3.12.3}/PKG-INFO +3 -3
  2. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/__init__.py +1 -1
  3. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/manager.py +8 -0
  4. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_navigation.py +2 -0
  5. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/base.py +67 -17
  6. {pycascadeui-3.12.2 → pycascadeui-3.12.3/pycascadeui.egg-info}/PKG-INFO +3 -3
  7. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/requires.txt +2 -2
  8. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pyproject.toml +3 -3
  9. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_view.py +109 -1
  10. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation.py +6 -2
  11. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistence.py +142 -0
  12. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/LICENSE +0 -0
  13. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/README.md +0 -0
  14. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/__main__.py +0 -0
  15. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/__init__.py +0 -0
  16. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/base.py +0 -0
  17. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/buttons.py +0 -0
  18. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/inputs.py +0 -0
  19. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/__init__.py +0 -0
  20. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/v1.py +0 -0
  21. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/patterns/v2.py +0 -0
  22. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/selects.py +0 -0
  23. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/types.py +0 -0
  24. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/v1_composition.py +0 -0
  25. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/components/wrappers.py +0 -0
  26. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/devtools.py +0 -0
  27. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/exceptions.py +0 -0
  28. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/__init__.py +0 -0
  29. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/__init__.py +0 -0
  30. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/memory.py +0 -0
  31. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/postgres.py +0 -0
  32. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/backends/sqlite.py +0 -0
  33. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/config.py +0 -0
  34. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/migrations.py +0 -0
  35. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/protocols.py +0 -0
  36. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/schema.py +0 -0
  37. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/persistence/schema_postgres.py +0 -0
  38. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/py.typed +0 -0
  39. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/setup.py +0 -0
  40. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/__init__.py +0 -0
  41. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/_batching.py +0 -0
  42. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/actions.py +0 -0
  43. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/computed.py +0 -0
  44. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/__init__.py +0 -0
  45. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/logging.py +0 -0
  46. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/persistence.py +0 -0
  47. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/middleware/undo.py +0 -0
  48. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/reducers.py +0 -0
  49. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/singleton.py +0 -0
  50. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/slots.py +0 -0
  51. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/store.py +0 -0
  52. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/state/types.py +0 -0
  53. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/testing.py +0 -0
  54. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/__init__.py +0 -0
  55. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/context.py +0 -0
  56. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/core.py +0 -0
  57. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/theming/themes.py +0 -0
  58. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/tracing.py +0 -0
  59. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/__init__.py +0 -0
  60. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/coercion.py +0 -0
  61. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/decorators.py +0 -0
  62. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/errors.py +0 -0
  63. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/fetch.py +0 -0
  64. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/guards.py +0 -0
  65. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/hooks.py +0 -0
  66. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/logging.py +0 -0
  67. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/responses.py +0 -0
  68. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/strings.py +0 -0
  69. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/utils/tasks.py +0 -0
  70. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/validation.py +0 -0
  71. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/__init__.py +0 -0
  72. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_interaction.py +0 -0
  73. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/_placement.py +0 -0
  74. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/layout.py +0 -0
  75. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/__init__.py +0 -0
  76. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/form.py +0 -0
  77. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/leaderboard.py +0 -0
  78. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/menu.py +0 -0
  79. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/paginated.py +0 -0
  80. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/roles.py +0 -0
  81. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/tabs.py +0 -0
  82. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/types.py +0 -0
  83. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/patterns/wizard.py +0 -0
  84. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/persistent.py +0 -0
  85. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/cascadeui/views/view.py +0 -0
  86. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/SOURCES.txt +0 -0
  87. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/dependency_links.txt +0 -0
  88. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/pycascadeui.egg-info/top_level.txt +0 -0
  89. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/setup.cfg +0 -0
  90. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_auto_defer.py +0 -0
  91. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends.py +0 -0
  92. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends_postgres.py +0 -0
  93. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_backends_raw_sql.py +0 -0
  94. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_batching.py +0 -0
  95. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_button_grid.py +0 -0
  96. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_components.py +0 -0
  97. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_computed.py +0 -0
  98. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_devtools.py +0 -0
  99. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_dynamic_persistent_button.py +0 -0
  100. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_emoji_grid.py +0 -0
  101. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_ephemeral_refresh.py +0 -0
  102. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_examples.py +0 -0
  103. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_fetch.py +0 -0
  104. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_five_pillars.py +0 -0
  105. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_form_patterns.py +0 -0
  106. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_hooks.py +0 -0
  107. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_input_coercion.py +0 -0
  108. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_inputs.py +0 -0
  109. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_instance_limit.py +0 -0
  110. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_invariants.py +0 -0
  111. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_pagination.py +0 -0
  112. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_patterns.py +0 -0
  113. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_layout_persistent.py +0 -0
  114. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_leaderboard_patterns.py +0 -0
  115. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_logging_middleware.py +0 -0
  116. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_menu_patterns.py +0 -0
  117. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_message_cleanup.py +0 -0
  118. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_middleware.py +0 -0
  119. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_nav_version.py +0 -0
  120. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation_artifact_restore.py +0 -0
  121. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_navigation_matrix.py +0 -0
  122. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_owner_only.py +0 -0
  123. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_paginated_patterns.py +0 -0
  124. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_pagination.py +0 -0
  125. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistence_middleware.py +0 -0
  126. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_persistent_views.py +0 -0
  127. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_pillar_isolation.py +0 -0
  128. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_placement.py +0 -0
  129. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_public_api.py +0 -0
  130. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_rebuild_callback.py +0 -0
  131. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_reducers.py +0 -0
  132. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_roles_patterns.py +0 -0
  133. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_scoping.py +0 -0
  134. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_selects.py +0 -0
  135. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_state_slots.py +0 -0
  136. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_state_store.py +0 -0
  137. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_tab_patterns.py +0 -0
  138. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_task_manager.py +0 -0
  139. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_theming.py +0 -0
  140. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_undo.py +0 -0
  141. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_update_coalescing.py +0 -0
  142. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_v2_helpers.py +0 -0
  143. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_validation.py +0 -0
  144. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_view_init.py +0 -0
  145. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_wizard_patterns.py +0 -0
  146. {pycascadeui-3.12.2 → pycascadeui-3.12.3}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.12.2
3
+ Version: 3.12.3
4
4
  Summary: Redux-inspired UI framework for discord.py
5
5
  Author-email: HollowTheSilver <hollow@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -28,8 +28,8 @@ Requires-Dist: discord.py>=2.7
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
31
- Requires-Dist: black>=26.3; extra == "dev"
32
- Requires-Dist: isort>=8.0; extra == "dev"
31
+ Requires-Dist: black<27.0,>=26.3; extra == "dev"
32
+ Requires-Dist: isort<9.0,>=8.0; extra == "dev"
33
33
  Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
34
34
  Provides-Extra: docs
35
35
  Requires-Dist: mkdocs>=1.5; extra == "docs"
@@ -184,7 +184,7 @@ if "PostgresBackend" in _persistence_all:
184
184
  # // ========================================( Script )======================================== // #
185
185
 
186
186
 
187
- __version__ = "3.12.2"
187
+ __version__ = "3.12.3"
188
188
 
189
189
  # Export public API
190
190
  __all__ = [
@@ -1396,6 +1396,14 @@ class PersistenceManager:
1396
1396
  # runs.
1397
1397
  view._message = message
1398
1398
 
1399
+ # Fingerprint the tree the fresh __init__ just produced, before
1400
+ # on_restore has had any chance to run. A teardown before that
1401
+ # render reads this against the CURRENT tree: unchanged means
1402
+ # nothing has touched it and there is nothing on screen to
1403
+ # correct, changed means a teardown override composed real
1404
+ # content (a farewell card) that has to ship regardless.
1405
+ view._reattach_baseline_digest = view._compute_tree_digest()
1406
+
1399
1407
  # ``is not None`` (not truthy) so a stored ``user_id=0`` still
1400
1408
  # restores -- Discord doesn't mint zero snowflakes, but tests
1401
1409
  # and edge-case fixtures do, and the cost of the explicit form
@@ -623,6 +623,7 @@ class _NavigationMixin:
623
623
  await self._ack_bounded(
624
624
  current_interaction.response.edit_message(view=new_view, **direct_kwargs)
625
625
  )
626
+ new_view._has_rendered = True
626
627
  new_view._redrive_dynamic_items()
627
628
  return True
628
629
  except asyncio.TimeoutError:
@@ -661,6 +662,7 @@ class _NavigationMixin:
661
662
  msg = await self._bounded(
662
663
  current_interaction.edit_original_response(view=new_view, **direct_kwargs)
663
664
  )
665
+ new_view._has_rendered = True
664
666
  # Preserve the parent's plain Message ref. The edit response
665
667
  # is an InteractionMessage / WebhookMessage bound to the
666
668
  # 15-minute interaction token; subsequent edits need the
@@ -1143,6 +1143,23 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1143
1143
  # ``None`` means no baseline has been recorded yet, so the
1144
1144
  # next refresh always runs through to the REST call.
1145
1145
  self._last_tree_digest: Optional[int] = None
1146
+ # Distinct from the digest above: this is never reset back to False
1147
+ # once a tree has landed on Discord, even when a stall or a send
1148
+ # failure clears the digest to force the next refresh through
1149
+ # unconditionally. The teardown freeze-edit gate reads this rather
1150
+ # than "digest is not None", since that comparison would otherwise
1151
+ # misread every one of those deliberate baseline-drops as "never
1152
+ # rendered" and skip an edit that has real content to correct.
1153
+ self._has_rendered: bool = False
1154
+ # Stamped once, by the persistence reattach path only, with the
1155
+ # digest of the tree a fresh __init__ produced -- before on_restore
1156
+ # has had any chance to run. Lets the teardown gate tell "restored,
1157
+ # never rendered, and nothing has touched the tree since" (matches
1158
+ # this baseline, safe to skip) apart from "restored, never rendered,
1159
+ # but a teardown override composed real content before delegating"
1160
+ # (differs from this baseline, has to ship). Left None for every
1161
+ # view that was never reattached, where _has_rendered alone governs.
1162
+ self._reattach_baseline_digest: Optional[int] = None
1146
1163
  # Set by ``refresh`` when it swallowed a transport failure, so a
1147
1164
  # caller that needs the edit to have LANDED can tell that apart from
1148
1165
  # a refresh that returned normally. A repaint is content to retry on
@@ -1857,6 +1874,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1857
1874
  # embed content is outside the digest, which is fine -- the
1858
1875
  # digest only certifies the component tree, and refresh() only
1859
1876
  # short-circuits when the caller passes no kwargs.
1877
+ self._has_rendered = True
1860
1878
  self._last_tree_digest = self._compute_tree_digest()
1861
1879
 
1862
1880
  await self._update_message_state(self._message)
@@ -2496,6 +2514,48 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
2496
2514
  frozen += 1
2497
2515
  return frozen
2498
2516
 
2517
+ def _freeze_edit_needed(self, froze: bool) -> bool:
2518
+ """Whether a teardown freeze edit has something new to ship.
2519
+
2520
+ ``froze`` alone answers "did disabling components change anything",
2521
+ which misses a caller that rebuilt the tree (a farewell card) before
2522
+ tearing down -- the digest comparison is what catches that case.
2523
+
2524
+ The comparison is gated on ``_has_rendered`` rather than on
2525
+ ``_last_tree_digest is not None``, because the digest is ``None``
2526
+ for two different reasons and only one of them means "nothing on
2527
+ screen": a view that has never landed a tree (restored from
2528
+ persistence, its post-ready render still pending: ``self.children``
2529
+ is whatever ``__init__`` produced, typically empty for a pattern
2530
+ that builds its tree in ``on_load``, which Discord rejects with
2531
+ error 50006), and a view that HAS rendered but had its digest
2532
+ deliberately dropped so the next refresh ships unconditionally (a
2533
+ stalled edit, a swallowed transport failure, a navigation landing
2534
+ that never stamps a digest at all). Gating on the digest alone
2535
+ would read the second case as "never measured" and skip an edit
2536
+ that has real, possibly stale content to correct, silently
2537
+ dropping a farewell card composed just before teardown -- the
2538
+ exact defect this guard exists to catch.
2539
+
2540
+ A never-rendered view can still have something to ship: a teardown
2541
+ override (``exit()``, ``on_timeout()``) that composes new content
2542
+ before delegating to ``super()`` runs whether or not ``on_restore``
2543
+ ever rendered first, and its farewell card is not the empty tree
2544
+ this guard exists to withhold. ``_reattach_baseline_digest``, stamped
2545
+ once at reattach with the digest of whatever a fresh ``__init__``
2546
+ produced, tells the two apart: a tree that still matches it has
2547
+ nothing on screen to correct (the original defect this method
2548
+ fixes), a tree that no longer matches it was composed by something
2549
+ real and has to ship.
2550
+ """
2551
+ if froze:
2552
+ return True
2553
+ if self._has_rendered:
2554
+ return self._compute_tree_digest() != self._last_tree_digest
2555
+ if self._reattach_baseline_digest is None:
2556
+ return False
2557
+ return self._compute_tree_digest() != self._reattach_baseline_digest
2558
+
2499
2559
  def _dispatch_timeout(self):
2500
2560
  """Repair the dynamic-item registry at the actual discord.py choke point.
2501
2561
 
@@ -2534,14 +2594,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
2534
2594
  self.state_store._undo_enabled_views.pop(self.id, None)
2535
2595
  await self.state_store._destroy_view(self.id, source_id=self.id)
2536
2596
 
2537
- # Skip the edit only when it would ship what is already on screen: a
2538
- # component-less display view (or one already fully disabled) would
2539
- # otherwise send a no-op PATCH on every timeout. The tree is checked
2540
- # alongside the freeze because an override that rebuilds before
2541
- # delegating here has something to say even though nothing froze.
2542
- if self._message and (
2543
- self._freeze_components() or self._compute_tree_digest() != self._last_tree_digest
2544
- ):
2597
+ # See _freeze_edit_needed for what "nothing to ship" covers here.
2598
+ if self._message and self._freeze_edit_needed(self._freeze_components()):
2545
2599
  try:
2546
2600
  await self._bounded(self._message.edit(**self._freeze_edit_kwargs()))
2547
2601
  except discord.NotFound:
@@ -3419,6 +3473,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
3419
3473
  interaction.response.edit_message(view=self, **kwargs),
3420
3474
  timeout=fast_path_timeout,
3421
3475
  )
3476
+ self._has_rendered = True
3422
3477
  self._last_tree_digest = self._compute_tree_digest()
3423
3478
  if perf_on:
3424
3479
  store._record_edit()
@@ -3477,6 +3532,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
3477
3532
  if ("embed" in kwargs or "embeds" in kwargs) and self._webhook_message:
3478
3533
  try:
3479
3534
  await self._bounded(self._webhook_message.edit(view=self, **kwargs))
3535
+ self._has_rendered = True
3480
3536
  self._last_tree_digest = self._compute_tree_digest()
3481
3537
  if perf_on:
3482
3538
  store._record_edit()
@@ -3510,6 +3566,7 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
3510
3566
 
3511
3567
  try:
3512
3568
  await self._bounded(self._message.edit(view=self, **kwargs))
3569
+ self._has_rendered = True
3513
3570
  self._last_tree_digest = self._compute_tree_digest()
3514
3571
  if perf_on:
3515
3572
  store._record_edit()
@@ -4790,15 +4847,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
4790
4847
  elif self._is_layout():
4791
4848
  # V2 messages ARE their components -- edit(view=None) would
4792
4849
  # produce an empty message (error 50006). Freeze instead.
4793
- # The edit is skipped only when it would ship what is
4794
- # already on screen: nothing froze AND the tree still
4795
- # matches the last render. Testing the freeze alone read
4796
- # a caller who rebuilt the tree before exiting as having
4797
- # nothing to say, so a farewell card composed in
4798
- # on_timeout was dropped and the expired prompt kept its
4799
- # live-looking buttons until someone pressed one.
4800
- froze = self._freeze_components()
4801
- if froze or self._compute_tree_digest() != self._last_tree_digest:
4850
+ # See _freeze_edit_needed for what "nothing to ship" covers.
4851
+ if self._freeze_edit_needed(self._freeze_components()):
4802
4852
  await self._bounded(self._message.edit(**self._freeze_edit_kwargs()))
4803
4853
  else:
4804
4854
  await self._bounded(self._message.edit(view=None))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.12.2
3
+ Version: 3.12.3
4
4
  Summary: Redux-inspired UI framework for discord.py
5
5
  Author-email: HollowTheSilver <hollow@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -28,8 +28,8 @@ Requires-Dist: discord.py>=2.7
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
31
- Requires-Dist: black>=26.3; extra == "dev"
32
- Requires-Dist: isort>=8.0; extra == "dev"
31
+ Requires-Dist: black<27.0,>=26.3; extra == "dev"
32
+ Requires-Dist: isort<9.0,>=8.0; extra == "dev"
33
33
  Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
34
34
  Provides-Extra: docs
35
35
  Requires-Dist: mkdocs>=1.5; extra == "docs"
@@ -3,8 +3,8 @@ discord.py>=2.7
3
3
  [dev]
4
4
  pytest>=7.0
5
5
  pytest-asyncio>=0.21
6
- black>=26.3
7
- isort>=8.0
6
+ black<27.0,>=26.3
7
+ isort<9.0,>=8.0
8
8
  testcontainers[postgres]>=4.0
9
9
 
10
10
  [docs]
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "pycascadeui"
10
- version = "3.12.2"
10
+ version = "3.12.3"
11
11
  description = "Redux-inspired UI framework for discord.py"
12
12
  readme = "README.md"
13
13
  license = "MIT"
@@ -38,8 +38,8 @@ dependencies = [
38
38
  dev = [
39
39
  "pytest>=7.0",
40
40
  "pytest-asyncio>=0.21",
41
- "black>=26.3",
42
- "isort>=8.0",
41
+ "black>=26.3,<27.0",
42
+ "isort>=8.0,<9.0",
43
43
  "testcontainers[postgres]>=4.0",
44
44
  ]
45
45
  docs = [
@@ -5,7 +5,7 @@ import inspect
5
5
  import io
6
6
  import logging
7
7
  import time
8
- from unittest.mock import AsyncMock, MagicMock
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
9
 
10
10
  import aiohttp
11
11
  import discord
@@ -5678,6 +5678,114 @@ class TestRebuiltTreeShipsOnTeardown:
5678
5678
 
5679
5679
  message.edit.assert_awaited_once()
5680
5680
 
5681
+ async def test_exit_skips_the_edit_for_a_view_that_never_rendered(self):
5682
+ """A view reattached from persistence but not yet rendered has no
5683
+ baseline on screen for this edit to correct.
5684
+
5685
+ No ``send()`` and no ``refresh()`` runs here, so ``_has_rendered``
5686
+ stays at its ``__init__`` default of ``False`` -- matching a view
5687
+ between reattach and its deferred post-ready render.
5688
+ """
5689
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5690
+ message = self._wire(view)
5691
+ assert view._has_rendered is False
5692
+
5693
+ await view.exit(delete_message=False)
5694
+
5695
+ message.edit.assert_not_called()
5696
+
5697
+ async def test_on_timeout_skips_the_edit_for_a_view_that_never_rendered(self):
5698
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5699
+ message = self._wire(view)
5700
+ assert view._has_rendered is False
5701
+
5702
+ await view.on_timeout()
5703
+
5704
+ message.edit.assert_not_called()
5705
+
5706
+ async def test_exit_still_ships_a_rebuild_when_the_digest_was_never_stamped(self):
5707
+ """``_last_tree_digest`` is ``None`` for more than one reason, and
5708
+ only "never rendered" means "skip".
5709
+
5710
+ A push/pop navigation landing never stamps the destination's digest,
5711
+ so a pushed view carries ``_has_rendered = True`` (content is
5712
+ genuinely on screen) alongside ``_last_tree_digest = None`` for its
5713
+ whole life until some later refresh happens to land. Gating the
5714
+ teardown skip on the digest alone would misread that combination as
5715
+ "never rendered" and drop a farewell card composed just before
5716
+ teardown -- the exact defect the digest comparison exists to catch.
5717
+ """
5718
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5719
+ view.add_item(ActionRow(StatefulButton(label="Accept")))
5720
+ await view.send()
5721
+ message = self._wire(view)
5722
+
5723
+ # Simulate the state a navigation landing leaves behind: rendered,
5724
+ # but with no stamped digest.
5725
+ view._last_tree_digest = None
5726
+ view.clear_items()
5727
+ view.add_item(card("Challenge expired."))
5728
+
5729
+ await view.exit(delete_message=False)
5730
+
5731
+ message.edit.assert_awaited_once()
5732
+
5733
+ async def test_on_timeout_still_ships_a_rebuild_when_the_digest_was_never_stamped(self):
5734
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5735
+ view.add_item(ActionRow(StatefulButton(label="Accept")))
5736
+ await view.send()
5737
+ message = self._wire(view)
5738
+
5739
+ view._last_tree_digest = None
5740
+ view.clear_items()
5741
+ view.add_item(card("Challenge expired."))
5742
+
5743
+ await view.on_timeout()
5744
+
5745
+ message.edit.assert_awaited_once()
5746
+
5747
+ async def test_has_rendered_survives_a_raising_digest_after_send(self):
5748
+ """``_has_rendered`` means "the send landed", stamped before the
5749
+ digest computation that follows it.
5750
+
5751
+ A user subclass's tree can raise inside ``_compute_tree_digest``
5752
+ (a property read on a live item) after the send already reached
5753
+ Discord. ``_send_pipeline`` swallows that raise and drops the
5754
+ digest to ``None``, but the flag must not depend on the digest
5755
+ computation succeeding -- otherwise a view Discord is genuinely
5756
+ showing would read as never rendered at its next teardown.
5757
+ """
5758
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5759
+
5760
+ with patch.object(type(view), "_compute_tree_digest", side_effect=RuntimeError("boom")):
5761
+ await view.send()
5762
+
5763
+ assert view._has_rendered is True
5764
+ assert view._last_tree_digest is None
5765
+
5766
+ message = self._wire(view)
5767
+ view.clear_items()
5768
+ view.add_item(card("Challenge expired."))
5769
+
5770
+ await view.exit(delete_message=False)
5771
+
5772
+ message.edit.assert_awaited_once()
5773
+
5774
+ async def test_refresh_marks_the_view_as_rendered_without_a_prior_send(self):
5775
+ """A view can land its first tree through ``refresh()`` rather than
5776
+ ``send()`` -- a persistence-restored view's ``on_restore()`` calling
5777
+ ``reload()``/``refresh()`` is the production shape this covers. The
5778
+ flag has to flip there too, or a teardown right after would read a
5779
+ genuinely rendered view as never rendered.
5780
+ """
5781
+ view = RenderableLayoutView(interaction=_make_interaction(), user_id=1, guild_id=2)
5782
+ message = self._wire(view)
5783
+ assert view._has_rendered is False
5784
+
5785
+ await view.refresh()
5786
+
5787
+ assert view._has_rendered is True
5788
+
5681
5789
 
5682
5790
  class TestTimeoutWindowStillRenders:
5683
5791
  """A view inside its timeout window is intact, so its render has to ship.
@@ -1097,11 +1097,14 @@ class TestNavigationFastPath:
1097
1097
  await root.send()
1098
1098
 
1099
1099
  nav = _make_interaction(user_id=1, guild_id=100, is_done=False)
1100
- await root.push(_Sub, interaction=nav)
1100
+ dest = await root.push(_Sub, interaction=nav)
1101
1101
 
1102
1102
  # One round-trip: edit + ack together, no separate defer.
1103
1103
  nav.response.edit_message.assert_awaited_once()
1104
1104
  nav.response.defer.assert_not_called()
1105
+ # The fast-path landing is a real render: a teardown right after
1106
+ # must not read it as "never rendered" (see _freeze_edit_needed).
1107
+ assert dest._has_rendered is True
1105
1108
 
1106
1109
  async def test_push_falls_back_to_deferred_edit_when_acked(self):
1107
1110
  class _Root(StatefulView):
@@ -1119,10 +1122,11 @@ class TestNavigationFastPath:
1119
1122
  nav.edit_original_response = AsyncMock(
1120
1123
  return_value=MagicMock(id=777, channel=MagicMock(id=666))
1121
1124
  )
1122
- await root.push(_Sub, interaction=nav)
1125
+ dest = await root.push(_Sub, interaction=nav)
1123
1126
 
1124
1127
  nav.response.edit_message.assert_not_called()
1125
1128
  nav.edit_original_response.assert_awaited_once()
1129
+ assert dest._has_rendered is True
1126
1130
 
1127
1131
  async def test_push_non_component_interaction_skips_fast_path(self):
1128
1132
  """A non-component interaction (e.g. a slash command) is ineligible for
@@ -1248,6 +1248,148 @@ class TestReattachOnBind:
1248
1248
  assert captured["view"].db == "POOL"
1249
1249
 
1250
1250
 
1251
+ class TestReattachBaselineDigest:
1252
+ """``_reattach_one`` stamps ``_reattach_baseline_digest`` with the tree a
1253
+ fresh ``__init__`` produced, so a teardown before ``on_restore`` ever
1254
+ renders can tell "untouched since reattach" apart from "a teardown
1255
+ override composed real content" -- the second case has to ship even
1256
+ though the view itself was never rendered.
1257
+ """
1258
+
1259
+ def _stub_seams(self, cls):
1260
+ async def _fake_register(self):
1261
+ pass
1262
+
1263
+ async def _fake_update(self, message):
1264
+ pass
1265
+
1266
+ cls._register_state = _fake_register
1267
+ cls._update_message_state = _fake_update
1268
+ cls._validate_custom_ids = lambda self: None
1269
+
1270
+ async def _row(self, key, cls):
1271
+ be = InMemoryBackend()
1272
+ await be.initialize()
1273
+ await be.row_upsert(
1274
+ TABLE_PERSISTENT_VIEWS,
1275
+ {
1276
+ "persistence_key": key,
1277
+ "view_class": cls.__qualname__,
1278
+ "custom_id": None,
1279
+ "message_id": 1,
1280
+ "channel_id": 2,
1281
+ "guild_id": None,
1282
+ "user_id": None,
1283
+ "session_id": None,
1284
+ "init_kwargs": json.dumps({"persistence_key": key}),
1285
+ "kwargs_schema_version": 1,
1286
+ "schema_version": 1,
1287
+ "created_at": 1,
1288
+ "updated_at": 1,
1289
+ },
1290
+ ["persistence_key"],
1291
+ )
1292
+ mgr = PersistenceManager(store=get_store(), registry=RegistryPersistence(backend=be))
1293
+ await mgr.rehydrate()
1294
+ return mgr
1295
+
1296
+ async def _reattach(self, cls, key):
1297
+ from cascadeui.views.persistent import PersistentLayoutView # noqa: F401
1298
+
1299
+ self._stub_seams(cls)
1300
+ mgr = await self._row(key, cls)
1301
+
1302
+ class _Msg:
1303
+ id = 1
1304
+
1305
+ async def edit(self, **kwargs):
1306
+ self.edited_with = kwargs
1307
+
1308
+ async def delete(self):
1309
+ self.deleted = True
1310
+
1311
+ message = _Msg()
1312
+
1313
+ class _FakeBot:
1314
+ def add_view(self, view, message_id):
1315
+ pass
1316
+
1317
+ mgr._bot = _FakeBot()
1318
+ restored_views = []
1319
+ outcome = await mgr._reattach_one(
1320
+ row=mgr._registry_rows[0],
1321
+ view_cls=cls,
1322
+ init_kwargs={"persistence_key": key},
1323
+ message=message,
1324
+ class_name=cls.__qualname__,
1325
+ restored_views=restored_views,
1326
+ )
1327
+ assert outcome == "restored"
1328
+ return restored_views[0], message
1329
+
1330
+ async def test_baseline_matches_a_fresh_non_empty_tree(self):
1331
+ """The stamp is a fingerprint, not a sentinel: confirm it equals
1332
+ the tree __init__ actually built, on the exact view instance
1333
+ reattach constructed, and differs across two views whose trees
1334
+ differ. Every empty V2 tree hashes to the same constant, so an
1335
+ empty fixture here cannot tell a real computation from a
1336
+ hardcoded one. __init__ has to build something.
1337
+ """
1338
+ from cascadeui.views.persistent import PersistentLayoutView
1339
+
1340
+ class _EmptyPanel(PersistentLayoutView):
1341
+ pass
1342
+
1343
+ class _BuiltPanel(PersistentLayoutView):
1344
+ def __init__(self, *args, **kwargs):
1345
+ super().__init__(*args, **kwargs)
1346
+ self.add_item(discord.ui.TextDisplay("Loading..."))
1347
+
1348
+ empty_view, _ = await self._reattach(_EmptyPanel, "baseline:empty")
1349
+ built_view, _ = await self._reattach(_BuiltPanel, "baseline:built")
1350
+
1351
+ assert built_view._reattach_baseline_digest is not None
1352
+ assert built_view._reattach_baseline_digest == built_view._compute_tree_digest()
1353
+ assert built_view._reattach_baseline_digest != empty_view._reattach_baseline_digest
1354
+ assert built_view._has_rendered is False
1355
+
1356
+ async def test_untouched_restored_view_skips_the_teardown_edit(self):
1357
+ """Nothing has changed the tree since reattach, so there is
1358
+ nothing on screen for the teardown edit to correct -- the
1359
+ original defect this stamp exists to keep fixed."""
1360
+ from cascadeui.views.persistent import PersistentLayoutView
1361
+
1362
+ class _EmptyPanel(PersistentLayoutView):
1363
+ pass
1364
+
1365
+ view, message = await self._reattach(_EmptyPanel, "baseline:untouched")
1366
+
1367
+ await view.exit(delete_message=False)
1368
+
1369
+ assert not hasattr(message, "edited_with")
1370
+
1371
+ async def test_farewell_card_composed_before_teardown_still_ships(self):
1372
+ """A teardown override that composes new content before delegating
1373
+ to ``super()`` runs whether or not ``on_restore`` ever rendered --
1374
+ its farewell card is not the empty tree the skip exists for, and
1375
+ has to reach the message even though ``_has_rendered`` is still
1376
+ False."""
1377
+ from cascadeui.components.patterns.v2 import card
1378
+ from cascadeui.views.persistent import PersistentLayoutView
1379
+
1380
+ class _FarewellPanel(PersistentLayoutView):
1381
+ async def exit(self, delete_message=None):
1382
+ self.clear_items()
1383
+ self.add_item(card("Session expired."))
1384
+ return await super().exit(delete_message=delete_message)
1385
+
1386
+ view, message = await self._reattach(_FarewellPanel, "baseline:farewell")
1387
+
1388
+ await view.exit(delete_message=False)
1389
+
1390
+ assert hasattr(message, "edited_with")
1391
+
1392
+
1251
1393
  class TestSendTimeBind:
1252
1394
  """``_bind_from_context`` (the send-time seam) forwards ``on_bind`` when
1253
1395
  the construction context resolves a bot, and ``send()`` invokes it."""
File without changes
File without changes
File without changes