pycascadeui 3.3.1__tar.gz → 3.3.2__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 (135) hide show
  1. {pycascadeui-3.3.1/pycascadeui.egg-info → pycascadeui-3.3.2}/PKG-INFO +2 -1
  2. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/README.md +1 -0
  3. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/__init__.py +1 -1
  4. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/_interaction.py +36 -5
  5. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/_navigation.py +23 -4
  6. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/base.py +91 -8
  7. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/layout.py +15 -3
  8. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/persistent.py +9 -3
  9. {pycascadeui-3.3.1 → pycascadeui-3.3.2/pycascadeui.egg-info}/PKG-INFO +2 -1
  10. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/pyproject.toml +1 -1
  11. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_auto_defer.py +91 -0
  12. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_layout_view.py +155 -0
  13. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_view_init.py +42 -0
  14. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/LICENSE +0 -0
  15. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/__main__.py +0 -0
  16. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/__init__.py +0 -0
  17. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/base.py +0 -0
  18. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/buttons.py +0 -0
  19. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/inputs.py +0 -0
  20. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/patterns/__init__.py +0 -0
  21. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/patterns/v1.py +0 -0
  22. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/patterns/v2.py +0 -0
  23. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/selects.py +0 -0
  24. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/types.py +0 -0
  25. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/v1_composition.py +0 -0
  26. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/components/wrappers.py +0 -0
  27. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/devtools.py +0 -0
  28. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/exceptions.py +0 -0
  29. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/__init__.py +0 -0
  30. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/backends/__init__.py +0 -0
  31. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/backends/memory.py +0 -0
  32. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/backends/postgres.py +0 -0
  33. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/backends/sqlite.py +0 -0
  34. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/config.py +0 -0
  35. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/manager.py +0 -0
  36. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/migrations.py +0 -0
  37. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/protocols.py +0 -0
  38. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/schema.py +0 -0
  39. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/persistence/schema_postgres.py +0 -0
  40. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/py.typed +0 -0
  41. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/setup.py +0 -0
  42. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/__init__.py +0 -0
  43. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/actions.py +0 -0
  44. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/computed.py +0 -0
  45. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/middleware/__init__.py +0 -0
  46. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/middleware/logging.py +0 -0
  47. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/middleware/persistence.py +0 -0
  48. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/middleware/undo.py +0 -0
  49. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/reducers.py +0 -0
  50. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/singleton.py +0 -0
  51. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/slots.py +0 -0
  52. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/store.py +0 -0
  53. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/state/types.py +0 -0
  54. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/theming/__init__.py +0 -0
  55. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/theming/context.py +0 -0
  56. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/theming/core.py +0 -0
  57. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/theming/themes.py +0 -0
  58. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/tracing.py +0 -0
  59. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/__init__.py +0 -0
  60. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/coercion.py +0 -0
  61. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/decorators.py +0 -0
  62. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/errors.py +0 -0
  63. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/fetch.py +0 -0
  64. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/logging.py +0 -0
  65. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/strings.py +0 -0
  66. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/utils/tasks.py +0 -0
  67. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/validation.py +0 -0
  68. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/__init__.py +0 -0
  69. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/_placement.py +0 -0
  70. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/__init__.py +0 -0
  71. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/form.py +0 -0
  72. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/leaderboard.py +0 -0
  73. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/menu.py +0 -0
  74. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/paginated.py +0 -0
  75. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/roles.py +0 -0
  76. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/tabs.py +0 -0
  77. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/types.py +0 -0
  78. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/patterns/wizard.py +0 -0
  79. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/cascadeui/views/view.py +0 -0
  80. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/pycascadeui.egg-info/SOURCES.txt +0 -0
  81. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/pycascadeui.egg-info/dependency_links.txt +0 -0
  82. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/pycascadeui.egg-info/requires.txt +0 -0
  83. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/pycascadeui.egg-info/top_level.txt +0 -0
  84. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/setup.cfg +0 -0
  85. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_backends.py +0 -0
  86. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_backends_postgres.py +0 -0
  87. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_backends_raw_sql.py +0 -0
  88. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_batching.py +0 -0
  89. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_button_grid.py +0 -0
  90. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_components.py +0 -0
  91. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_computed.py +0 -0
  92. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_devtools.py +0 -0
  93. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_dynamic_persistent_button.py +0 -0
  94. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_emoji_grid.py +0 -0
  95. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_ephemeral_refresh.py +0 -0
  96. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_fetch.py +0 -0
  97. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_five_pillars.py +0 -0
  98. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_form_patterns.py +0 -0
  99. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_hooks.py +0 -0
  100. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_input_coercion.py +0 -0
  101. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_inputs.py +0 -0
  102. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_instance_limit.py +0 -0
  103. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_layout_pagination.py +0 -0
  104. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_layout_patterns.py +0 -0
  105. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_layout_persistent.py +0 -0
  106. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_leaderboard_patterns.py +0 -0
  107. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_menu_patterns.py +0 -0
  108. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_message_cleanup.py +0 -0
  109. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_middleware.py +0 -0
  110. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_nav_version.py +0 -0
  111. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_navigation.py +0 -0
  112. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_navigation_artifact_restore.py +0 -0
  113. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_owner_only.py +0 -0
  114. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_paginated_patterns.py +0 -0
  115. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_pagination.py +0 -0
  116. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_persistence.py +0 -0
  117. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_persistence_middleware.py +0 -0
  118. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_persistent_views.py +0 -0
  119. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_pillar_isolation.py +0 -0
  120. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_placement.py +0 -0
  121. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_rebuild_callback.py +0 -0
  122. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_reducers.py +0 -0
  123. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_roles_patterns.py +0 -0
  124. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_scoping.py +0 -0
  125. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_selects.py +0 -0
  126. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_state_slots.py +0 -0
  127. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_state_store.py +0 -0
  128. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_tab_patterns.py +0 -0
  129. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_theming.py +0 -0
  130. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_undo.py +0 -0
  131. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_update_coalescing.py +0 -0
  132. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_v2_helpers.py +0 -0
  133. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_validation.py +0 -0
  134. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_wizard_patterns.py +0 -0
  135. {pycascadeui-3.3.1 → pycascadeui-3.3.2}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.1
3
+ Version: 3.3.2
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
@@ -665,6 +665,7 @@ for row in rows:
665
665
  - Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
666
666
  - Interaction serialization so rapid clicks process sequentially
667
667
  - Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
668
+ - `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
668
669
  - Silent snowflake coercion at every public boundary
669
670
  - Class-attribute validation at subclass-definition time
670
671
 
@@ -622,6 +622,7 @@ for row in rows:
622
622
  - Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
623
623
  - Interaction serialization so rapid clicks process sequentially
624
624
  - Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
625
+ - `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
625
626
  - Silent snowflake coercion at every public boundary
626
627
  - Class-attribute validation at subclass-definition time
627
628
 
@@ -132,7 +132,7 @@ if "SQLiteBackend" in _persistence_all:
132
132
  # // ========================================( Script )======================================== // #
133
133
 
134
134
 
135
- __version__ = "3.3.1"
135
+ __version__ = "3.3.2"
136
136
 
137
137
  # Export public API
138
138
  __all__ = [
@@ -88,6 +88,22 @@ class _InteractionMixin:
88
88
  if self.auto_defer and not interaction.response.is_done():
89
89
  try:
90
90
  await interaction.response.defer()
91
+ except discord.HTTPException as e:
92
+ # 40060 means Discord acknowledged a request the
93
+ # acting-view fast path cancelled locally (cancellation
94
+ # race) -- the interaction is already acked, so this is
95
+ # benign and routine. Any other status is a genuine ack
96
+ # failure the user saw as an interaction-failed toast.
97
+ if e.code == 40060:
98
+ logger.debug(
99
+ f"Post-callback defer raced an existing ack in "
100
+ f"{self.__class__.__name__} (40060)"
101
+ )
102
+ else:
103
+ logger.warning(
104
+ f"Post-callback defer failed in {self.__class__.__name__}: "
105
+ f"status={e.status} code={e.code}"
106
+ )
91
107
  except Exception:
92
108
  logger.debug(
93
109
  f"Post-callback defer failed in {self.__class__.__name__} "
@@ -189,9 +205,24 @@ class _InteractionMixin:
189
205
  send operations. Prevents double-defer when auto-defer or
190
206
  ``serialize_interactions`` has already acknowledged the
191
207
  interaction before the callback runs.
208
+
209
+ The ack is bounded by ``auto_defer_delay``: this call runs inside
210
+ the interaction lock, and a Discord ack endpoint that stalls past
211
+ the ack window would pin the lock on a hung socket. A defer that
212
+ cannot land in time is useless anyway -- the auto-defer timer,
213
+ running outside the lock, is the backstop -- so the stall is
214
+ cancelled and swallowed rather than propagated.
192
215
  """
193
216
  if not interaction.response.is_done():
194
- await interaction.response.defer()
217
+ try:
218
+ await asyncio.wait_for(
219
+ interaction.response.defer(), timeout=max(0.5, self.auto_defer_delay)
220
+ )
221
+ except asyncio.TimeoutError:
222
+ logger.debug(
223
+ f"Ack defer stalled past {self.auto_defer_delay}s in "
224
+ f"{type(self).__name__}; auto-defer timer backstops the ack."
225
+ )
195
226
 
196
227
  # // ==================( Ephemeral Refresh )================== // #
197
228
 
@@ -341,11 +372,11 @@ class _InteractionMixin:
341
372
  # panel becomes a harmless orphan the user can dismiss.
342
373
  if self._message:
343
374
  try:
344
- await self._message.delete()
345
- except (discord.NotFound, discord.HTTPException):
375
+ await self._bounded(self._message.delete())
376
+ except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
346
377
  try:
347
- await self._message.edit(view=self)
348
- except (discord.NotFound, discord.HTTPException):
378
+ await self._bounded(self._message.edit(view=self))
379
+ except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
349
380
  pass
350
381
 
351
382
  await self.exit()
@@ -380,13 +380,24 @@ class _NavigationMixin:
380
380
  new_view._check_placement()
381
381
 
382
382
  try:
383
- msg = await current_interaction.edit_original_response(view=new_view, **edit_kwargs)
383
+ msg = await self._bounded(
384
+ current_interaction.edit_original_response(view=new_view, **edit_kwargs)
385
+ )
384
386
  # Preserve the parent's plain Message ref. The edit response
385
387
  # is an InteractionMessage / WebhookMessage bound to the
386
388
  # 15-minute interaction token; subsequent edits need the
387
389
  # channel endpoint, which the plain ref provides.
388
390
  if new_view._message is None:
389
391
  new_view._message = msg
392
+ except asyncio.TimeoutError:
393
+ # Edit stalled past edit_timeout. The destination view is
394
+ # already registered with a fresh digest, so the next
395
+ # interaction re-renders it; re-attempting inline would stack
396
+ # a second wait on the same stalled endpoint.
397
+ logger.warning(
398
+ f"Navigation edit stalled past {self.edit_timeout}s in "
399
+ f"{type(self).__name__}; destination re-renders on next interaction."
400
+ )
390
401
  except discord.HTTPException:
391
402
  # Interaction token expired (15-min lifetime). Route the
392
403
  # channel-endpoint fallback through refresh() so it picks
@@ -405,9 +416,17 @@ class _NavigationMixin:
405
416
  # Stack was empty -- pop() already stopped/unsubscribed this view,
406
417
  # so remove the dead components from the message to avoid a broken UI
407
418
  try:
408
- await interaction.edit_original_response(view=None)
409
- except discord.HTTPException:
410
- pass
419
+ await self._bounded(interaction.edit_original_response(view=None))
420
+ except asyncio.TimeoutError:
421
+ logger.debug(
422
+ f"Back-navigation clear stalled past {self.edit_timeout}s "
423
+ f"in {type(self).__name__}."
424
+ )
425
+ except discord.HTTPException as e:
426
+ logger.debug(
427
+ f"Back-navigation clear failed in {type(self).__name__}: "
428
+ f"status={e.status} code={e.code}"
429
+ )
411
430
  # When prev_view is non-None, pop() routed through _apply_navigation_edit
412
431
  # which already swapped the message to the restored view.
413
432
 
@@ -195,6 +195,20 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
195
195
  # Discord returns an escalated rate-limit.
196
196
  refresh_cooldown_ms: Optional[int] = None
197
197
 
198
+ # Subclass config: edit timeout ceiling
199
+ # discord.py issues HTTP edits with no total timeout (aiohttp defaults to
200
+ # total=None), so a connection that stalls without a response would pin
201
+ # the awaiting code -- and, on the interaction-locked refresh/navigation
202
+ # paths, the view itself -- until the socket drops. This ceiling bounds
203
+ # every live-view and teardown edit through ``_bounded``: a stall is
204
+ # cancelled after this many seconds and the view recovers on the next
205
+ # interaction. The default clears realistic attachment uploads while
206
+ # still capping a true hang. ``None`` disables the ceiling (unbounded
207
+ # awaits, matching discord.py's own default). The acting-view fast path
208
+ # keeps its own tighter bound, which protects the 3s ack deadline rather
209
+ # than guarding against a hang.
210
+ edit_timeout: Optional[float] = 60.0
211
+
198
212
  # Subclass config: interaction serialization
199
213
  # When True, rapid button clicks are processed one at a time to prevent
200
214
  # racing message edits that cause "This interaction failed" errors.
@@ -283,6 +297,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
283
297
  )
284
298
  # Attributes that must be a positive float/int.
285
299
  _POSITIVE_NUMBER_ATTRS: ClassVar[tuple] = ("auto_defer_delay",)
300
+ # Attributes that must be a positive float/int or None (None = disabled).
301
+ _OPTIONAL_POSITIVE_NUMBER_ATTRS: ClassVar[tuple] = ("edit_timeout",)
286
302
  # Attributes that must be a bool.
287
303
  _BOOL_ATTRS: ClassVar[tuple] = (
288
304
  "owner_only",
@@ -338,6 +354,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
338
354
  if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
339
355
  raise ValueError(f"{cls.__name__}.{name} must be a positive number, got {value!r}")
340
356
  return
357
+ if name in cls._OPTIONAL_POSITIVE_NUMBER_ATTRS:
358
+ if value is None:
359
+ return
360
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
361
+ raise ValueError(
362
+ f"{cls.__name__}.{name} must be a positive number or None, got {value!r}"
363
+ )
364
+ return
341
365
  if name in cls._BOOL_ATTRS:
342
366
  if not isinstance(value, bool):
343
367
  raise ValueError(
@@ -426,6 +450,9 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
426
450
  for attr in cls._POSITIVE_NUMBER_ATTRS:
427
451
  if attr in own:
428
452
  cls._validate_attribute_value(attr, own[attr])
453
+ for attr in cls._OPTIONAL_POSITIVE_NUMBER_ATTRS:
454
+ if attr in own:
455
+ cls._validate_attribute_value(attr, own[attr])
429
456
  for attr in cls._BOOL_ATTRS:
430
457
  if attr in own:
431
458
  cls._validate_attribute_value(attr, own[attr])
@@ -1465,9 +1492,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1465
1492
 
1466
1493
  if self._message:
1467
1494
  try:
1468
- await self._message.edit(view=self)
1495
+ await self._bounded(self._message.edit(view=self))
1469
1496
  except discord.NotFound:
1470
1497
  pass # Message was already deleted
1498
+ except asyncio.TimeoutError:
1499
+ logger.warning(
1500
+ f"Timed out disabling components on timeout for "
1501
+ f"{type(self).__name__}; continuing teardown."
1502
+ )
1471
1503
  except Exception as e:
1472
1504
  hint = ""
1473
1505
  if self._ephemeral:
@@ -1664,6 +1696,9 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1664
1696
  # value. Disqualified cases (modal interactions, cross-view
1665
1697
  # message mismatch, already-deferred response, missing message
1666
1698
  # ref) fall through to the existing webhook/channel paths.
1699
+ # Ephemeral acting views take the ack-first branch instead: a
1700
+ # webhook-only edit runs too slowly to double as the ack without
1701
+ # risking the 3s deadline.
1667
1702
  #
1668
1703
  # The fast path couples ack to edit in one HTTP call. A slow
1669
1704
  # edit response from Discord (latency spike, ephemeral backend
@@ -1677,13 +1712,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1677
1712
  # done" and the fall-through paths behave as if the fast path
1678
1713
  # was never attempted.
1679
1714
  interaction = _CURRENT_INTERACTION.get()
1680
- if (
1715
+ acting = (
1681
1716
  interaction is not None
1682
1717
  and interaction.type == discord.InteractionType.component
1683
1718
  and interaction.message is not None
1684
1719
  and interaction.message.id == self._message.id
1685
1720
  and not interaction.response.is_done()
1686
- ):
1721
+ )
1722
+ if acting and not self._ephemeral:
1687
1723
  fast_path_timeout = max(0.5, self.auto_defer_delay - 1.0)
1688
1724
  try:
1689
1725
  await asyncio.wait_for(
@@ -1723,6 +1759,18 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1723
1759
  # Any other HTTP error falls through to the channel path
1724
1760
  # so a transient failure on the interaction endpoint does
1725
1761
  # not lose the edit entirely.
1762
+ elif acting and self._ephemeral:
1763
+ # Ephemeral messages edit only through the interaction webhook,
1764
+ # which runs slower than a channel PATCH. The edit-as-ack fast
1765
+ # path couples the ack to that edit, so a slow webhook response
1766
+ # starves the 3s ack deadline: the panel misses its ack (the
1767
+ # interaction-failed toast) and loses the edit (a frozen body).
1768
+ # Acknowledge first with a deferred update, then fall through to
1769
+ # the webhook edit below. self._message is the
1770
+ # InteractionMessage/WebhookMessage, whose .edit() routes through
1771
+ # the webhook and stays valid for the token's 15-minute life with
1772
+ # no 3s deadline on the edit itself.
1773
+ await self._safe_defer(interaction)
1726
1774
 
1727
1775
  # Interaction-response messages ignore embed edits via the channel
1728
1776
  # endpoint (PATCH /channels/{id}/messages/{id}). When the caller
@@ -1731,12 +1779,19 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1731
1779
  # update embeds. Falls back to the plain Message on token expiry.
1732
1780
  if ("embed" in kwargs or "embeds" in kwargs) and self._webhook_message:
1733
1781
  try:
1734
- await self._webhook_message.edit(view=self, **kwargs)
1782
+ await self._bounded(self._webhook_message.edit(view=self, **kwargs))
1735
1783
  self._last_tree_digest = self._compute_tree_digest()
1736
1784
  if perf_on:
1737
1785
  store._record_edit()
1738
1786
  self._stamp_cooldown()
1739
1787
  return
1788
+ except asyncio.TimeoutError:
1789
+ logger.warning(
1790
+ f"Webhook edit stalled past {self.edit_timeout}s in "
1791
+ f"{type(self).__name__}; the next refresh re-ships."
1792
+ )
1793
+ self._last_tree_digest = None
1794
+ return
1740
1795
  except discord.HTTPException as e:
1741
1796
  if self._handle_rate_limit(e):
1742
1797
  return
@@ -1744,13 +1799,19 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1744
1799
  self._webhook_message = None
1745
1800
 
1746
1801
  try:
1747
- await self._message.edit(view=self, **kwargs)
1802
+ await self._bounded(self._message.edit(view=self, **kwargs))
1748
1803
  self._last_tree_digest = self._compute_tree_digest()
1749
1804
  if perf_on:
1750
1805
  store._record_edit()
1751
1806
  self._stamp_cooldown()
1752
1807
  except discord.NotFound:
1753
1808
  pass
1809
+ except asyncio.TimeoutError:
1810
+ logger.warning(
1811
+ f"Edit stalled past {self.edit_timeout}s in {type(self).__name__}; "
1812
+ f"the next refresh re-ships."
1813
+ )
1814
+ self._last_tree_digest = None
1754
1815
  except discord.HTTPException as e:
1755
1816
  if not self._handle_rate_limit(e):
1756
1817
  raise
@@ -1780,6 +1841,20 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
1780
1841
  self._refresh_not_before = time.monotonic() + retry
1781
1842
  return True
1782
1843
 
1844
+ async def _bounded(self, coro):
1845
+ """Await a Discord HTTP coroutine under the ``edit_timeout`` ceiling.
1846
+
1847
+ ``asyncio.wait_for`` cancels the request when it stalls past
1848
+ ``edit_timeout`` seconds; aiohttp closes the connection on
1849
+ cancellation, so a hung socket cannot pin the awaiting code.
1850
+ ``edit_timeout = None`` awaits the coroutine directly with no
1851
+ ceiling. Raises ``asyncio.TimeoutError`` on stall so the caller
1852
+ can release the interaction lock and recover on the next edit.
1853
+ """
1854
+ if self.edit_timeout is None:
1855
+ return await coro
1856
+ return await asyncio.wait_for(coro, self.edit_timeout)
1857
+
1783
1858
  def _check_placement(self) -> None:
1784
1859
  """Run the V2 placement validator on this view if enabled.
1785
1860
 
@@ -2413,19 +2488,27 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
2413
2488
  if self._message:
2414
2489
  try:
2415
2490
  if delete_message:
2416
- await self._message.delete()
2491
+ await self._bounded(self._message.delete())
2417
2492
  elif self._is_layout():
2418
2493
  # V2 messages ARE their components -- edit(view=None) would
2419
2494
  # produce an empty message (error 50006). Freeze instead.
2420
2495
  self._freeze_components()
2421
- await self._message.edit(view=self)
2496
+ await self._bounded(self._message.edit(view=self))
2422
2497
  else:
2423
- await self._message.edit(view=None)
2498
+ await self._bounded(self._message.edit(view=None))
2424
2499
  except discord.NotFound:
2425
2500
  # Expected lifecycle: user dismissed the ephemeral, an
2426
2501
  # admin deleted the message, or the channel was deleted.
2427
2502
  # Nothing left to clean up on Discord's side.
2428
2503
  pass
2504
+ except asyncio.TimeoutError:
2505
+ # Visual cleanup stalled past edit_timeout. State teardown
2506
+ # already ran above, so the view is gone regardless of the
2507
+ # stale message on screen.
2508
+ logger.warning(
2509
+ f"Exit cleanup edit stalled past {self.edit_timeout}s for "
2510
+ f"{type(self).__name__}; view torn down regardless."
2511
+ )
2429
2512
  except discord.HTTPException as e:
2430
2513
  if self._ephemeral and getattr(e, "status", None) == 401:
2431
2514
  # Expected lifecycle for ephemerals past the 15-minute
@@ -1,6 +1,8 @@
1
1
  # // ========================================( Modules )======================================== // #
2
2
 
3
3
 
4
+ import asyncio
5
+ import logging
4
6
  from typing import Any, Dict, Optional, Sequence
5
7
 
6
8
  import discord
@@ -9,6 +11,8 @@ from discord.ui import ActionRow, Item, LayoutView
9
11
  from ..components.base import StatefulButton
10
12
  from .base import _StatefulMixin
11
13
 
14
+ logger = logging.getLogger(__name__)
15
+
12
16
  # // ========================================( Classes )======================================== // #
13
17
 
14
18
 
@@ -98,9 +102,17 @@ class StatefulLayoutView(_StatefulMixin, LayoutView):
98
102
  # an empty message. Freeze instead.
99
103
  try:
100
104
  self._freeze_components()
101
- await interaction.edit_original_response(view=self)
102
- except discord.HTTPException:
103
- pass
105
+ await self._bounded(interaction.edit_original_response(view=self))
106
+ except asyncio.TimeoutError:
107
+ logger.debug(
108
+ f"Back-navigation freeze stalled past {self.edit_timeout}s "
109
+ f"in {type(self).__name__}."
110
+ )
111
+ except discord.HTTPException as e:
112
+ logger.debug(
113
+ f"Back-navigation freeze failed in {type(self).__name__}: "
114
+ f"status={e.status} code={e.code}"
115
+ )
104
116
  # When prev_view is non-None, pop() routed through _apply_navigation_edit
105
117
  # which already swapped the message to the restored view.
106
118
 
@@ -1,6 +1,7 @@
1
1
  # // ========================================( Modules )======================================== // #
2
2
 
3
3
 
4
+ import asyncio
4
5
  import logging
5
6
  import re
6
7
  from typing import Dict
@@ -107,7 +108,7 @@ class _PersistentMixin:
107
108
  V2 calls ``delete()`` because V2 messages ARE their components --
108
109
  ``edit(view=None)`` would produce an empty message (Discord error 50006).
109
110
  """
110
- await old_message.edit(view=None)
111
+ await self._bounded(old_message.edit(view=None))
111
112
 
112
113
  async def _register_persistent(self, message) -> None:
113
114
  """Perform duplicate-key cleanup and dispatch PERSISTENT_VIEW_REGISTERED.
@@ -154,7 +155,12 @@ class _PersistentMixin:
154
155
  f"Cleaned up previous message {old_msg_id} for "
155
156
  f"persistence_key '{self.persistence_key}'"
156
157
  )
157
- except (discord.NotFound, discord.Forbidden, discord.HTTPException):
158
+ except (
159
+ discord.NotFound,
160
+ discord.Forbidden,
161
+ discord.HTTPException,
162
+ asyncio.TimeoutError,
163
+ ):
158
164
  pass # Old message already gone, nothing to clean up
159
165
  except Exception as e:
160
166
  logger.debug(
@@ -296,4 +302,4 @@ class PersistentLayoutView(_PersistentMixin, StatefulLayoutView):
296
302
 
297
303
  async def _cleanup_orphan_message(self, old_message):
298
304
  """V2 messages ARE their components -- delete instead of edit."""
299
- await old_message.delete()
305
+ await self._bounded(old_message.delete())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.3.1
3
+ Version: 3.3.2
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
@@ -665,6 +665,7 @@ for row in rows:
665
665
  - Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
666
666
  - Interaction serialization so rapid clicks process sequentially
667
667
  - Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
668
+ - `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
668
669
  - Silent snowflake coercion at every public boundary
669
670
  - Class-attribute validation at subclass-definition time
670
671
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pycascadeui"
7
- version = "3.3.1"
7
+ version = "3.3.2"
8
8
  description = "Redux-inspired UI framework for discord.py"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,8 +1,11 @@
1
1
  """Tests for auto-defer safety net on StatefulView."""
2
2
 
3
3
  import asyncio
4
+ import logging
5
+ import time
4
6
  from unittest.mock import AsyncMock, MagicMock, patch
5
7
 
8
+ import discord
6
9
  import pytest
7
10
  from helpers import make_interaction as _make_interaction
8
11
 
@@ -19,6 +22,23 @@ def _make_item(callback):
19
22
  return item
20
23
 
21
24
 
25
+ def _http_error(status, code):
26
+ """Build a ``discord.HTTPException`` carrying a specific status/code.
27
+
28
+ The post-callback defer classifies errors by ``code`` (40060 is the
29
+ benign already-acknowledged race), so tests need to control it directly.
30
+ """
31
+
32
+ class _Err(discord.HTTPException):
33
+ def __init__(self):
34
+ Exception.__init__(self, str(status))
35
+ self.status = status
36
+ self.code = code
37
+ self.retry_after = 0
38
+
39
+ return _Err()
40
+
41
+
22
42
  # // ========================================( Timer Fires )======================================== // #
23
43
 
24
44
 
@@ -149,6 +169,55 @@ class TestPostCallbackDefer:
149
169
 
150
170
  interaction.response.defer.assert_not_called()
151
171
 
172
+ async def test_post_defer_40060_race_logged_at_debug(self, caplog):
173
+ """A 40060 (already acknowledged) on the post-callback defer is the
174
+ benign cancellation race the acting-view fast path can produce.
175
+ Logged at debug, never warning, so a successful fast-path edit does
176
+ not spam warnings on every interaction.
177
+ """
178
+ view = StatefulView(interaction=_make_interaction())
179
+ view.auto_defer_delay = 10
180
+ view.owner_only = False
181
+
182
+ interaction = _make_interaction(is_done=False)
183
+ interaction.response.defer = AsyncMock(side_effect=_http_error(400, 40060))
184
+
185
+ async def silent_callback(inter):
186
+ pass
187
+
188
+ item = _make_item(silent_callback)
189
+ with caplog.at_level(logging.DEBUG, logger="cascadeui.views._interaction"):
190
+ await view._scheduled_task(item, interaction)
191
+
192
+ assert any(
193
+ rec.levelno == logging.DEBUG and "40060" in rec.getMessage() for rec in caplog.records
194
+ )
195
+ assert not any(rec.levelno >= logging.WARNING for rec in caplog.records)
196
+
197
+ async def test_post_defer_genuine_failure_logged_at_warning(self, caplog):
198
+ """A non-40060 HTTP failure is a real ack failure -- the user saw an
199
+ interaction-failed toast -- so it surfaces at warning with the
200
+ status and code instead of vanishing at debug.
201
+ """
202
+ view = StatefulView(interaction=_make_interaction())
203
+ view.auto_defer_delay = 10
204
+ view.owner_only = False
205
+
206
+ interaction = _make_interaction(is_done=False)
207
+ interaction.response.defer = AsyncMock(side_effect=_http_error(404, 10062))
208
+
209
+ async def silent_callback(inter):
210
+ pass
211
+
212
+ item = _make_item(silent_callback)
213
+ with caplog.at_level(logging.WARNING, logger="cascadeui.views._interaction"):
214
+ await view._scheduled_task(item, interaction)
215
+
216
+ assert any(
217
+ rec.levelno == logging.WARNING and "status=404" in rec.getMessage()
218
+ for rec in caplog.records
219
+ )
220
+
152
221
 
153
222
  # // ========================================( Timer Skipped )======================================== // #
154
223
 
@@ -232,6 +301,28 @@ class TestAutoDeferErrorHandling:
232
301
  # Timer should not crash
233
302
  await view._auto_defer_timer(interaction)
234
303
 
304
+ async def test_safe_defer_bounds_a_stalled_ack(self):
305
+ """``_safe_defer`` cancels a stalled defer at ``auto_defer_delay`` and
306
+ swallows the timeout, so a hung Discord ack endpoint cannot pin the
307
+ interaction lock on the socket lifetime.
308
+ """
309
+ view = StatefulView(interaction=_make_interaction())
310
+ view.auto_defer_delay = 0.05
311
+ view.owner_only = False
312
+
313
+ interaction = _make_interaction(is_done=False)
314
+
315
+ async def stall(*args, **kwargs):
316
+ await asyncio.sleep(60)
317
+
318
+ interaction.response.defer = AsyncMock(side_effect=stall)
319
+
320
+ before = time.monotonic()
321
+ await view._safe_defer(interaction) # must return, not hang
322
+ elapsed = time.monotonic() - before
323
+
324
+ assert elapsed < 2.0 # bounded by auto_defer_delay, not the 60s stall
325
+
235
326
  async def test_callback_error_still_triggers_on_error(self):
236
327
  """When the callback raises, on_error is called even with auto-defer active."""
237
328
  view = StatefulView(interaction=_make_interaction())