simcord 2.2.0__tar.gz → 2.3.0__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 (260) hide show
  1. {simcord-2.2.0 → simcord-2.3.0}/CHANGELOG.md +24 -0
  2. {simcord-2.2.0 → simcord-2.3.0}/CONTRIBUTING.md +10 -0
  3. {simcord-2.2.0 → simcord-2.3.0}/PKG-INFO +1 -1
  4. {simcord-2.2.0 → simcord-2.3.0}/docs/concepts.md +4 -1
  5. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/diagnostics.md +28 -9
  6. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/fixtures.md +14 -0
  7. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/interactions.md +20 -0
  8. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/messages.md +29 -3
  9. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/preview.md +9 -0
  10. {simcord-2.2.0 → simcord-2.3.0}/docs/stability.md +33 -4
  11. {simcord-2.2.0 → simcord-2.3.0}/pyproject.toml +1 -1
  12. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/_dpy_internals.py +19 -3
  13. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/actors.py +2 -15
  14. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/asserts.py +2 -2
  15. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/channels.py +6 -1
  16. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/messages.py +17 -9
  17. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/components.py +46 -2
  18. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/env.py +35 -8
  19. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/_helpers.py +109 -53
  20. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/channels.py +13 -4
  21. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/interactions.py +8 -7
  22. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/protocol.schema.json +58 -0
  23. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/results.py +38 -4
  24. simcord-2.3.0/tests/integration/test_forum.py +201 -0
  25. simcord-2.3.0/tests/integration/test_message_routes.py +249 -0
  26. simcord-2.3.0/tests/integration/test_migration_bridge.py +339 -0
  27. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_dx.py +44 -0
  28. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_settlement_contract.py +47 -0
  29. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_components_v2_validation.py +38 -0
  30. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_dpy_internals.py +72 -0
  31. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_pytest_plugin.py +6 -8
  32. {simcord-2.2.0 → simcord-2.3.0}/uv.lock +1 -1
  33. simcord-2.2.0/tests/integration/test_forum.py +0 -79
  34. simcord-2.2.0/tests/integration/test_message_routes.py +0 -94
  35. simcord-2.2.0/tests/unit/test_version.py +0 -13
  36. {simcord-2.2.0 → simcord-2.3.0}/.github/ISSUE_TEMPLATE/bug.md +0 -0
  37. {simcord-2.2.0 → simcord-2.3.0}/.github/ISSUE_TEMPLATE/feature.md +0 -0
  38. {simcord-2.2.0 → simcord-2.3.0}/.github/ISSUE_TEMPLATE/parity-gap.md +0 -0
  39. {simcord-2.2.0 → simcord-2.3.0}/.github/dependabot.yml +0 -0
  40. {simcord-2.2.0 → simcord-2.3.0}/.github/workflows/ci.yml +0 -0
  41. {simcord-2.2.0 → simcord-2.3.0}/.github/workflows/dpy-master.yml +0 -0
  42. {simcord-2.2.0 → simcord-2.3.0}/.github/workflows/release.yml +0 -0
  43. {simcord-2.2.0 → simcord-2.3.0}/.github/workflows/verify.yml +0 -0
  44. {simcord-2.2.0 → simcord-2.3.0}/.gitignore +0 -0
  45. {simcord-2.2.0 → simcord-2.3.0}/.pre-commit-config.yaml +0 -0
  46. {simcord-2.2.0 → simcord-2.3.0}/.python-version +0 -0
  47. {simcord-2.2.0 → simcord-2.3.0}/.readthedocs.yaml +0 -0
  48. {simcord-2.2.0 → simcord-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  49. {simcord-2.2.0 → simcord-2.3.0}/LICENSE +0 -0
  50. {simcord-2.2.0 → simcord-2.3.0}/README.md +0 -0
  51. {simcord-2.2.0 → simcord-2.3.0}/SECURITY.md +0 -0
  52. {simcord-2.2.0 → simcord-2.3.0}/benchmarks/_helpers.py +0 -0
  53. {simcord-2.2.0 → simcord-2.3.0}/benchmarks/test_dispatch_benchmarks.py +0 -0
  54. {simcord-2.2.0 → simcord-2.3.0}/benchmarks/test_perf_guards.py +0 -0
  55. {simcord-2.2.0 → simcord-2.3.0}/changes/README.md +0 -0
  56. {simcord-2.2.0 → simcord-2.3.0}/docs/api.md +0 -0
  57. {simcord-2.2.0 → simcord-2.3.0}/docs/architecture.md +0 -0
  58. {simcord-2.2.0 → simcord-2.3.0}/docs/assets/simcord-terminal-demo.gif +0 -0
  59. {simcord-2.2.0 → simcord-2.3.0}/docs/assets/simcord-transparent.webp +0 -0
  60. {simcord-2.2.0 → simcord-2.3.0}/docs/assets/social-card.png +0 -0
  61. {simcord-2.2.0 → simcord-2.3.0}/docs/changelog.md +0 -0
  62. {simcord-2.2.0 → simcord-2.3.0}/docs/cookbook.md +0 -0
  63. {simcord-2.2.0 → simcord-2.3.0}/docs/faq.md +0 -0
  64. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/ai-coding-agents.md +0 -0
  65. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/components.md +0 -0
  66. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/intents.md +0 -0
  67. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/mocks-vs-simulation.md +0 -0
  68. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/moderation-and-guild.md +0 -0
  69. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/permissions.md +0 -0
  70. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/test-without-token.md +0 -0
  71. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/testing-discord-py-bots.md +0 -0
  72. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/testing-slash-commands.md +0 -0
  73. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/threads-reactions-dms.md +0 -0
  74. {simcord-2.2.0 → simcord-2.3.0}/docs/guides/time-control.md +0 -0
  75. {simcord-2.2.0 → simcord-2.3.0}/docs/index.md +0 -0
  76. {simcord-2.2.0 → simcord-2.3.0}/docs/installation.md +0 -0
  77. {simcord-2.2.0 → simcord-2.3.0}/docs/llms.txt +0 -0
  78. {simcord-2.2.0 → simcord-2.3.0}/docs/parity-matrix.md +0 -0
  79. {simcord-2.2.0 → simcord-2.3.0}/docs/performance.md +0 -0
  80. {simcord-2.2.0 → simcord-2.3.0}/docs/quickstart.md +0 -0
  81. {simcord-2.2.0 → simcord-2.3.0}/docs/robots.txt +0 -0
  82. {simcord-2.2.0 → simcord-2.3.0}/examples/README.md +0 -0
  83. {simcord-2.2.0 → simcord-2.3.0}/examples/__init__.py +0 -0
  84. {simcord-2.2.0 → simcord-2.3.0}/examples/bot.py +0 -0
  85. {simcord-2.2.0 → simcord-2.3.0}/examples/conftest.py +0 -0
  86. {simcord-2.2.0 → simcord-2.3.0}/examples/preview_example.py +0 -0
  87. {simcord-2.2.0 → simcord-2.3.0}/examples/test_bot.py +0 -0
  88. {simcord-2.2.0 → simcord-2.3.0}/mkdocs.yml +0 -0
  89. {simcord-2.2.0 → simcord-2.3.0}/overrides/main.html +0 -0
  90. {simcord-2.2.0 → simcord-2.3.0}/scripts/capture_visual_reference.py +0 -0
  91. {simcord-2.2.0 → simcord-2.3.0}/scripts/check_discoverability.py +0 -0
  92. {simcord-2.2.0 → simcord-2.3.0}/scripts/check_package.py +0 -0
  93. {simcord-2.2.0 → simcord-2.3.0}/scripts/compare_visual_reference.py +0 -0
  94. {simcord-2.2.0 → simcord-2.3.0}/scripts/discord_reference_bot.py +0 -0
  95. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/__init__.py +0 -0
  96. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/__init__.py +0 -0
  97. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/access.py +0 -0
  98. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/cdn.py +0 -0
  99. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/errors.py +0 -0
  100. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/__init__.py +0 -0
  101. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/auditlog.py +0 -0
  102. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/automod.py +0 -0
  103. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/channel.py +0 -0
  104. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/expression.py +0 -0
  105. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/guild.py +0 -0
  106. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/interaction.py +0 -0
  107. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/invite.py +0 -0
  108. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/member.py +0 -0
  109. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/message.py +0 -0
  110. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/role.py +0 -0
  111. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/scheduled_event.py +0 -0
  112. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/stage_instance.py +0 -0
  113. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/user.py +0 -0
  114. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/voice.py +0 -0
  115. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/models/webhook.py +0 -0
  116. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/__init__.py +0 -0
  117. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/audit.py +0 -0
  118. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/base.py +0 -0
  119. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/commands.py +0 -0
  120. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/expressions.py +0 -0
  121. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/guilds.py +0 -0
  122. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/invites.py +0 -0
  123. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/permissions.py +0 -0
  124. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/polls.py +0 -0
  125. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/reactions.py +0 -0
  126. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/scheduled_events.py +0 -0
  127. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/voice.py +0 -0
  128. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/ops/webhooks.py +0 -0
  129. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/permissions.py +0 -0
  130. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/serializers.py +0 -0
  131. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/backend/state.py +0 -0
  132. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/builders.py +0 -0
  133. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/enums.py +0 -0
  134. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/gateway.py +0 -0
  135. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/__init__.py +0 -0
  136. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/client.py +0 -0
  137. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/router.py +0 -0
  138. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/__init__.py +0 -0
  139. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/application.py +0 -0
  140. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/auditlogs.py +0 -0
  141. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/automod.py +0 -0
  142. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/commands.py +0 -0
  143. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/expressions.py +0 -0
  144. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/guilds.py +0 -0
  145. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/invites.py +0 -0
  146. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/messages.py +0 -0
  147. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/reactions.py +0 -0
  148. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/scheduled_events.py +0 -0
  149. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/http/routes/stage_instances.py +0 -0
  150. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/intents.py +0 -0
  151. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/interactions.py +0 -0
  152. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/parity.py +0 -0
  153. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/__init__.py +0 -0
  154. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_actions.py +0 -0
  155. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_assets.py +0 -0
  156. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_capture.py +0 -0
  157. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_markdown.py +0 -0
  158. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_media.py +0 -0
  159. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_pages.py +0 -0
  160. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_server.py +0 -0
  161. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/_snapshot.py +0 -0
  162. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/app.js +0 -0
  163. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/components.js +0 -0
  164. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto-Arabic.txt +0 -0
  165. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto-CJK.txt +0 -0
  166. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto-Devanagari.txt +0 -0
  167. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto-Emoji.txt +0 -0
  168. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto-Hebrew.txt +0 -0
  169. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/OFL-1.1-Noto.txt +0 -0
  170. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/manifest.json +0 -0
  171. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-color-emoji-v2.051.ttf +0 -0
  172. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-arabic-v2.012.ttf +0 -0
  173. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-devanagari-v2.007.ttf +0 -0
  174. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-hebrew-v3.001.ttf +0 -0
  175. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-latin-italic-v2.015.ttf +0 -0
  176. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-latin-v2.015.ttf +0 -0
  177. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-mono-v2.014.ttf +0 -0
  178. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/fonts/noto-sans-sc-v2.004.ttf +0 -0
  179. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/index.html +0 -0
  180. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/preview/static/preview.css +0 -0
  181. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/py.typed +0 -0
  182. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/pytest_plugin.py +0 -0
  183. {simcord-2.2.0 → simcord-2.3.0}/src/simcord/types.py +0 -0
  184. {simcord-2.2.0 → simcord-2.3.0}/tests/conformance/_world.py +0 -0
  185. {simcord-2.2.0 → simcord-2.3.0}/tests/conformance/test_field_honesty.py +0 -0
  186. {simcord-2.2.0 → simcord-2.3.0}/tests/conformance/test_field_honesty_fuzz.py +0 -0
  187. {simcord-2.2.0 → simcord-2.3.0}/tests/conformance/test_route_coverage.py +0 -0
  188. {simcord-2.2.0 → simcord-2.3.0}/tests/conformance/test_serializers.py +0 -0
  189. {simcord-2.2.0 → simcord-2.3.0}/tests/conftest.py +0 -0
  190. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/__init__.py +0 -0
  191. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/__init__.py +0 -0
  192. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/catalog.py +0 -0
  193. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/coverage.json +0 -0
  194. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/measurements.json +0 -0
  195. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/profiles.json +0 -0
  196. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/preview/states.json +0 -0
  197. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/sample_bot/__init__.py +0 -0
  198. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/sample_bot/events.py +0 -0
  199. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/sample_bot/general.py +0 -0
  200. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/sample_bot/interactions.py +0 -0
  201. {simcord-2.2.0 → simcord-2.3.0}/tests/fixtures/sample_bot/moderation.py +0 -0
  202. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/preview_helpers.py +0 -0
  203. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_app_commands.py +0 -0
  204. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_application_emojis.py +0 -0
  205. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_asserts.py +0 -0
  206. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_audit_logs.py +0 -0
  207. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_automod.py +0 -0
  208. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_bot_users_and_webhooks.py +0 -0
  209. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_channels.py +0 -0
  210. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_command_permissions.py +0 -0
  211. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_common_bot_sweep.py +0 -0
  212. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_components_v2_interactions.py +0 -0
  213. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_components_v2_messages.py +0 -0
  214. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_coverage_sweep.py +0 -0
  215. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_diagnostics.py +0 -0
  216. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_edge_cases.py +0 -0
  217. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_error_capture_and_edits.py +0 -0
  218. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_expressions.py +0 -0
  219. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_gateway_and_automod_exempt.py +0 -0
  220. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_guilds.py +0 -0
  221. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_intents.py +0 -0
  222. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_interaction_followups.py +0 -0
  223. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_interactions.py +0 -0
  224. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_invites.py +0 -0
  225. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_lifecycle.py +0 -0
  226. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_members.py +0 -0
  227. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_messages.py +0 -0
  228. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_misc_branches.py +0 -0
  229. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_moderation.py +0 -0
  230. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_polls.py +0 -0
  231. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview.py +0 -0
  232. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_additional.py +0 -0
  233. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_capture.py +0 -0
  234. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_capture_dx.py +0 -0
  235. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_coverage.py +0 -0
  236. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_edges.py +0 -0
  237. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_fixes.py +0 -0
  238. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_fonts.py +0 -0
  239. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_preview_security.py +0 -0
  240. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_reactions.py +0 -0
  241. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_roles_and_ordering.py +0 -0
  242. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_scheduled_events.py +0 -0
  243. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_setup_hook_suspension.py +0 -0
  244. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_sharding.py +0 -0
  245. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_stage_instances.py +0 -0
  246. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_threads.py +0 -0
  247. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_time.py +0 -0
  248. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_transport_edges.py +0 -0
  249. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_voice_state.py +0 -0
  250. {simcord-2.2.0 → simcord-2.3.0}/tests/integration/test_world_helpers.py +0 -0
  251. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_assert_helpers.py +0 -0
  252. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_backend_lookup_errors.py +0 -0
  253. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_errors.py +0 -0
  254. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_intent_rules.py +0 -0
  255. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_lazy_import.py +0 -0
  256. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_parity.py +0 -0
  257. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_parity_tooling.py +0 -0
  258. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_permission_validators.py +0 -0
  259. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_permissions.py +0 -0
  260. {simcord-2.2.0 → simcord-2.3.0}/tests/unit/test_router.py +0 -0
@@ -4,6 +4,30 @@ This changelog is generated with [towncrier](https://towncrier.readthedocs.io/).
4
4
 
5
5
  <!-- towncrier release notes start -->
6
6
 
7
+ ## 2.3.0 (2026-10-05)
8
+
9
+ ### Features
10
+
11
+ - Add the temporary `future_behavior=False` migration bridge in SimCord 2.3. Opt in with `simcord.run(bot, future_behavior=True)` or the pytest marker to acknowledge only the current error prefix and detach nested mutable result observations. Legacy live error lists, lifetime acknowledgement and payload aliasing remain the defaults, with targeted deprecation warnings. SimCord 3.0 removes the flag and makes the opt-in semantics the defaults. Startup/restart READY and restart guild-replay settlement retain their independent five-second budgets.
12
+
13
+ ### Bug fixes
14
+
15
+ - Describe all seven existing protocol-2 preview actions in the packaged schema, including required targets/controls and envelope fields. Preserve focus defaults, unknown metadata, multipart modal uploads, and runtime authorization/replay checks. Reuse the existing builder operation guard for actors without changing ownership or permissions.
16
+ - Share message validation across HTTP, actors and backend creation/editing: enforce the 10-embed count, combined 6000-character limit and individual embed text/field limits, excluding surrounding whitespace without changing stored text. Reject empty new user/bot messages while retaining attachment-, embed-, component- and poll-only sends, nullable/partial edits and empty interaction deferrals. Previously accepted invalid messages now fail with code 50035, and invalid interaction responses do not consume acknowledgement.
17
+ Interaction update callbacks without a source message now fail before acknowledgement instead of silently succeeding.
18
+
19
+ Prepare forum starter fields, components and every upload read before creating thread/message/CDN state, then publish the existing ordered events only after thread, starter and parent state are complete. Validation or upload-read failures no longer leave orphan posts or gateway events. Preparation consumes upload streams; an earlier successfully read stream must be rewound or replaced if a later read fails. Subscriber exceptions after publication begins are outside this atomicity boundary.
20
+ Starter reference identifiers are also validated before publication, preventing malformed references from corrupting the forum's previous last-post pointer.
21
+ - Recognize shielded Discord waits on Python 3.14.8 without treating the caller captured by asyncio's cancellation cleanup as an additional dependency.
22
+
23
+
24
+ ## 2.2.1 (2026-09-24)
25
+
26
+ ### Bug fixes
27
+
28
+ - Fixed View/Modal expiry recognition for views that never reach the store's dispatch tables — a view whose dispatchable items are all `DynamicItem`s (e.g. a `LayoutView` built only from patterned custom IDs) registered a live expiry task that settlement never recognized, so its `timeout=` blocked actor operations and fired on the wall clock. Expiry tasks are now identified by the coroutine they run (`BaseView.__timeout_task_impl`), which covers every registered view regardless of how the store indexes it.
29
+
30
+
7
31
  ## 2.2.0 (2026-09-23)
8
32
 
9
33
  ### Features
@@ -69,6 +69,16 @@ Maintainers update `pyproject.toml`, then consume fragments with
69
69
  version commit are tagged `vX.Y.Z`; the release workflow verifies the tag,
70
70
  quality gates, documentation, and built metadata before publishing.
71
71
 
72
+ For the 2.3 → 3.0 migration, keep the two release lines separate and verify both
73
+ legacy defaults and `future_behavior=True` before tagging 2.3.
74
+
75
+ The expanded 3.0 preview work additionally requires authorized Discord comparisons
76
+ with complete provenance and dated human accessibility checks. Its reference ledger
77
+ is `tests/fixtures/preview/coverage.json`; blocked or non-comparable rows are not
78
+ passing evidence. These preview-specific requirements do not block the compatible
79
+ 2.3 bridge, which retains the already-released protocol-2 preview without new
80
+ visual fidelity claims.
81
+
72
82
  ## Reporting bugs
73
83
 
74
84
  A failing test using `simcord` is the perfect bug report. If your bot hits an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simcord
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Offline discord.py bot testing with pytest. No network, token, or test server.
5
5
  Project-URL: Homepage, https://github.com/SilentHacks/simcord
6
6
  Project-URL: Documentation, https://simcord.readthedocs.io/
@@ -95,13 +95,16 @@ assert channel.last_message.content == "Pong!" # the reply is already here
95
95
 
96
96
  Bot-owned work stays bot-owned across parking, timeout, cancellation, later actions, startup,
97
97
  and restart. Recognized waits — `Client.wait_for` (including its `timeout=`), View/Modal
98
- completion, store-registered View/Modal expiry timers, `discord.ext.tasks` loop intervals,
98
+ completion, View/Modal expiry timers, `discord.ext.tasks` loop intervals,
99
99
  composed `gather`/`shield`/`wait`/`TaskGroup`, explicit `env.external_wait(...)`, and verified
100
100
  far-future sleeps — may remain. A timer whose only job is resuming a recognized wait — a
101
101
  `wait_for` deadline on a listener, a View/Modal expiry, a loop interval, a wake-up timer
102
102
  scheduled inside `external_wait` — is virtual: it fires only via `env.advance_time()` or the
103
103
  awaited input, never the wall clock. Verified far-future sleeps keep their real fallback, and
104
104
  a View sent with no dispatchable items never registers (its expiry keeps real-time behavior).
105
+ Views whose dispatchable items are all `DynamicItem`s are covered too — expiry is identified
106
+ from the task's coroutine, not the store's dispatch table; `view.wait()` on such a view is not
107
+ recognized, so `await env.external_wait(view.wait(), reason="...")` if a bot awaits one.
105
108
  Unknown waits remain active and time out. Use a reasoned, scoped declaration for external
106
109
  input:
107
110
 
@@ -27,10 +27,17 @@ excluded — a non-command message isn't a bug.)
27
27
  ### Errors fail tests by default
28
28
 
29
29
  !!! warning "Uninspected errors are re-raised at teardown"
30
- If the bot raised errors during a test and the test never inspected them, `simcord.run`
31
- re-raises them as an `ExceptionGroup` when the environment closes — so a swallowed bug
32
- can't slip past a green test. **Reading `env.errors` counts as inspecting**: your
33
- assertions take over from there.
30
+ With the 2.x default, reading `env.errors` acknowledges errors for the environment's
31
+ entire lifetime, including errors that arrive later. A saved list stays live.
32
+ SimCord 2.3 warns on this legacy read. Use `simcord.run(bot, future_behavior=True)`
33
+ to adopt the intended 3.0 behavior now: each read returns a shallow list snapshot and
34
+ acknowledges only the errors captured so far. A later error still fails teardown,
35
+ including one captured during shutdown; previously acknowledged errors are not
36
+ re-raised alongside it. Exception objects and their tracebacks are preserved.
37
+
38
+ The `future_behavior` flag defaults to `False` in 2.3. SimCord 3.0 removes the flag and
39
+ makes snapshot/prefix acknowledgement the default. Saving an empty opt-in snapshot does
40
+ not acknowledge errors that arrive later, and mutating a snapshot cannot change the history.
34
41
 
35
42
  You have three ways to handle this:
36
43
 
@@ -46,9 +53,14 @@ async with simcord.run(bot, check_errors=False) as env:
46
53
  ...
47
54
  ```
48
55
 
49
- `env.raise_errors()` is the one-call way to assert "the bot ran cleanly": it raises an
50
- `ExceptionGroup` of everything captured (even a single error) and does nothing if there were
51
- none.
56
+ `env.raise_errors()` always raises an `ExceptionGroup` of the **entire captured history**
57
+ (even errors acknowledged earlier), or does nothing if there were none. It acknowledges
58
+ only the current prefix in opt-in mode, including an empty prefix; later errors still fail
59
+ teardown. `check_errors=False` disables automatic checking in either mode. Teardown never
60
+ masks an exception already propagating from the test body.
61
+
62
+ `env.error_cursor` and `env.errors_since(cursor)` are non-consuming observations used by
63
+ Preview diagnostics. They do not acknowledge errors or disable teardown checking.
52
64
 
53
65
  ## Assertions
54
66
 
@@ -89,8 +101,8 @@ assert_error(env, discord.Forbidden, code=50013) # matches the wrapped origina
89
101
  assert_no_errors(env) # the symmetric "ran cleanly" check
90
102
  ```
91
103
 
92
- `assert_error` reads `env.errors`, which counts as inspecting them (see below), so it also
93
- satisfies the teardown guard.
104
+ `assert_error` reads `env.errors`, so it acknowledges the same errors as a direct read:
105
+ the whole lifetime by default, or only the current prefix with `future_behavior=True`.
94
106
 
95
107
  ## The transcript
96
108
 
@@ -162,8 +174,15 @@ simcord_env.inject_error("PUT", "/guilds/*/bans/*", status=403, code=50013, time
162
174
 
163
175
  Because failures surface as genuine `discord.Forbidden` / `discord.HTTPException` with real
164
176
  codes, your `except discord.HTTPException:` branches are exercised exactly as in production.
177
+
165
178
  ## Settlement timeouts and recovery
166
179
 
180
+ `settle_timeout` defaults to five seconds for actor operations, `env.settle()`, and the
181
+ pre-restart drain; `env.settle(timeout=...)` overrides that individual settle. Startup
182
+ READY settlement has its own fixed five-second budget, independent of `settle_timeout`.
183
+ Restart uses a fresh fixed five-second READY budget and then a separate fixed five-second
184
+ guild-replay budget. These are settlement budgets, not a timeout on `setup_hook` or login.
185
+
167
186
  `TimeoutError` reports the operation, effective timeout, bot-owned task or callback, wait
168
187
  reason, and any recognized waits left parked. A timeout or cancellation does not release
169
188
  ownership or pretend work completed; state may already have changed. Release the real
@@ -96,9 +96,17 @@ Both `simcord.run(bot, **options)` and the underlying `Env` accept these keyword
96
96
  | --- | --- | --- |
97
97
  | `strict_sync` | `True` | Unsynced app commands can't be invoked — invoking one fails the test, catching forgotten `tree.sync()` calls. Set `False` to auto-register unsynced commands for isolated unit tests. |
98
98
  | `check_errors` | `True` | At teardown, errors the bot raised but the test never inspected are re-raised as an `ExceptionGroup`, so bot bugs can't pass silently. Set `False` to opt out. |
99
+ | `future_behavior` | `False` | Temporary 2.3 bridge: shallow error snapshots with prefix acknowledgement, and detached mutable result payload snapshots. Must be a bool. In 3.0 the flag is removed and these semantics become the defaults. |
99
100
  | `approved_intents` | all | Simulates developer-portal privileged-intent toggles. |
100
101
  | `shard_count` | client setting | Supplies the Get Gateway Bot recommendation when an `AutoShardedClient` does not configure `shard_count` itself. |
101
102
  | `settle_timeout` | `5.0` seconds | Default maximum time for joining runnable bot work; a direct settle `timeout=` overrides it. |
103
+
104
+ `settle_timeout` does not change startup's fixed five-second READY settlement budget.
105
+ Restart drains existing work using `settle_timeout`, then gets a fresh fixed five-second
106
+ READY budget and a separate fixed five-second guild-replay budget. These budgets do not
107
+ bound login or `setup_hook`. `strict_sync=False` remains an independent opt-out; the
108
+ migration bridge does not change command registration.
109
+
102
110
  External waits must be scoped with `await env.external_wait(awaitable, reason="...")`; unknown waits time out. Overlapping actor, builder, lifecycle, and time-control operations reject before mutation. Teardown cancels bot-owned work but leaves caller-owned tasks alive.
103
111
 
104
112
  ```python
@@ -161,6 +169,12 @@ async def test_command_logic_in_isolation(simcord_env):
161
169
  ...
162
170
  ```
163
171
 
172
+ To opt into the 3.0 observation behavior for a plugin fixture, use
173
+ `@pytest.mark.simcord(future_behavior=True)`. The same flag works in a wrapper fixture
174
+ with `simcord.run(bot, future_behavior=True)`. Legacy `env.errors` and mutable result
175
+ payload reads emit `DeprecationWarning` with this migration guidance; startup and scalar
176
+ result reads do not.
177
+
164
178
  For an override shared by many tests, drive `simcord.run` yourself in a small wrapper fixture
165
179
  instead:
166
180
 
@@ -63,6 +63,26 @@ assert result.ephemeral
63
63
  assert result.response.content == f"Banned {target.mention}: spam"
64
64
  ```
65
65
 
66
+ ### The 2.3 migration bridge
67
+
68
+ Use `simcord.run(bot, future_behavior=True)` (or
69
+ `@pytest.mark.simcord(future_behavior=True)`) for the intended 3.0 observation semantics.
70
+ Results remain **live handles**: reading them again reflects later original-response edits
71
+ and followups. Each mutable payload read returns a detached nested snapshot, including
72
+ components, embeds, full message payloads, modal payloads and autocomplete choices.
73
+ Editing a returned snapshot does not edit Discord state, and subsequent backend changes
74
+ do not rewrite a previously returned snapshot. Lists and dictionaries keep their shapes;
75
+ absent modal/autocomplete payloads remain `None`, and empty choices remain `[]`.
76
+
77
+ Modal submission always uses the backend's canonical modal, not edits to a detached
78
+ `result.modal` snapshot. Make real changes through bot response/edit/followup APIs.
79
+
80
+ The flag defaults to `False` in 2.3: existing nested payload aliasing is preserved, with a
81
+ `DeprecationWarning` on mutable payload reads. Scalar reads such as `content`,
82
+ `acknowledged` and `ephemeral` do not warn. In 3.0 the flag is removed and detached
83
+ observations become the default. See [Errors & diagnostics](diagnostics.md) for the
84
+ matching error-acknowledgement transition.
85
+
66
86
  ## The interaction lifecycle is real
67
87
 
68
88
  The full lifecycle behaves as on Discord:
@@ -121,9 +121,16 @@ Full treatment in [Errors & diagnostics](diagnostics.md).
121
121
 
122
122
  ## Validation limits
123
123
 
124
- Real Discord limits are enforced with real error codes. Message content over 2000
125
- characters, or embeds totalling over 6000 characters, raise `discord.HTTPException` with
126
- code `50035` — exactly as in production:
124
+ Message creation and edits share validation across bot HTTP requests, actor helpers and
125
+ the backend. Content is limited to 2000 characters. Messages may contain at most 10
126
+ embeds, with at most 6000 text characters **across all embeds combined**. Individual
127
+ embed limits are title 256, description 4096, 25 fields, field name 256, field value
128
+ 1024, footer text 2048 and author name 256. Leading and trailing whitespace in each
129
+ embed text field is excluded from these counts; stored text is unchanged. Counts use
130
+ Python's Unicode `len`, as in earlier 2.x releases.
131
+
132
+ Invalid bot requests raise `discord.HTTPException` with code `50035`; actor/backend
133
+ calls raise `simcord.BackendError` with the same code:
127
134
 
128
135
  ```python
129
136
  async def test_rejects_oversized(simcord_env):
@@ -133,6 +140,25 @@ async def test_rejects_oversized(simcord_env):
133
140
  assert exc.value.code == 50035
134
141
  ```
135
142
 
143
+ Some invalid messages previously accepted by SimCord now fail, including embeds whose
144
+ combined text exceeds 6000 characters and oversized individual embed fields. New user
145
+ and bot messages must contain content, an attachment, an embed, components or a poll;
146
+ content-free messages with one of those payloads remain supported. Partial edits and
147
+ explicit `None` values can clear fields, including leaving an existing message empty.
148
+ System messages and empty interaction deferrals are not treated as empty message sends.
149
+ Stickers remain explicitly unsupported offline.
150
+ Interaction message responses validate before acknowledgement; a rejected response
151
+ can be retried. Updating an interaction's source message requires an actual source
152
+ message (a component interaction or a modal opened from one).
153
+
154
+ Forum posts prepare the entire starter message and read all uploaded files before
155
+ creating a thread or storing CDN objects. Validation or upload-read failures leave no
156
+ thread, starter message or gateway event behind. Subscribers to `THREAD_CREATE` and
157
+ `MESSAGE_CREATE` see the complete thread, starter and parent state. Upload preparation
158
+ does consume file streams: if a later upload read fails, earlier streams have already
159
+ been read and must be rewound or replaced before retrying. This boundary does not
160
+ include arbitrary subscriber exceptions after gateway publication begins.
161
+
136
162
  ## Next
137
163
 
138
164
  - [Slash commands](interactions.md) — app commands, context menus and the interaction
@@ -270,6 +270,15 @@ identifies the focused projection. `modal`, `candidates`, `assets`, `entities`,
270
270
  details may evolve under `protocolVersion`; assert on documented keys rather than exact payload
271
271
  layout.
272
272
 
273
+ The packaged `protocol.schema.json` also describes protocol-2 action envelopes at
274
+ `#/$defs/action`: `click`, `select`, `modal_submit`, `viewer`, `focus`, `refresh`,
275
+ and `close`. Validate against that definition rather than the snapshot root.
276
+ Unknown metadata remains accepted, and an omitted/null focus target retains its
277
+ initial-target default. Modal values are broadly typed because controls determine
278
+ their types; file bytes are delivered through multipart transport, not JSON.
279
+ Schema validity does not establish authorization, freshness, control availability,
280
+ or sequence admission: runtime checks remain authoritative.
281
+
273
282
  ## Screenshot profiles, modes, and reports
274
283
 
275
284
  `preview.screenshot(path, ...)` settles and pins the requested viewer/target before releasing the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: "Stability & versioning"
3
- description: "What SimCord's public API covers, what stays internal, and how versioning works for the 2.0 release."
3
+ description: "SimCord's public API, semantic versioning, and the compatible 2.3 migration bridge for 3.0."
4
4
  ---
5
5
 
6
6
  # Stability & versioning
@@ -31,9 +31,8 @@ own supervisor.
31
31
 
32
32
  SimCord follows [semantic versioning](https://semver.org/). The
33
33
  **public API** below is covered by that promise: no breaking change to it without
34
- a major version bump. The 2.0 release keeps this contract while changing
35
- settlement ownership as described above. CI continues to enforce honest parity,
36
- coverage, and offline-performance checks.
34
+ a major version bump. Historical 2.0 settlement changes are described above.
35
+ CI continues to enforce honest parity, coverage, and offline-performance checks.
37
36
 
38
37
  The fuzzer proves that across every route whose body is a field set — message
39
38
  send and edits, webhook execute, bulk delete, and the rest — an unrecognised
@@ -117,6 +116,36 @@ verified in CI, so they cannot quietly drift as discord.py evolves.
117
116
  These exception types are part of the public API precisely so your tests can
118
117
  assert on them.
119
118
 
119
+ ## Preparing for 3.0 with 2.3
120
+
121
+ Use `simcord.run(bot, future_behavior=True)` (or decorate a fixture-using test with
122
+ `@pytest.mark.simcord(future_behavior=True)`) to exercise both
123
+ upcoming observation contracts without changing the default behavior of 2.x:
124
+
125
+ - Reading `env.errors` acknowledges only the currently captured prefix and returns
126
+ a list snapshot. Later errors, including shutdown errors, still fail teardown.
127
+ Exception objects and tracebacks are preserved. Explicit `raise_errors()` checks
128
+ the entire history; preview diagnostics never acknowledge errors.
129
+ - Result handles remain live, but mutable payloads are detached on each read.
130
+ Saved payloads no longer follow edits, and mutating them cannot change the world.
131
+ Read the handle again after a genuine edit or followup.
132
+
133
+ Legacy error inspection and mutable result reads emit `DeprecationWarning` with
134
+ opt-in guidance in 2.3. Enable these warnings while migrating, for example with
135
+ `pytest -W default::DeprecationWarning`. The temporary `future_behavior` option
136
+ will be removed in 3.0 when these semantics become unconditional. See the
137
+ [diagnostics guide](guides/diagnostics.md) and
138
+ [interaction guide](guides/interactions.md) for details.
139
+
140
+ The compatible correctness fixes reject previously accepted invalid messages and
141
+ prevent rejected forum requests from publishing partial state. Tests depending on
142
+ invalid Discord payloads must change; nullable edits remain supported.
143
+ Independent startup budgets and `strict_sync=False` remain supported.
144
+
145
+ This release retains preview protocol 2. The protocol-3 work and its additional
146
+ action kinds remain on the separate 3.0 development line. No Discord visual
147
+ calibration or human accessibility sign-off is implied by local automated checks.
148
+
120
149
  ## Deprecation policy
121
150
 
122
151
  From 1.0 onward, a public-API symbol is never removed or changed incompatibly
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "simcord"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Offline discord.py bot testing with pytest. No network, token, or test server."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -77,6 +77,13 @@ def verify() -> None:
77
77
  for suffix in ("__stopped", "__timeout_task"):
78
78
  if not any(name.endswith(suffix) for name in vars(view_probe)): # pragma: no cover
79
79
  problems.append(f"BaseView.{suffix}")
80
+ impl_probe = getattr(_view.BaseView, "_BaseView__timeout_task_impl", None)
81
+ if (
82
+ impl_probe is None
83
+ or not asyncio.iscoroutinefunction(impl_probe)
84
+ or "self" not in impl_probe.__code__.co_varnames
85
+ ): # pragma: no cover
86
+ problems.append("BaseView._BaseView__timeout_task_impl coroutine")
80
87
  if not hasattr(asyncio.timeouts.Timeout(0.0), "_task"): # pragma: no cover
81
88
  problems.append("Timeout._task")
82
89
 
@@ -197,7 +204,16 @@ def is_wakeup_callback(callback: Any, args: tuple[Any, ...], waiter: Any, task:
197
204
 
198
205
 
199
206
  def _view_timeout_task_identity(client: discord.Client, task: asyncio.Task[Any]) -> bool:
200
- """True when task is some store-registered View/Modal's expiry task."""
207
+ """True when task is a registered View/Modal's expiry task."""
208
+ coro = task.get_coro()
209
+ impl = getattr(_view.BaseView, "_BaseView__timeout_task_impl", None)
210
+ if impl is not None and getattr(coro, "cr_code", None) is getattr(impl, "__code__", None):
211
+ owner = getattr(getattr(coro, "cr_frame", None), "f_locals", {}).get("self")
212
+ if isinstance(owner, _view.BaseView):
213
+ for name, value in vars(owner).items():
214
+ if name.endswith("__stopped") and isinstance(value, asyncio.Future) and value.done():
215
+ return False
216
+ return True
201
217
  store = getattr(get_state(client), "_view_store", None)
202
218
  if store is None:
203
219
  return False
@@ -301,9 +317,9 @@ def composed_tasks(task: asyncio.Task[Any], waiter: Any) -> list[Any]:
301
317
  if getattr(callback, "__module__", "") == "asyncio.tasks" and getattr(
302
318
  callback, "__qualname__", ""
303
319
  ).endswith("shield.<locals>._outer_done_callback"):
304
- for cell in callback.__closure__ or ():
320
+ for name, cell in zip(callback.__code__.co_freevars, callback.__closure__ or (), strict=True):
305
321
  value = cell.cell_contents
306
- if isinstance(value, asyncio.Future):
322
+ if name == "inner" and isinstance(value, asyncio.Future):
307
323
  found.append(value)
308
324
 
309
325
  wait_codes = {
@@ -9,7 +9,6 @@ channels), then delivered as authentic gateway events.
9
9
  from __future__ import annotations
10
10
 
11
11
  from collections.abc import Sequence
12
- from functools import wraps
13
12
  from typing import TYPE_CHECKING, Any
14
13
 
15
14
  import discord
@@ -18,7 +17,7 @@ from . import interactions as _interactions
18
17
  from .backend import serializers
19
18
  from .backend.access import can_access_channel, can_access_message
20
19
  from .backend.errors import SetupError
21
- from .builders import ChannelHandle, GuildHandle, RoleHandle, UserHandle
20
+ from .builders import ChannelHandle, GuildHandle, RoleHandle, UserHandle, _guard_builder_operation
22
21
  from .components import validate_modal, walk_components
23
22
  from .enums import SELECT_TYPES, AppCommandType, ComponentType, InteractionType
24
23
  from .results import InteractionResult, ResponseMessage, to_discord_message
@@ -861,18 +860,6 @@ async def _dispatch_actor_interaction(
861
860
  return InteractionResult(actor._env, record)
862
861
 
863
862
 
864
- def _guard_actor_operation(method: Any) -> Any:
865
- @wraps(method)
866
- async def guarded(self: MemberActor, *args: Any, **kwargs: Any) -> Any:
867
- token = self._env._begin_operation(method.__name__)
868
- try:
869
- return await method(self, *args, **kwargs)
870
- finally:
871
- self._env._end_operation(token)
872
-
873
- return guarded
874
-
875
-
876
863
  for _operation_name in (
877
864
  "send",
878
865
  "edit",
@@ -895,4 +882,4 @@ for _operation_name in (
895
882
  "subscribe_event",
896
883
  "unsubscribe_event",
897
884
  ):
898
- setattr(MemberActor, _operation_name, _guard_actor_operation(getattr(MemberActor, _operation_name)))
885
+ setattr(MemberActor, _operation_name, _guard_builder_operation(getattr(MemberActor, _operation_name)))
@@ -185,8 +185,8 @@ def assert_error(
185
185
 
186
186
  discord.py wraps callback failures (e.g. ``CommandInvokeError.original``), so
187
187
  the type and code are matched against the error *and* its ``.original``.
188
- Returns the matched error. Reading ``env.errors`` marks errors inspected, so
189
- this also satisfies the teardown ``check_errors`` guard.
188
+ Returns the matched error. Reading ``env.errors`` acknowledges the captured
189
+ prefix with ``future_behavior=True`` (the entire lifetime in legacy mode).
190
190
  """
191
191
  captured = env.errors
192
192
 
@@ -194,6 +194,7 @@ class ChannelMixin(BackendBase):
194
194
  auto_archive_duration: int = 1440,
195
195
  message_id: int | None = None,
196
196
  applied_tags: Iterable[int] = (),
197
+ broadcast: bool = True,
197
198
  ) -> Channel:
198
199
  parent = self.get_channel(parent_id)
199
200
  guild = self.get_guild(parent.guild_id) # type: ignore[arg-type]
@@ -214,10 +215,14 @@ class ChannelMixin(BackendBase):
214
215
  self.channels[thread.id] = thread
215
216
  self.messages.setdefault(thread.id, {})
216
217
  guild.thread_ids.append(thread.id)
218
+ if broadcast:
219
+ self.announce_thread_create(thread)
220
+ return thread
221
+
222
+ def announce_thread_create(self, thread: Channel) -> None:
217
223
  payload = dict(serializers.thread_payload(self, thread))
218
224
  payload["newly_created"] = True
219
225
  self.emit("THREAD_CREATE", payload)
220
- return thread
221
226
 
222
227
  # ------------------------------------------------------- stage instances
223
228
 
@@ -51,6 +51,8 @@ class MessageMixin(BackendBase):
51
51
  content=content,
52
52
  embeds=embed_value,
53
53
  poll=poll,
54
+ attachments=attachments,
55
+ require_nonempty=True,
54
56
  )
55
57
  except (ComponentValidationError, TypeError, ValueError) as exc:
56
58
  raise _component_error(
@@ -89,16 +91,20 @@ class MessageMixin(BackendBase):
89
91
  if channel.is_thread:
90
92
  channel.message_count += 1
91
93
  if broadcast:
92
- payload = dict(serializers.message_payload(self, message))
93
- if channel.guild_id is not None:
94
- guild = self.guilds[channel.guild_id]
95
- if author_id in guild.members:
96
- payload["member"] = serializers.member_payload(
97
- self, guild, guild.members[author_id], with_user=False
98
- )
99
- self.emit("MESSAGE_CREATE", payload)
94
+ self.announce_message_create(message)
100
95
  return message
101
96
 
97
+ def announce_message_create(self, message: Message) -> None:
98
+ channel = self.get_channel(message.channel_id)
99
+ payload = dict(serializers.message_payload(self, message))
100
+ if channel.guild_id is not None:
101
+ guild = self.guilds[channel.guild_id]
102
+ if message.author_id in guild.members:
103
+ payload["member"] = serializers.member_payload(
104
+ self, guild, guild.members[message.author_id], with_user=False
105
+ )
106
+ self.emit("MESSAGE_CREATE", payload)
107
+
102
108
  def _mentions_everyone(self, channel: Channel, author_id: int, content: str) -> bool:
103
109
  """An @everyone only actually pings if the author may mention everyone."""
104
110
  if "@everyone" not in content and "@here" not in content:
@@ -115,8 +121,10 @@ class MessageMixin(BackendBase):
115
121
  embeds = fields["embeds"] if "embeds" in fields else message.embeds
116
122
  components = fields["components"] if "components" in fields else message.components
117
123
  attachments = fields["attachments"] if "attachments" in fields else message.attachments
118
- flags = int(fields["flags"]) if "flags" in fields and fields["flags"] is not None else message.flags
119
124
  try:
125
+ flags = (
126
+ int(fields["flags"]) if "flags" in fields and fields["flags"] is not None else message.flags
127
+ )
120
128
  normalized_components = validate_message_state(
121
129
  [] if components is None else components,
122
130
  flags=flags,
@@ -551,6 +551,45 @@ def validate_components(components: Any, *, flags: int = 0) -> list[dict[str, An
551
551
  return normalized
552
552
 
553
553
 
554
+ def validate_embeds(embeds: Any) -> None:
555
+ """Check Discord's per-field and combined embed limits without trimming storage."""
556
+ if not isinstance(embeds, list):
557
+ raise ComponentValidationError("embeds must be an array")
558
+ if len(embeds) > 10:
559
+ raise ComponentValidationError("embeds: Must be 10 or fewer in length")
560
+ total = 0
561
+ for index, embed in enumerate(embeds):
562
+ if not isinstance(embed, Mapping):
563
+ raise ComponentValidationError(f"embeds[{index}] must be an object")
564
+ fields = embed.get("fields") if embed.get("fields") is not None else []
565
+ if not isinstance(fields, list) or len(fields) > 25:
566
+ raise ComponentValidationError(f"embeds[{index}].fields: Must be 25 or fewer in length")
567
+ texts = [("title", embed.get("title"), 256), ("description", embed.get("description"), 4096)]
568
+ for field_index, field in enumerate(fields):
569
+ if not isinstance(field, Mapping):
570
+ raise ComponentValidationError(f"embeds[{index}].fields[{field_index}] must be an object")
571
+ texts.extend(
572
+ (f"fields[{field_index}].{key}", field.get(key), limit)
573
+ for key, limit in (("name", 256), ("value", 1024))
574
+ )
575
+ for key, text_key, limit in (("footer", "text", 2048), ("author", "name", 256)):
576
+ section = embed.get(key) if embed.get(key) is not None else {}
577
+ if not isinstance(section, Mapping):
578
+ raise ComponentValidationError(f"embeds[{index}].{key} must be an object")
579
+ texts.append((f"{key}.{text_key}", section.get(text_key), limit))
580
+ for path, text, limit in texts:
581
+ if text is None:
582
+ continue
583
+ if not isinstance(text, str):
584
+ raise ComponentValidationError(f"embeds[{index}].{path} must be a string")
585
+ length = len(text.strip())
586
+ if length > limit:
587
+ raise ComponentValidationError(f"embeds[{index}].{path}: Must be {limit} or fewer characters")
588
+ total += length
589
+ if total > 6000:
590
+ raise ComponentValidationError("embeds: total size of embeds exceeds 6000 characters")
591
+
592
+
554
593
  def validate_message_state(
555
594
  components: Any,
556
595
  *,
@@ -560,11 +599,12 @@ def validate_message_state(
560
599
  poll: Any = None,
561
600
  stickers: Any = None,
562
601
  previous_flags: int = 0,
602
+ attachments: Any = None,
603
+ require_nonempty: bool = False,
563
604
  ) -> list[dict[str, Any]]:
564
605
  if content is not None and (not isinstance(content, str) or len(content) > 2000):
565
606
  raise _fail("", "content must be a string of 2000 or fewer characters")
566
- if not isinstance(embeds, list):
567
- raise _fail("", "embeds must be an array")
607
+ validate_embeds(embeds)
568
608
  v2 = bool(int(flags) & COMPONENTS_V2_FLAG)
569
609
  if int(previous_flags) & COMPONENTS_V2_FLAG and not v2:
570
610
  raise _fail("", "the components_v2 flag cannot be removed")
@@ -572,6 +612,10 @@ def validate_message_state(
572
612
  raise _fail("", "components_v2 messages require at least one component")
573
613
  if v2 and ((content or "") or embeds or poll is not None or stickers):
574
614
  raise _fail("", "components_v2 messages cannot contain content, embeds, polls, or stickers")
615
+ if require_nonempty and not (
616
+ (content or "").strip() or embeds or components or attachments or poll is not None
617
+ ):
618
+ raise ComponentValidationError("Cannot send an empty message")
575
619
  return validate_components(components, flags=int(flags))
576
620
 
577
621