pycascadeui 3.2.0__tar.gz → 3.3.1__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 (136) hide show
  1. {pycascadeui-3.2.0/pycascadeui.egg-info → pycascadeui-3.3.1}/PKG-INFO +9 -4
  2. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/README.md +5 -3
  3. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/__init__.py +8 -3
  4. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/__init__.py +4 -1
  5. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/patterns/__init__.py +2 -0
  6. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/patterns/v2.py +91 -14
  7. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/selects.py +14 -4
  8. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/types.py +18 -0
  9. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/__init__.py +5 -0
  10. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/backends/__init__.py +9 -0
  11. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/backends/memory.py +40 -1
  12. pycascadeui-3.3.1/cascadeui/persistence/backends/postgres.py +847 -0
  13. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/backends/sqlite.py +204 -7
  14. pycascadeui-3.3.1/cascadeui/persistence/protocols.py +301 -0
  15. pycascadeui-3.3.1/cascadeui/persistence/schema_postgres.py +125 -0
  16. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/__init__.py +3 -1
  17. pycascadeui-3.3.1/cascadeui/utils/fetch.py +73 -0
  18. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/_navigation.py +6 -0
  19. pycascadeui-3.3.1/cascadeui/views/_placement.py +438 -0
  20. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/base.py +55 -0
  21. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/layout.py +40 -2
  22. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/paginated.py +18 -2
  23. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/roles.py +1 -1
  24. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/persistent.py +11 -0
  25. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/view.py +23 -1
  26. {pycascadeui-3.2.0 → pycascadeui-3.3.1/pycascadeui.egg-info}/PKG-INFO +9 -4
  27. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/pycascadeui.egg-info/SOURCES.txt +9 -1
  28. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/pycascadeui.egg-info/requires.txt +4 -0
  29. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/pyproject.toml +5 -1
  30. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_auto_defer.py +9 -6
  31. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_backends.py +32 -21
  32. pycascadeui-3.3.1/tests/test_backends_postgres.py +426 -0
  33. pycascadeui-3.3.1/tests/test_backends_raw_sql.py +442 -0
  34. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_computed.py +1 -0
  35. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_ephemeral_refresh.py +9 -8
  36. pycascadeui-3.3.1/tests/test_fetch.py +146 -0
  37. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_five_pillars.py +4 -11
  38. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_hooks.py +1 -0
  39. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_layout_view.py +64 -1
  40. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_menu_patterns.py +11 -1
  41. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_message_cleanup.py +5 -1
  42. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_navigation_artifact_restore.py +0 -1
  43. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_paginated_patterns.py +61 -11
  44. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_pagination.py +52 -0
  45. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_persistence.py +19 -17
  46. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_persistence_middleware.py +6 -19
  47. pycascadeui-3.3.1/tests/test_placement.py +534 -0
  48. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_scoping.py +26 -39
  49. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_selects.py +37 -1
  50. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_state_slots.py +10 -30
  51. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_theming.py +3 -1
  52. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_undo.py +6 -4
  53. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_v2_helpers.py +115 -0
  54. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_validation.py +3 -0
  55. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_view_init.py +91 -2
  56. pycascadeui-3.2.0/cascadeui/persistence/protocols.py +0 -171
  57. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/LICENSE +0 -0
  58. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/__main__.py +0 -0
  59. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/base.py +0 -0
  60. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/buttons.py +0 -0
  61. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/inputs.py +0 -0
  62. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/patterns/v1.py +0 -0
  63. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/v1_composition.py +0 -0
  64. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/components/wrappers.py +0 -0
  65. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/devtools.py +0 -0
  66. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/exceptions.py +0 -0
  67. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/config.py +0 -0
  68. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/manager.py +0 -0
  69. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/migrations.py +0 -0
  70. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/persistence/schema.py +0 -0
  71. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/py.typed +0 -0
  72. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/setup.py +0 -0
  73. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/__init__.py +0 -0
  74. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/actions.py +0 -0
  75. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/computed.py +0 -0
  76. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/middleware/__init__.py +0 -0
  77. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/middleware/logging.py +0 -0
  78. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/middleware/persistence.py +0 -0
  79. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/middleware/undo.py +0 -0
  80. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/reducers.py +0 -0
  81. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/singleton.py +0 -0
  82. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/slots.py +0 -0
  83. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/store.py +0 -0
  84. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/state/types.py +0 -0
  85. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/theming/__init__.py +0 -0
  86. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/theming/context.py +0 -0
  87. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/theming/core.py +0 -0
  88. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/theming/themes.py +0 -0
  89. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/tracing.py +0 -0
  90. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/coercion.py +0 -0
  91. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/decorators.py +0 -0
  92. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/errors.py +0 -0
  93. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/logging.py +0 -0
  94. pycascadeui-3.2.0/cascadeui/utils/helpers.py → pycascadeui-3.3.1/cascadeui/utils/strings.py +0 -0
  95. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/utils/tasks.py +0 -0
  96. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/validation.py +0 -0
  97. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/__init__.py +0 -0
  98. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/_interaction.py +0 -0
  99. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/__init__.py +0 -0
  100. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/form.py +0 -0
  101. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/leaderboard.py +0 -0
  102. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/menu.py +0 -0
  103. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/tabs.py +0 -0
  104. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/types.py +0 -0
  105. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/cascadeui/views/patterns/wizard.py +0 -0
  106. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/pycascadeui.egg-info/dependency_links.txt +0 -0
  107. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/pycascadeui.egg-info/top_level.txt +0 -0
  108. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/setup.cfg +0 -0
  109. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_batching.py +0 -0
  110. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_button_grid.py +0 -0
  111. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_components.py +0 -0
  112. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_devtools.py +0 -0
  113. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_dynamic_persistent_button.py +0 -0
  114. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_emoji_grid.py +0 -0
  115. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_form_patterns.py +0 -0
  116. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_input_coercion.py +0 -0
  117. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_inputs.py +0 -0
  118. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_instance_limit.py +0 -0
  119. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_layout_pagination.py +0 -0
  120. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_layout_patterns.py +0 -0
  121. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_layout_persistent.py +0 -0
  122. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_leaderboard_patterns.py +0 -0
  123. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_middleware.py +0 -0
  124. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_nav_version.py +1 -1
  125. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_navigation.py +0 -0
  126. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_owner_only.py +0 -0
  127. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_persistent_views.py +0 -0
  128. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_pillar_isolation.py +1 -1
  129. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_rebuild_callback.py +0 -0
  130. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_reducers.py +0 -0
  131. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_roles_patterns.py +0 -0
  132. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_state_store.py +0 -0
  133. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_tab_patterns.py +0 -0
  134. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_update_coalescing.py +2 -2
  135. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_wizard_patterns.py +0 -0
  136. {pycascadeui-3.2.0 → pycascadeui-3.3.1}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycascadeui
3
- Version: 3.2.0
3
+ Version: 3.3.1
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
@@ -30,12 +30,15 @@ Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
31
31
  Requires-Dist: black>=26.3; extra == "dev"
32
32
  Requires-Dist: isort>=8.0; extra == "dev"
33
+ Requires-Dist: testcontainers[postgres]>=4.0; extra == "dev"
33
34
  Provides-Extra: docs
34
35
  Requires-Dist: mkdocs>=1.5; extra == "docs"
35
36
  Requires-Dist: mkdocs-material>=9.0; extra == "docs"
36
37
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
37
38
  Provides-Extra: sqlite
38
39
  Requires-Dist: aiosqlite>=0.19; extra == "sqlite"
40
+ Provides-Extra: postgres
41
+ Requires-Dist: asyncpg>=0.30; extra == "postgres"
39
42
  Dynamic: license-file
40
43
 
41
44
  <p align="center">
@@ -149,7 +152,8 @@ pip install pycascadeui
149
152
  Optional dependencies:
150
153
 
151
154
  ```bash
152
- pip install pycascadeui[sqlite]
155
+ pip install pycascadeui[sqlite] # single-process persistence
156
+ pip install pycascadeui[postgres] # multi-process persistence with LISTEN/NOTIFY
153
157
  ```
154
158
 
155
159
  Requirements:
@@ -647,7 +651,7 @@ for row in rows:
647
651
  ### Components
648
652
  - Stateful buttons, selects, and modals with state integration
649
653
  - Select callbacks can opt into a `values` second parameter
650
- - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`
654
+ - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
651
655
  - Grid helpers: `emoji_grid()` and `button_grid()`
652
656
  - Typed modal fields (`text`, `integer`, `float`, `date`) with per-field validation
653
657
  - Declarative `FormSchema` and `WizardSchema` base classes
@@ -674,7 +678,8 @@ for row in rows:
674
678
  ### Persistence
675
679
  - Persistent views that survive bot restarts with automatic message re-attachment
676
680
  - Opt-in per slot via `persistent_slots = (...)` (≈ `redux-persist`)
677
- - Built-in SQLite and in-memory backends; custom backends via capability-flag `Protocol`
681
+ - Built-in SQLite, PostgreSQL, and in-memory backends; custom backends via capability-flag `Protocol`
682
+ - Cross-process scoped invalidation through PostgreSQL `LISTEN`/`NOTIFY` (multi-worker bots)
678
683
  - Named scoped buckets via `scoped_slot` for per-subsystem persistence
679
684
  - Two-namespace model (`registry` and `application`) with per-namespace debounce and retry backoff
680
685
  - Undo and redo via snapshot-based state history (opt in with `enable_undo`)
@@ -109,7 +109,8 @@ pip install pycascadeui
109
109
  Optional dependencies:
110
110
 
111
111
  ```bash
112
- pip install pycascadeui[sqlite]
112
+ pip install pycascadeui[sqlite] # single-process persistence
113
+ pip install pycascadeui[postgres] # multi-process persistence with LISTEN/NOTIFY
113
114
  ```
114
115
 
115
116
  Requirements:
@@ -607,7 +608,7 @@ for row in rows:
607
608
  ### Components
608
609
  - Stateful buttons, selects, and modals with state integration
609
610
  - Select callbacks can opt into a `values` second parameter
610
- - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`
611
+ - V2 builders: `card()`, `stats_card()`, `action_section()`, `toggle_section()`, `image_section()`, `link_section()`, `confirm_section()`, `button_row()`, `cycle_button()`, `toggle_button()`, `tab_nav()`, `key_value()`, `alert()`, `progress_bar()`, `divider()`, `gap()`, `gallery()`, `file_attachment()`
611
612
  - Grid helpers: `emoji_grid()` and `button_grid()`
612
613
  - Typed modal fields (`text`, `integer`, `float`, `date`) with per-field validation
613
614
  - Declarative `FormSchema` and `WizardSchema` base classes
@@ -634,7 +635,8 @@ for row in rows:
634
635
  ### Persistence
635
636
  - Persistent views that survive bot restarts with automatic message re-attachment
636
637
  - Opt-in per slot via `persistent_slots = (...)` (≈ `redux-persist`)
637
- - Built-in SQLite and in-memory backends; custom backends via capability-flag `Protocol`
638
+ - Built-in SQLite, PostgreSQL, and in-memory backends; custom backends via capability-flag `Protocol`
639
+ - Cross-process scoped invalidation through PostgreSQL `LISTEN`/`NOTIFY` (multi-worker bots)
638
640
  - Named scoped buckets via `scoped_slot` for per-subsystem persistence
639
641
  - Two-namespace model (`registry` and `application`) with per-namespace debounce and retry backoff
640
642
  - Undo and redo via snapshot-based state history (opt in with `enable_undo`)
@@ -28,6 +28,7 @@ from .components.patterns import (
28
28
  cycle_button,
29
29
  divider,
30
30
  emoji_grid,
31
+ file_attachment,
31
32
  gallery,
32
33
  gap,
33
34
  image_section,
@@ -39,7 +40,7 @@ from .components.patterns import (
39
40
  toggle_button,
40
41
  toggle_section,
41
42
  )
42
- from .components.types import EmojiInput
43
+ from .components.types import EmojiInput, MediaInput
43
44
  from .components.v1_composition import CompositeComponent, get_component, register_component
44
45
  from .components.wrappers import with_confirmation, with_cooldown, with_loading_state
45
46
  from .devtools import DevToolsCog, InspectorView
@@ -82,8 +83,9 @@ from .theming.core import Theme, get_default_theme, get_theme, register_theme, s
82
83
  from .theming.themes import dark_theme, default_theme, light_theme
83
84
  from .utils.decorators import cascade_component, cascade_reducer
84
85
  from .utils.errors import safe_execute, with_error_boundary, with_retry
85
- from .utils.helpers import slugify
86
+ from .utils.fetch import fetch_as_file
86
87
  from .utils.logging import setup_logging
88
+ from .utils.strings import slugify
87
89
  from .utils.tasks import get_task_manager
88
90
  from .validation import (
89
91
  ValidationResult,
@@ -130,7 +132,7 @@ if "SQLiteBackend" in _persistence_all:
130
132
  # // ========================================( Script )======================================== // #
131
133
 
132
134
 
133
- __version__ = "3.2.0"
135
+ __version__ = "3.3.1"
134
136
 
135
137
  # Export public API
136
138
  __all__ = [
@@ -197,6 +199,7 @@ __all__ = [
197
199
  "with_cooldown",
198
200
  # Type aliases
199
201
  "EmojiInput",
202
+ "MediaInput",
200
203
  # V2 Cards & Sections
201
204
  "card",
202
205
  "action_section",
@@ -220,6 +223,7 @@ __all__ = [
220
223
  "tab_nav",
221
224
  # V2 Media
222
225
  "gallery",
226
+ "file_attachment",
223
227
  # V2 Grids
224
228
  "EmojiGrid",
225
229
  "emoji_grid",
@@ -277,6 +281,7 @@ __all__ = [
277
281
  "cascade_reducer",
278
282
  "cascade_component",
279
283
  "slugify",
284
+ "fetch_as_file",
280
285
  # DevTools
281
286
  "InspectorView",
282
287
  "DevToolsCog",
@@ -23,6 +23,7 @@ from .patterns import (
23
23
  confirm_section,
24
24
  cycle_button,
25
25
  divider,
26
+ file_attachment,
26
27
  gallery,
27
28
  gap,
28
29
  image_section,
@@ -35,7 +36,7 @@ from .patterns import (
35
36
  toggle_section,
36
37
  )
37
38
  from .selects import ChannelSelect, Dropdown, MentionableSelect, RoleSelect, UserSelect
38
- from .types import EmojiInput
39
+ from .types import EmojiInput, MediaInput
39
40
  from .v1_composition import CompositeComponent, get_component, register_component
40
41
  from .wrappers import with_confirmation, with_cooldown, with_loading_state
41
42
 
@@ -102,6 +103,8 @@ __all__ = [
102
103
  "tab_nav",
103
104
  # V2 media
104
105
  "gallery",
106
+ "file_attachment",
105
107
  # Type aliases
106
108
  "EmojiInput",
109
+ "MediaInput",
107
110
  ]
@@ -13,6 +13,7 @@ from .v2 import (
13
13
  cycle_button,
14
14
  divider,
15
15
  emoji_grid,
16
+ file_attachment,
16
17
  gallery,
17
18
  gap,
18
19
  image_section,
@@ -57,6 +58,7 @@ __all__ = [
57
58
  "tab_nav",
58
59
  # V2 media
59
60
  "gallery",
61
+ "file_attachment",
60
62
  # V2 grids
61
63
  "EmojiGrid",
62
64
  "emoji_grid",
@@ -9,6 +9,7 @@ from discord.enums import SeparatorSpacing
9
9
  from discord.ui import (
10
10
  ActionRow,
11
11
  Container,
12
+ File,
12
13
  MediaGallery,
13
14
  Section,
14
15
  Separator,
@@ -17,11 +18,33 @@ from discord.ui import (
17
18
  )
18
19
 
19
20
  from ..base import StatefulButton
20
- from ..types import EmojiInput
21
+ from ..types import EmojiInput, MediaInput
21
22
 
22
23
  # Discord content-length ceiling for a single TextDisplay component.
23
24
  _TEXTDISPLAY_MAX_CHARS = 4000
24
25
 
26
+
27
+ def _coerce_media_ref(value: MediaInput) -> str:
28
+ """Resolve a ``MediaInput`` to the URL string Discord's API consumes.
29
+
30
+ Strings pass through unchanged. A :class:`discord.File` resolves to
31
+ its ``.uri`` (the ``"attachment://<filename>"`` reference built from
32
+ the normalized filename). The builder emits only the reference
33
+ string; the bytes travel separately through
34
+ ``view.send(files=[...])``.
35
+
36
+ Only the ``.uri`` is extracted -- the ``description`` and ``spoiler``
37
+ attributes of the source :class:`discord.File` are NOT forwarded.
38
+ Builders that wrap the reference in a component carrying those
39
+ fields (``image_section``, ``file_attachment``) accept them as
40
+ explicit kwargs, so the metadata is never silently lost on the
41
+ documented call paths.
42
+ """
43
+ if isinstance(value, discord.File):
44
+ return value.uri
45
+ return value
46
+
47
+
25
48
  # Regional indicator emoji for alpha axis preset (🇦 through 🇿, 26 glyphs).
26
49
  _ALPHA_LABELS = [chr(0x1F1E6 + i) for i in range(26)]
27
50
 
@@ -181,7 +204,7 @@ def toggle_section(
181
204
  def image_section(
182
205
  text: str,
183
206
  *,
184
- url: str,
207
+ url: MediaInput,
185
208
  description: Optional[str] = None,
186
209
  spoiler: bool = False,
187
210
  ) -> Section:
@@ -192,7 +215,11 @@ def image_section(
192
215
 
193
216
  Args:
194
217
  text: Display text (supports markdown).
195
- url: Image URL for the thumbnail.
218
+ url: Image reference for the thumbnail. Accepts either a URL
219
+ string (remote or ``attachment://`` form) or a
220
+ :class:`discord.File` instance whose ``.uri`` is used.
221
+ File-backed references require the same ``discord.File`` to
222
+ be passed via ``view.send(files=[...])``.
196
223
  description: Optional alt text for the thumbnail (up to 256 chars).
197
224
  spoiler: Whether the thumbnail is hidden behind a spoiler.
198
225
 
@@ -206,7 +233,7 @@ def image_section(
206
233
  url=member.display_avatar.url,
207
234
  )
208
235
  """
209
- kwargs = {"media": url, "spoiler": spoiler}
236
+ kwargs = {"media": _coerce_media_ref(url), "spoiler": spoiler}
210
237
  if description is not None:
211
238
  kwargs["description"] = description
212
239
  return Section(
@@ -790,18 +817,22 @@ def tab_nav(
790
817
 
791
818
 
792
819
  def gallery(
793
- *urls: str,
820
+ *media: MediaInput,
794
821
  descriptions: Optional[Sequence[Optional[str]]] = None,
795
822
  ) -> MediaGallery:
796
- """Build a MediaGallery from image URLs.
823
+ """Build a MediaGallery from image references.
797
824
 
798
825
  Simplifies the ``MediaGallery(MediaGalleryItem(...), ...)`` nesting
799
- into a flat call with URLs.
826
+ into a flat call.
800
827
 
801
828
  Args:
802
- *urls: Image URLs (up to 10).
829
+ *media: Image references (up to 10). Each accepts either a URL
830
+ string (remote or ``attachment://`` form) or a
831
+ :class:`discord.File` instance whose ``.uri`` is used.
832
+ File-backed references require the same ``discord.File``
833
+ objects to be passed via ``view.send(files=[...])``.
803
834
  descriptions: Optional sequence of descriptions matching each
804
- URL positionally. Use ``None`` for items without a
835
+ reference positionally. Use ``None`` for items without a
805
836
  description.
806
837
 
807
838
  Returns:
@@ -815,23 +846,69 @@ def gallery(
815
846
  descriptions=["First image", None],
816
847
  )
817
848
  """
818
- if descriptions is not None and len(descriptions) != len(urls):
849
+ if not media:
850
+ raise ValueError(
851
+ "gallery: at least one media reference is required. Discord "
852
+ "rejects empty MediaGallery components with HTTP 400 (1-10 "
853
+ "items required)."
854
+ )
855
+ if len(media) > 10:
856
+ raise ValueError(
857
+ f"gallery: too many media references ({len(media)}). Discord "
858
+ f"caps MediaGallery at 10 items. Split into multiple gallery() "
859
+ f"calls."
860
+ )
861
+ if descriptions is not None and len(descriptions) != len(media):
819
862
  raise ValueError(
820
863
  f"gallery: descriptions length ({len(descriptions)}) must match "
821
- f"urls length ({len(urls)}). Pad with None for URLs that should "
822
- f"have no description."
864
+ f"media length ({len(media)}). Pad with None for references that "
865
+ f"should have no description."
823
866
  )
824
867
 
825
868
  items = []
826
- for i, url in enumerate(urls):
869
+ for i, ref in enumerate(media):
827
870
  desc = descriptions[i] if descriptions is not None else None
828
- kwargs = {"media": url}
871
+ kwargs = {"media": _coerce_media_ref(ref)}
829
872
  if desc is not None:
830
873
  kwargs["description"] = desc
831
874
  items.append(MediaGalleryItem(**kwargs))
832
875
  return MediaGallery(*items)
833
876
 
834
877
 
878
+ def file_attachment(
879
+ url: MediaInput,
880
+ *,
881
+ spoiler: bool = False,
882
+ ) -> File:
883
+ """Build a File component for attachment display.
884
+
885
+ Completes the V2 media family alongside ``gallery()``. Discord
886
+ renders the file as a downloadable card inline with the rest of the
887
+ V2 content.
888
+
889
+ Args:
890
+ url: File reference. Accepts either a remote URL, the
891
+ ``attachment://<filename>`` form, or a
892
+ :class:`discord.File` instance whose ``.uri`` is used. When
893
+ a file-backed reference is supplied, the same
894
+ ``discord.File`` must travel via ``view.send(files=[...])``.
895
+ spoiler: Whether to flag the file as a spoiler.
896
+
897
+ Returns:
898
+ A ``File`` component ready to be added to a Container or
899
+ ``StatefulLayoutView``.
900
+
901
+ Example::
902
+
903
+ card(
904
+ "## Quarterly Report",
905
+ file_attachment("attachment://q1_2026.pdf"),
906
+ "Released April 15.",
907
+ )
908
+ """
909
+ return File(media=_coerce_media_ref(url), spoiler=spoiler)
910
+
911
+
835
912
  # // ========================================( Grids )======================================== // #
836
913
 
837
914
 
@@ -37,7 +37,12 @@ def _wrap_default_values(
37
37
  out.append(value)
38
38
  continue
39
39
  snowflake_id = coerce_snowflake_id(value)
40
- out.append(discord.SelectDefaultValue(id=snowflake_id, type=default_type))
40
+ # SelectDefaultValue stores type verbatim; a bare string breaks to_dict().
41
+ out.append(
42
+ discord.SelectDefaultValue(
43
+ id=snowflake_id, type=discord.SelectDefaultValueType[default_type]
44
+ )
45
+ )
41
46
  return out
42
47
 
43
48
 
@@ -63,15 +68,20 @@ def _wrap_mentionable_defaults(
63
68
  if isinstance(value, discord.SelectDefaultValue):
64
69
  out.append(value)
65
70
  elif isinstance(value, discord.Role):
66
- out.append(discord.SelectDefaultValue(id=value.id, type="role"))
71
+ out.append(
72
+ discord.SelectDefaultValue(id=value.id, type=discord.SelectDefaultValueType.role)
73
+ )
67
74
  elif isinstance(value, (discord.Member, discord.User)):
68
- out.append(discord.SelectDefaultValue(id=value.id, type="user"))
75
+ out.append(
76
+ discord.SelectDefaultValue(id=value.id, type=discord.SelectDefaultValueType.user)
77
+ )
69
78
  else:
70
79
  raise TypeError(
71
80
  f"MentionableSelect default values must be Member, User, Role, "
72
81
  f"or SelectDefaultValue (got {type(value).__name__}: {value!r}). "
73
82
  f"Raw int IDs cannot be auto-typed; construct "
74
- f"discord.SelectDefaultValue(id=..., type='user' or 'role') "
83
+ f"discord.SelectDefaultValue(id=..., "
84
+ f"type=discord.SelectDefaultValueType.user or .role) "
75
85
  f"explicitly for those."
76
86
  )
77
87
  return out
@@ -23,3 +23,21 @@ A live :class:`discord.Emoji` (returned by ``bot.get_emoji`` or
23
23
  ``bot.fetch_application_emoji``) and a :class:`discord.PartialEmoji`
24
24
  instance are also accepted directly.
25
25
  """
26
+
27
+
28
+ MediaInput = Union[str, discord.File]
29
+ """Anything CascadeUI accepts where a media reference is required.
30
+
31
+ Mirrors the union accepted by :class:`discord.ui.MediaGallery`,
32
+ :class:`discord.ui.Thumbnail`, and :class:`discord.ui.File`. Two forms:
33
+
34
+ * A URL string -- either an arbitrary remote URL
35
+ (``"https://cdn.example.com/img.png"``) or the
36
+ ``"attachment://<filename>"`` reference scheme for files uploaded
37
+ alongside the same message.
38
+ * A :class:`discord.File` instance, in which case the underlying
39
+ ``.uri`` (``"attachment://<filename>"``) is used. The same file
40
+ object must also be passed via ``view.send(files=[...])`` (or
41
+ ``refresh(attachments=[...])`` for in-place swaps) so the bytes
42
+ travel with the message.
43
+ """
@@ -29,3 +29,8 @@ if "SQLiteBackend" in _backends_all:
29
29
  from .backends import SQLiteBackend # noqa: F401
30
30
 
31
31
  __all__.append("SQLiteBackend")
32
+
33
+ if "PostgresBackend" in _backends_all:
34
+ from .backends import PostgresBackend # noqa: F401
35
+
36
+ __all__.append("PostgresBackend")
@@ -17,3 +17,12 @@ try:
17
17
  except ImportError as _e:
18
18
  if "aiosqlite" not in str(_e):
19
19
  _logger.warning(f"Failed to import SQLiteBackend: {_e}")
20
+
21
+ # Optional backend -- imported lazily to avoid a hard dependency on asyncpg
22
+ try:
23
+ from .postgres import PostgresBackend
24
+
25
+ __all__.append("PostgresBackend")
26
+ except ImportError as _e:
27
+ if "asyncpg" not in str(_e):
28
+ _logger.warning(f"Failed to import PostgresBackend: {_e}")
@@ -45,9 +45,17 @@ class InMemoryBackend:
45
45
  """
46
46
 
47
47
  capabilities: ClassVar[Capability] = (
48
- Capability.KV | Capability.RELATIONAL | Capability.TTL_INDEX | Capability.SCHEMA_META
48
+ Capability.KV
49
+ | Capability.RELATIONAL
50
+ | Capability.TTL_INDEX
51
+ | Capability.SCHEMA_META
52
+ # Capability.RAW_SQL deliberately omitted -- in-memory storage
53
+ # has no SQL engine to escape to. Code paths that require raw SQL
54
+ # must check `Capability.RAW_SQL in backend.capabilities` first.
49
55
  )
50
56
 
57
+ placeholder_style: ClassVar[str] = "n/a"
58
+
51
59
  def __init__(self) -> None:
52
60
  self._kv: dict[str, dict[str, bytes]] = {}
53
61
  self._rows: dict[str, list[dict[str, Any]]] = {}
@@ -155,3 +163,34 @@ class InMemoryBackend:
155
163
 
156
164
  async def set_schema_version(self, table: str, version: int) -> None:
157
165
  self._schema_versions[table] = version
166
+
167
+ # // ========================================( Raw SQL opt-out stubs )======================================== // #
168
+
169
+ # Capability.RAW_SQL is deliberately not declared on this backend --
170
+ # in-memory storage has no SQL engine to escape to. The five methods
171
+ # below raise NotImplementedError with a clear remediation message
172
+ # rather than producing AttributeError, so callers who reach for the
173
+ # escape hatch against InMemoryBackend get a directed error pointing
174
+ # them at SQLiteBackend or PostgresBackend.
175
+
176
+ _RAW_SQL_ERROR: ClassVar[str] = (
177
+ "InMemoryBackend does not support Capability.RAW_SQL. "
178
+ "Use SQLiteBackend or PostgresBackend for raw-SQL operations, "
179
+ "or check `Capability.RAW_SQL in backend.capabilities` before "
180
+ "reaching for the escape hatch."
181
+ )
182
+
183
+ async def execute(self, sql: str, *params: Any) -> int:
184
+ raise NotImplementedError(self._RAW_SQL_ERROR)
185
+
186
+ async def fetch(self, sql: str, *params: Any) -> list[dict[str, Any]]:
187
+ raise NotImplementedError(self._RAW_SQL_ERROR)
188
+
189
+ async def executemany(self, sql: str, params_list: list[tuple]) -> int:
190
+ raise NotImplementedError(self._RAW_SQL_ERROR)
191
+
192
+ async def fetch_one(self, sql: str, *params: Any) -> dict[str, Any] | None:
193
+ raise NotImplementedError(self._RAW_SQL_ERROR)
194
+
195
+ def transaction(self) -> Any:
196
+ raise NotImplementedError(self._RAW_SQL_ERROR)