PyMemoryEditor 3.0.2__tar.gz → 3.2.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 (206) hide show
  1. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/CONTRIBUTING.md +21 -6
  2. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/Makefile +42 -8
  3. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PKG-INFO +3 -1
  4. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/__init__.py +1 -1
  5. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_widgets.py +106 -27
  6. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/application.py +72 -12
  7. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_table.py +95 -23
  8. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/log_console_dialog.py +2 -2
  9. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/main_window.py +79 -24
  10. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_map_dialog.py +4 -14
  11. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
  12. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/modules_dialog.py +2 -3
  13. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/open_process_dialog.py +2 -14
  14. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
  15. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
  16. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/results_view.py +89 -15
  17. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_worker.py +2 -1
  18. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/functions.py +177 -10
  19. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/process.py +100 -12
  20. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/functions.py +416 -166
  21. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/libsystem.py +15 -0
  22. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/process.py +8 -0
  23. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/types.py +52 -1
  24. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__init__.py +4 -2
  25. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/config.py +58 -34
  26. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/policy.py +108 -16
  27. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/server.py +48 -22
  28. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/session.py +227 -58
  29. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/toolset.py +435 -233
  30. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/abstract.py +136 -33
  31. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/pointer_scan.py +130 -19
  32. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/region.py +1 -4
  33. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/remote_pointer.py +11 -3
  34. pymemoryeditor-3.2.0/PyMemoryEditor/process/scanning.py +586 -0
  35. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/thread_info.py +6 -4
  36. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/convert.py +51 -50
  37. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/pattern.py +38 -6
  38. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan.py +211 -80
  39. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan_numpy.py +50 -26
  40. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/functions.py +280 -87
  41. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/process.py +70 -12
  42. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/types.py +23 -16
  43. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/memory-region.md +4 -3
  44. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/openprocess.md +8 -3
  45. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/pointer-path.md +8 -1
  46. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/thread-info.md +10 -7
  47. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/utilities.md +9 -5
  48. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/app.md +3 -2
  49. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/conf.py +1 -1
  50. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/allocate-free.md +13 -10
  51. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/memory-regions.md +3 -2
  52. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/modules-threads.md +5 -2
  53. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pattern-scan.md +18 -0
  54. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pointer-scan.md +7 -2
  55. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/searching.md +42 -14
  56. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/installation.md +4 -3
  57. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/mcp.md +47 -20
  58. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/platform-notes.md +50 -6
  59. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/troubleshooting.md +26 -3
  60. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/pyproject.toml +20 -4
  61. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_cheat_entry.py +98 -0
  62. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_pointer_parsing.py +10 -0
  63. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_smoke.py +129 -1
  64. pymemoryeditor-3.2.0/tests/app/test_app_widgets.py +248 -0
  65. pymemoryeditor-3.2.0/tests/app/test_main_window_lifecycle.py +200 -0
  66. pymemoryeditor-3.2.0/tests/app/test_results_model.py +164 -0
  67. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/conftest.py +12 -2
  68. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_argument_fuzz.py +3 -2
  69. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_fake_fidelity.py +29 -1
  70. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_live.py +14 -7
  71. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_parsing.py +142 -0
  72. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_policy.py +108 -0
  73. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_server.py +142 -0
  74. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_toolset.py +816 -5
  75. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_allocate_free.py +5 -4
  76. pymemoryeditor-3.2.0/tests/memory/test_search_argument_errors.py +69 -0
  77. pymemoryeditor-3.2.0/tests/memory/test_unaligned_scan.py +81 -0
  78. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness_fallback_warns.py +1 -1
  79. pymemoryeditor-3.2.0/tests/platforms/test_linux_error_hints.py +110 -0
  80. pymemoryeditor-3.2.0/tests/platforms/test_linux_pid_reuse.py +214 -0
  81. pymemoryeditor-3.2.0/tests/platforms/test_linux_snapshot_passthrough.py +60 -0
  82. pymemoryeditor-3.2.0/tests/platforms/test_macos_load_commands.py +185 -0
  83. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_protect.py +160 -0
  84. pymemoryeditor-3.2.0/tests/platforms/test_macos_region_walk.py +118 -0
  85. pymemoryeditor-3.2.0/tests/platforms/test_macos_snapshot.py +69 -0
  86. pymemoryeditor-3.2.0/tests/platforms/test_macos_threads.py +171 -0
  87. pymemoryeditor-3.2.0/tests/platforms/test_macos_types.py +97 -0
  88. pymemoryeditor-3.2.0/tests/platforms/test_win32_backend_guards.py +266 -0
  89. pymemoryeditor-3.2.0/tests/platforms/test_win32_region_walk.py +159 -0
  90. pymemoryeditor-3.2.0/tests/platforms/test_win32_toolhelp.py +124 -0
  91. pymemoryeditor-3.2.0/tests/platforms/test_win32_wow64.py +137 -0
  92. pymemoryeditor-3.2.0/tests/pointer/test_pointer_review_fixes.py +224 -0
  93. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_scan.py +148 -0
  94. pymemoryeditor-3.2.0/tests/process/test_foreign_process.py +95 -0
  95. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_chunking_integration.py +71 -17
  96. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan.py +210 -0
  97. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan_properties.py +99 -11
  98. pymemoryeditor-3.2.0/tests/scan/test_scan_review_fixes.py +443 -0
  99. pymemoryeditor-3.0.2/PyMemoryEditor/process/scanning.py +0 -467
  100. pymemoryeditor-3.0.2/tests/app/test_app_widgets.py +0 -87
  101. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.flake8 +0 -0
  102. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.gitignore +0 -0
  103. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/.readthedocs.yaml +0 -0
  104. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/CODE_OF_CONDUCT.md +0 -0
  105. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/LICENSE +0 -0
  106. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/__main__.py +0 -0
  107. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/__init__.py +0 -0
  108. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_auto_refresh_dialog.py +0 -0
  109. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_icon.py +0 -0
  110. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/assets/icon.svg +0 -0
  111. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_entry.py +0 -0
  112. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_poll_worker.py +0 -0
  113. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_types.py +0 -0
  114. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scanner_panel.py +0 -0
  115. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/threads_dialog.py +0 -0
  116. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/value_types.py +0 -0
  117. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/enums.py +0 -0
  118. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/libc.py +0 -0
  119. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/types.py +0 -0
  120. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/__init__.py +0 -0
  121. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__main__.py +0 -0
  122. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/__init__.py +0 -0
  123. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/errors.py +0 -0
  124. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/info.py +0 -0
  125. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/module_info.py +0 -0
  126. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/util.py +0 -0
  127. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/py.typed +0 -0
  128. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/__init__.py +0 -0
  129. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/__init__.py +0 -0
  130. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_allocation_states.py +0 -0
  131. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_protections.py +0 -0
  132. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_types.py +0 -0
  133. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/process_operations.py +0 -0
  134. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/standard_access_rights.py +0 -0
  135. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/README.md +0 -0
  136. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/SECURITY.md +0 -0
  137. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/screenshots/app.png +0 -0
  138. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/social-preview.html +0 -0
  139. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/assets/social-preview.png +0 -0
  140. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_static/.gitkeep +0 -0
  141. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_static/custom.css +0 -0
  142. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/_templates/layout.html +0 -0
  143. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/enums.md +0 -0
  144. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/errors.md +0 -0
  145. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/index.md +0 -0
  146. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/module-info.md +0 -0
  147. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/api/remote-pointer.md +0 -0
  148. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/contributing.md +0 -0
  149. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/funding.md +0 -0
  150. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/glossary.md +0 -0
  151. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/logging.md +0 -0
  152. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/opening-process.md +0 -0
  153. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/pointers.md +0 -0
  154. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/guide/read-write.md +0 -0
  155. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/index.md +0 -0
  156. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/license.md +0 -0
  157. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/quickstart.md +0 -0
  158. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/requirements.txt +0 -0
  159. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/docs/why.md +0 -0
  160. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/__init__.py +0 -0
  161. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_scan_request.py +0 -0
  162. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_app_value_types.py +0 -0
  163. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_auto_refresh_dialog.py +0 -0
  164. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_cheat_poll_worker.py +0 -0
  165. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_open_process_dialog.py +0 -0
  166. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/app/test_refine_scan_worker.py +0 -0
  167. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/conftest.py +0 -0
  168. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/__init__.py +0 -0
  169. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/test_errors.py +0 -0
  170. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/core/test_logger.py +0 -0
  171. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/__init__.py +0 -0
  172. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_registry.py +0 -0
  173. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/mcp_server/test_session.py +0 -0
  174. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/__init__.py +0 -0
  175. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_bufflength_inference.py +0 -0
  176. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_editor.py +0 -0
  177. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_narrow_width_consistency.py +0 -0
  178. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_partial_io.py +0 -0
  179. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_read_into.py +0 -0
  180. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_str_boundary.py +0 -0
  181. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_str_decode_consistency.py +0 -0
  182. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_typed_accessors.py +0 -0
  183. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_safety.py +0 -0
  184. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_span.py +0 -0
  185. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/memory/test_write_str_bytes_width.py +0 -0
  186. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/__init__.py +0 -0
  187. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness.py +0 -0
  188. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_linux_types.py +0 -0
  189. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_transient_reads.py +0 -0
  190. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/platforms/test_win32_permissions.py +0 -0
  191. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/__init__.py +0 -0
  192. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_chain.py +0 -0
  193. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/pointer/test_remote_pointer.py +0 -0
  194. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/__init__.py +0 -0
  195. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_memory_region.py +0 -0
  196. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_module_enumeration.py +0 -0
  197. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_partial_name_match.py +0 -0
  198. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_process_enumeration.py +0 -0
  199. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_process_lookup.py +0 -0
  200. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/process/test_thread_enumeration.py +0 -0
  201. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/__init__.py +0 -0
  202. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_compile.py +0 -0
  203. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_scan.py +0 -0
  204. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_region_snapshot.py +0 -0
  205. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scan_numpy.py +0 -0
  206. {pymemoryeditor-3.0.2 → pymemoryeditor-3.2.0}/tests/scan/test_scanning_helper.py +0 -0
@@ -22,9 +22,15 @@ The tests read and write the memory of the test process itself; they should run
22
22
  on any supported platform without elevated privileges.
23
23
 
24
24
  ```bash
25
- make test # pytest tests -v
25
+ make test-fast # pytest tests -m "not slow" (~20 s: unit, property and GUI tests)
26
+ make test # pytest tests -v (everything, incl. live whole-process scans)
27
+ make test-mcp # pytest tests/mcp_server (the MCP server suite, ~10 s)
26
28
  ```
27
29
 
30
+ `test-fast` is the loop to iterate on; the `slow` and `scan_heavy` markers
31
+ (defined in `pyproject.toml`) hold the tests that scan the test process's whole
32
+ address space, which takes minutes on CI runners.
33
+
28
34
  ## Linting
29
35
 
30
36
  ```bash
@@ -46,11 +52,13 @@ make pre-commit # lint + type-check + test
46
52
  ```
47
53
 
48
54
  The CI pipeline runs the same lint, mypy and tests, and blocks merges on failure.
49
- The test matrix runs on Ubuntu, Windows and macOS across multiple Python versions, so
50
- all three platform backends are exercised on every push. A dedicated job also
51
- runs the suite with the `speed` extra (NumPy) to keep the vectorized scan path
52
- covered. Even so, contributors touching a specific backend are encouraged to
53
- run `pytest tests` locally on that platform before submitting.
55
+ The library matrix runs Python 3.10–3.15 on Ubuntu and Windows plus a single
56
+ Python 3.12 cell on macOS, so all three platform backends are exercised on every
57
+ push. Dedicated jobs also run the suite with the `speed` extra (NumPy) to keep
58
+ the vectorized scan path covered, and the MCP server suite once per OS. Even so,
59
+ contributors touching a specific backend are encouraged to run `pytest tests`
60
+ locally on that platform before submitting. `make ci` runs the same lint,
61
+ type-check and coverage-gated test invocations the workflow does.
54
62
 
55
63
  ## Project layout
56
64
 
@@ -63,6 +71,7 @@ PyMemoryEditor/
63
71
  ├── win32/ # Windows implementation (kernel32, user32)
64
72
  ├── linux/ # Linux implementation (process_vm_readv/writev, /proc/<pid>/maps)
65
73
  ├── macos/ # macOS implementation (task_for_pid, mach_vm_*)
74
+ ├── mcp/ # Model Context Protocol server exposed as `pymemoryeditor-mcp`
66
75
  └── app/ # PySide6 (Qt) demo app exposed as `pymemoryeditor` CLI
67
76
  ```
68
77
 
@@ -84,6 +93,12 @@ The public alias `OpenProcess` is chosen at import time in `__init__.py` based o
84
93
  2. Branch from `main`. Keep commits focused.
85
94
  3. Run `make pre-commit` (lint + type-check + tests) locally before pushing.
86
95
  4. Open a PR describing the change and how it was tested.
96
+ 5. Give the PR a [Conventional Commits](https://www.conventionalcommits.org/) title,
97
+ e.g. `fix: reject empty search targets` or `docs(mcp): document --allow-write`.
98
+ A required check lints it: the type must be one of `feat`, `fix`, `perf`,
99
+ `refactor`, `revert`, `docs`, `ci`, `build`, `chore`, `test`, `style`, and
100
+ the subject must start lowercase and not end with a period. PRs are
101
+ squash-merged, so the title becomes the commit message on `main`.
87
102
 
88
103
  ## Reporting bugs
89
104
 
@@ -30,7 +30,9 @@ help:
30
30
  @echo " $(YELLOW)install-app$(NC) - Install dependencies to run the PyMemoryEditor App"
31
31
  @echo " $(YELLOW)run-app$(NC) - Run the PyMemoryEditor App"
32
32
  @echo " $(YELLOW)install-dev$(NC) - Install development dependencies"
33
- @echo " $(YELLOW)test$(NC) - Run tests"
33
+ @echo " $(YELLOW)test$(NC) - Run the full test suite (incl. live whole-process scans)"
34
+ @echo " $(YELLOW)test-fast$(NC) - Run the fast lane only (-m \"not slow\", ~20 s)"
35
+ @echo " $(YELLOW)test-mcp$(NC) - Run the MCP server suite"
34
36
  @echo " $(YELLOW)test-verbose$(NC) - Run tests with verbose output"
35
37
  @echo " $(YELLOW)test-coverage$(NC) - Run tests with coverage report"
36
38
  @echo " $(YELLOW)lint$(NC) - Run linter (flake8)"
@@ -40,8 +42,9 @@ help:
40
42
  @echo " $(YELLOW)build-wheel$(NC) - Build wheel package"
41
43
  @echo " $(YELLOW)build-sdist$(NC) - Build source distribution"
42
44
  @echo " $(YELLOW)validate$(NC) - Validate package"
43
- @echo " $(YELLOW)publish$(NC) - Publish to PyPI"
45
+ @echo " $(YELLOW)publish$(NC) - Publish to PyPI by token (emergency path; releases go through GitHub Releases)"
44
46
  @echo " $(YELLOW)publish-test$(NC) - Publish to Test PyPI"
47
+ @echo " $(YELLOW)ci$(NC) - Mirror the CI workflow: lint, type-check, both coverage-gated test runs"
45
48
  @echo " $(YELLOW)version$(NC) - Show current version"
46
49
  @echo " $(YELLOW)check-deps$(NC) - Check for outdated dependencies"
47
50
  @echo " $(YELLOW)update-deps$(NC) - Update dependencies"
@@ -103,13 +106,30 @@ install:
103
106
  $(PIP) install -e .
104
107
  @echo "$(GREEN)Package installed successfully!$(NC)"
105
108
 
106
- # Run tests
109
+ # Run tests (everything, including the live scans marked `slow`/`scan_heavy`)
107
110
  .PHONY: test
108
111
  test:
109
112
  @echo "$(GREEN)Running tests...$(NC)"
110
113
  $(PYTHON) -m pytest $(TEST_DIR) -v
111
114
  @echo "$(GREEN)Tests completed!$(NC)"
112
115
 
116
+ # Fast lane: unit, property and GUI tests only (~20 s). The markers are
117
+ # defined in pyproject.toml.
118
+ .PHONY: test-fast
119
+ test-fast:
120
+ @echo "$(GREEN)Running the fast test lane (-m \"not slow\")...$(NC)"
121
+ $(PYTHON) -m pytest $(TEST_DIR) -m "not slow"
122
+ @echo "$(GREEN)Fast tests completed!$(NC)"
123
+
124
+ # The MCP server suite. CI runs it as its own job (`build-mcp`), with the
125
+ # pytest-qt plugin disabled because it imports Qt at startup and no test here
126
+ # needs it.
127
+ .PHONY: test-mcp
128
+ test-mcp:
129
+ @echo "$(GREEN)Running the MCP server tests...$(NC)"
130
+ $(PYTHON) -m pytest $(TEST_DIR)/mcp_server -p no:pytest-qt
131
+ @echo "$(GREEN)MCP tests completed!$(NC)"
132
+
113
133
  # Run tests with verbose output
114
134
  .PHONY: test-verbose
115
135
  test-verbose:
@@ -185,7 +205,10 @@ validate: build
185
205
  $(PYTHON) -m twine check $(DIST_DIR)/*
186
206
  @echo "$(GREEN)Package validation completed!$(NC)"
187
207
 
188
- # Publish to PyPI
208
+ # Publish to PyPI by API token. This is the emergency path only: releases are
209
+ # cut by publishing a GitHub Release, which .github/workflows/publish.yml
210
+ # uploads to PyPI via trusted publishing (OIDC, no token) and then lists on
211
+ # the MCP Registry. Use this target only if that workflow is unavailable.
189
212
  .PHONY: publish
190
213
  publish: validate
191
214
  @echo "$(YELLOW)Are you sure you want to publish to PyPI? [y/N]$(NC)" && read ans && [ $${ans:-N} = y ]
@@ -238,11 +261,12 @@ install-docs:
238
261
  $(PIP) install -e .
239
262
  @echo "$(GREEN)Docs dependencies installed!$(NC)"
240
263
 
241
- # Build the HTML documentation. Output lands in $(DOCS_BUILD_DIR).
264
+ # Build the HTML documentation. Output lands in $(DOCS_BUILD_DIR). Warnings
265
+ # are errors, the same as the `docs` job in python-package.yml.
242
266
  .PHONY: docs
243
267
  docs:
244
268
  @echo "$(GREEN)Building HTML documentation...$(NC)"
245
- $(PYTHON) -m sphinx -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
269
+ $(PYTHON) -m sphinx -W --keep-going -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
246
270
  @echo "$(GREEN)Documentation generated at $(DOCS_BUILD_DIR)/index.html$(NC)"
247
271
 
248
272
  # Live-reload docs server — rebuilds on every save and reloads the browser.
@@ -281,9 +305,19 @@ pre-commit: lint type-check test
281
305
  pre-publish: all security
282
306
  @echo "$(GREEN)Pre-publish checks completed!$(NC)"
283
307
 
284
- # CI/CD targets
308
+ # CI/CD targets. Mirrors .github/workflows/python-package.yml: the library
309
+ # and app tests are gated on the library's coverage (.coveragerc-lib), the
310
+ # MCP suite on its own (.coveragerc-mcp), with the same floors as the
311
+ # workflow. Keep the two in sync when either changes.
285
312
  .PHONY: ci
286
- ci: install-dev install lint type-check test build validate
313
+ ci: install-dev install lint type-check
314
+ @echo "$(GREEN)Running the library/app suite with the CI coverage gate...$(NC)"
315
+ QT_QPA_PLATFORM=offscreen $(PYTHON) -m pytest $(TEST_DIR) --ignore=$(TEST_DIR)/mcp_server --maxfail=10 \
316
+ --cov=$(PACKAGE_NAME) --cov-config=.coveragerc-lib --cov-report=term --cov-fail-under=62
317
+ @echo "$(GREEN)Running the MCP suite with the CI coverage gate...$(NC)"
318
+ $(PYTHON) -m pytest $(TEST_DIR)/mcp_server -p no:pytest-qt --maxfail=10 \
319
+ --cov=$(PACKAGE_NAME)/mcp --cov-config=.coveragerc-mcp --cov-report=term --cov-fail-under=85
320
+ $(MAKE) build validate
287
321
  @echo "$(GREEN)CI pipeline completed!$(NC)"
288
322
 
289
323
  # Show package info
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: PyMemoryEditor
3
- Version: 3.0.2
3
+ Version: 3.2.0
4
4
  Summary: Read, write and scan process memory in a few lines of Python — Cheat Engine-style scans, pointer chains and AOB search on Windows, Linux and macOS.
5
5
  Project-URL: Homepage, https://github.com/JeanExtreme002/PyMemoryEditor
6
6
  Project-URL: Documentation, https://pymemoryeditor.readthedocs.io
@@ -26,6 +26,8 @@ Classifier: Programming Language :: Python :: 3.10
26
26
  Classifier: Programming Language :: Python :: 3.11
27
27
  Classifier: Programming Language :: Python :: 3.12
28
28
  Classifier: Programming Language :: Python :: 3.13
29
+ Classifier: Programming Language :: Python :: 3.14
30
+ Classifier: Programming Language :: Python :: 3.15
29
31
  Classifier: Topic :: Games/Entertainment
30
32
  Classifier: Topic :: Scientific/Engineering
31
33
  Classifier: Topic :: Security
@@ -8,7 +8,7 @@ Supported platforms: Windows, Linux and macOS (32-bit and 64-bit).
8
8
  """
9
9
 
10
10
  __author__ = "Jean Loui Bernard Silva de Jesus"
11
- __version__ = "3.0.2"
11
+ __version__ = "3.2.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -6,9 +6,10 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
6
6
  previously appeared duplicated across several dialog modules.
7
7
  """
8
8
 
9
+ import threading
9
10
  from typing import Any, Callable, Iterable, List, Optional, Tuple
10
11
 
11
- from PySide6.QtCore import QObject, Qt, QThread
12
+ from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QTimer
12
13
  from PySide6.QtGui import QStandardItem
13
14
 
14
15
 
@@ -84,6 +85,56 @@ def _reap_detached_worker(worker: QThread) -> None:
84
85
  worker.deleteLater()
85
86
 
86
87
 
88
+ def detached_workers_running() -> bool:
89
+ """Whether any worker that outlived its owner is still running."""
90
+ return any(worker.isRunning() for worker in _DETACHED_WORKERS)
91
+
92
+
93
+ def run_after_detached_workers(callback: Callable[[], None]) -> None:
94
+ """Run ``callback`` once every currently detached worker has finished.
95
+
96
+ A detached worker can still be inside a backend syscall on the process it
97
+ was given, so releasing that process (``close()``) must wait for it: on
98
+ Windows the handle value can be recycled for an unrelated object the moment
99
+ it is closed, and a late write from the cheat poller would land there.
100
+ With no worker still running, ``callback`` runs immediately.
101
+ """
102
+ pending = [worker for worker in _DETACHED_WORKERS if worker.isRunning()]
103
+ if not pending:
104
+ callback()
105
+ return
106
+
107
+ remaining = set(pending)
108
+ done = False
109
+ # `finished` can be delivered from the worker's own thread while the
110
+ # isFinished() sweep below runs on this one: guard the bookkeeping so the
111
+ # callback (a handle close) runs exactly once.
112
+ guard = threading.Lock()
113
+ # Whichever thread notices the last worker finishing, the callback is
114
+ # posted to the GUI thread (the application object lives there), so it
115
+ # never runs concurrently with the GUI setting up the next process.
116
+ gui = QCoreApplication.instance()
117
+
118
+ def _on_finished(worker: QThread) -> None:
119
+ nonlocal done
120
+ with guard:
121
+ remaining.discard(worker)
122
+ if remaining or done:
123
+ return
124
+ done = True
125
+ if gui is None:
126
+ callback()
127
+ else:
128
+ QTimer.singleShot(0, gui, callback)
129
+
130
+ for worker in pending:
131
+ worker.finished.connect(lambda w=worker: _on_finished(w))
132
+ # Same race as detach_worker: one may have finished before its connect.
133
+ for worker in pending:
134
+ if worker.isFinished():
135
+ _on_finished(worker)
136
+
137
+
87
138
  class TearsDownOnClose:
88
139
  """Mixin that runs a dialog's teardown on *every* way out, not just close.
89
140
 
@@ -166,45 +217,76 @@ class NumericItem(QStandardItem):
166
217
  return self.text() < other.text()
167
218
 
168
219
 
220
+ _HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
221
+
222
+
223
+ def _parse_hex_digits(text: str) -> Optional[int]:
224
+ """``text`` as an unsigned hex number, or None. Accepts a ``0x`` prefix."""
225
+ cleaned = text.strip()
226
+ if cleaned[:2].lower() == "0x":
227
+ cleaned = cleaned[2:]
228
+ # Explicit digit check rather than relying on int(): int(x, 16) also takes
229
+ # "1_0" (underscore separators) and leading sign/whitespace, none of which
230
+ # is a hex address a user meant to type.
231
+ if not cleaned or not _HEX_DIGITS.issuperset(cleaned):
232
+ return None
233
+ return int(cleaned, 16)
234
+
235
+
169
236
  def parse_hex_address(text: str) -> Optional[int]:
170
237
  """Parse a hex address string (with or without 0x prefix) into an int.
171
238
 
172
- Returns None on any parse error. Whitespace is tolerated.
239
+ Addresses are unsigned: a leading sign makes the string invalid. Returns
240
+ None on any parse error. Surrounding whitespace is tolerated.
173
241
  """
174
242
  if not text:
175
243
  return None
176
- cleaned = text.strip()
177
- if not cleaned:
244
+ return _parse_hex_digits(text)
245
+
246
+
247
+ def parse_hex_offset(text: str) -> Optional[int]:
248
+ """Parse a hex pointer-chain offset, which unlike an address may be negative.
249
+
250
+ ``"-0x10"`` → -16, ``"10"`` → 16. Returns None on any parse error.
251
+ """
252
+ if not text:
178
253
  return None
179
- if cleaned.lower().startswith("0x"):
180
- cleaned = cleaned[2:]
181
- try:
182
- return int(cleaned, 16)
183
- except (TypeError, ValueError):
254
+ cleaned = text.strip()
255
+ negative = cleaned.startswith("-")
256
+ if negative or cleaned.startswith("+"):
257
+ cleaned = cleaned[1:]
258
+ parsed = _parse_hex_digits(cleaned)
259
+ if parsed is None:
184
260
  return None
261
+ return -parsed if negative else parsed
262
+
263
+
264
+ def format_size(size: int) -> str:
265
+ """Human-readable byte count (``1536`` → ``"1.5 KB"``), binary units."""
266
+ units = ["B", "KB", "MB", "GB", "TB"]
267
+ s = float(size)
268
+ for unit in units:
269
+ if s < 1024 or unit == units[-1]:
270
+ return f"{s:,.1f} {unit}" if unit != "B" else f"{int(s):,} B"
271
+ s /= 1024
272
+ return f"{size:,} B"
185
273
 
186
274
 
187
275
  def parse_offsets(texts: Iterable[str]) -> Optional[List[int]]:
188
276
  """Parse pointer-chain offset tokens (in order) from raw field strings.
189
277
 
190
278
  Empty tokens are skipped; every remaining token is read as hex (with or
191
- without ``0x``). Returns ``None`` if any non-empty token can't be parsed —
192
- the caller treats that as "invalid chain, do nothing". Pure (no Qt) so it
193
- can be unit-tested directly.
279
+ without ``0x``, optionally negative). Returns ``None`` if any non-empty
280
+ token can't be parsed — the caller treats that as "invalid chain, do
281
+ nothing". Pure (no Qt) so it can be unit-tested directly.
194
282
  """
195
283
  offsets: List[int] = []
196
284
  for text in texts:
197
285
  if not text:
198
286
  continue
199
- parsed = parse_hex_address(text)
287
+ parsed = parse_hex_offset(text)
200
288
  if parsed is None:
201
- # parse_hex_address handles the 0x form; fall back to a plain hex
202
- # int for ambiguous tokens like "10" (the dialog treats offsets as
203
- # hex throughout).
204
- try:
205
- parsed = int(text, 16)
206
- except ValueError:
207
- return None
289
+ return None
208
290
  offsets.append(parsed)
209
291
  return offsets
210
292
 
@@ -230,13 +312,10 @@ def resolve_base_address(
230
312
  module_name = name_part.strip().strip('"').strip("'").strip()
231
313
  offset = parse_hex_address(offset_part)
232
314
  if offset is None:
233
- try:
234
- offset = int(offset_part.strip(), 16)
235
- except ValueError:
236
- return None, (
237
- "The offset after '+' must be hex "
238
- '(e.g. "game.exe"+0x10F4F4).'
239
- )
315
+ return None, (
316
+ "The offset after '+' must be hex "
317
+ '(e.g. "game.exe"+0x10F4F4).'
318
+ )
240
319
  module_base = module_lookup(module_name)
241
320
  if module_base is None:
242
321
  return None, (
@@ -5,6 +5,7 @@ Entry point for the PyMemoryEditor App.
5
5
  A Cheat-Engine-inspired memory scanner built on PySide6 (Qt for Python),
6
6
  working on Windows, Linux and macOS.
7
7
  """
8
+ import logging
8
9
  import sys
9
10
  import signal
10
11
  import contextlib
@@ -266,11 +267,6 @@ def apply_theme(app, theme_id: str = DEFAULT_THEME_ID) -> None:
266
267
  )
267
268
 
268
269
 
269
- def apply_dark_theme(app) -> None:
270
- """Backward-compatible shim that applies the default dark theme."""
271
- apply_theme(app, DEFAULT_THEME_ID)
272
-
273
-
274
270
  STYLE_SHEET = """
275
271
  QToolTip {
276
272
  color: %(text)s;
@@ -484,10 +480,66 @@ def _scoped_signal_handler(signalnum, handler):
484
480
  signal.signal(signalnum, previous_handler)
485
481
 
486
482
 
483
+ _LOG = logging.getLogger(__name__)
484
+
485
+
486
+ def _qt_excepthook(exc_type, exc_value, exc_tb) -> None:
487
+ """``sys.excepthook`` for the duration of the Qt event loop.
488
+
489
+ PySide6 catches an exception raised inside a slot, hands it to
490
+ ``sys.excepthook`` and carries on — with the default hook that is a
491
+ traceback on stderr the user never sees, and the action they clicked
492
+ silently does nothing. Log it (so the Log Console gets it) and show it.
493
+ KeyboardInterrupt is left to the interpreter's hook: it is the host's
494
+ signal, not an app bug.
495
+ """
496
+ if issubclass(exc_type, KeyboardInterrupt):
497
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
498
+ return
499
+ _LOG.error(
500
+ "Unhandled exception in the UI", exc_info=(exc_type, exc_value, exc_tb)
501
+ )
502
+ try:
503
+ from PySide6.QtCore import QThread
504
+ from PySide6.QtWidgets import QApplication, QMessageBox
505
+
506
+ app = QApplication.instance()
507
+ # Widgets may only be created on the GUI thread. PySide6 routes an
508
+ # exception escaping a QThread.run() through this same hook, from
509
+ # that worker thread, so a dialog there would be undefined behaviour.
510
+ # The log line above is the whole report in that case.
511
+ if app is None or QThread.currentThread() != app.thread():
512
+ return
513
+ QMessageBox.critical(
514
+ None,
515
+ "Unexpected error",
516
+ "%s: %s\n\nThe full traceback is in Tools → Log Console."
517
+ % (exc_type.__name__, exc_value),
518
+ )
519
+ except Exception: # noqa: BLE001 — a hook must never raise
520
+ pass
521
+
522
+
523
+ @contextlib.contextmanager
524
+ def _scoped_excepthook(hook):
525
+ """Install ``hook`` as ``sys.excepthook`` for the block, then restore."""
526
+ previous = sys.excepthook
527
+ sys.excepthook = hook
528
+ try:
529
+ yield
530
+ finally:
531
+ sys.excepthook = previous
532
+
533
+
487
534
  def main(argv=None):
488
535
  """
489
536
  Run the app. Safe to call in-process: it installs no signal handler.
490
537
 
538
+ While the event loop runs, ``sys.excepthook`` is swapped for one that logs
539
+ and shows slot exceptions (PySide6 routes them there and continues), and
540
+ restored on the way out — the loop owns the thread for that time, so an
541
+ embedder's own hook is not observable from inside it anyway.
542
+
491
543
  ``argv`` defaults to ``sys.argv`` so packaging tools (which call
492
544
  ``main()`` with no arguments) keep working. Tests and embedders can pass
493
545
  an explicit list — previously a positional ``*args`` was accepted but
@@ -509,6 +561,7 @@ def main(argv=None):
509
561
  from PySide6.QtCore import QSettings
510
562
  from PySide6.QtWidgets import QApplication
511
563
 
564
+ from ._widgets import detached_workers_running
512
565
  from .main_window import MainWindow
513
566
  from .open_process_dialog import OpenProcessDialog
514
567
 
@@ -536,14 +589,21 @@ def main(argv=None):
536
589
  window = MainWindow(process)
537
590
  window.show()
538
591
  try:
539
- app.exec()
592
+ with _scoped_excepthook(_qt_excepthook):
593
+ app.exec()
540
594
  finally:
541
- try:
542
- # `process` is only the handle the picker opened; File → Change
543
- # Process… released that one and attached the window to another.
544
- window.process.close()
545
- except Exception:
546
- pass
595
+ # A worker that blew its join on close is detached, not stopped, and
596
+ # may still be inside a syscall on this handle; closing it now could
597
+ # hand its value to an unrelated object mid-write. The process is
598
+ # exiting, so leave it to the OS, which releases every handle then.
599
+ if not detached_workers_running():
600
+ try:
601
+ # `process` is only the handle the picker opened; File →
602
+ # Change Process… released that one and attached the window
603
+ # to another.
604
+ window.process.close()
605
+ except Exception:
606
+ pass
547
607
 
548
608
 
549
609
  def main_cli(argv=None):
@@ -56,11 +56,6 @@ from .value_types import (
56
56
  )
57
57
 
58
58
 
59
- # Re-exported for backward compatibility with callers that imported the
60
- # poll-interval constant from this module before the split.
61
- _TICK_INTERVAL_MS = TICK_INTERVAL_MS
62
-
63
-
64
59
  class CheatTable(QWidget):
65
60
  """Bottom pane: saved addresses, freezing, manual edits."""
66
61
 
@@ -80,6 +75,14 @@ class CheatTable(QWidget):
80
75
  self._suspend_signals = False
81
76
  # Before _build_ui(), which connects cellChanged — a path that reads it.
82
77
  self._poller_stopped = False
78
+ # Set while the owner knows the target is gone (see set_polling_paused):
79
+ # the poller idles on an empty snapshot and edits are refused instead
80
+ # of being queued against a dead handle.
81
+ self._paused = False
82
+ # Write failures reported by the poller since the last flush. They are
83
+ # shown in one dialog per event-loop burst, so a bulk edit whose every
84
+ # row fails (target exited) produces one message, not one per row.
85
+ self._pending_write_failures: List[Tuple[int, str]] = []
83
86
 
84
87
  self._build_ui()
85
88
 
@@ -130,6 +133,31 @@ class CheatTable(QWidget):
130
133
  self.shutdown()
131
134
  super().closeEvent(event)
132
135
 
136
+ def set_polling_paused(self, paused: bool) -> None:
137
+ """Idle the poll loop while the target is known to be gone.
138
+
139
+ The owning window's heartbeat calls this when the process exits. The
140
+ publish timer stops and the worker is handed an empty snapshot, so it
141
+ sleeps through its ticks instead of hitting a dead handle ten times a
142
+ second (and, on a recycled PID, a stranger's memory). Entries are kept:
143
+ the table still shows the last values read. Resuming re-publishes.
144
+ """
145
+ if self._poller_stopped or paused == self._paused:
146
+ return
147
+ self._paused = paused
148
+ if paused:
149
+ self._publish_timer.stop()
150
+ self._poller.update_snapshot([])
151
+ else:
152
+ self._publish_snapshot_to_worker()
153
+ self._publish_timer.start()
154
+
155
+ def is_polling_paused(self) -> bool:
156
+ return self._paused
157
+
158
+ def entry_count(self) -> int:
159
+ return len(self._entries)
160
+
133
161
  def _build_ui(self) -> None:
134
162
  layout = QVBoxLayout(self)
135
163
  # Small top inset so the toolbar buttons don't sit flush against the
@@ -208,8 +236,17 @@ class CheatTable(QWidget):
208
236
  # spotted from the table, so the floor is enforced once, at the door,
209
237
  # rather than at each of those call sites. (The AOB pattern spec is the
210
238
  # one whose declared length is 0 — the scanner derives its real width
211
- # from the pattern.)
212
- entry.length = max(1, int(entry.length))
239
+ # from the pattern.) The ceiling is the same one every other width
240
+ # field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
241
+ # and silently resizing its entry would alter what the user exports
242
+ # next, so that is refused -- the import loop reports and skips it.
243
+ length = int(entry.length)
244
+ if length > MAX_ENTRY_LENGTH:
245
+ raise ValueError(
246
+ "entry at 0x%X has length %d, over the %d-byte maximum"
247
+ % (entry.address, length, MAX_ENTRY_LENGTH)
248
+ )
249
+ entry.length = max(1, length)
213
250
 
214
251
  # If the address already exists, just refresh its description/type.
215
252
  for existing in self._entries:
@@ -236,7 +273,16 @@ class CheatTable(QWidget):
236
273
  length: int,
237
274
  description: str = "",
238
275
  ) -> None:
239
- """Convenience used by the scanner panel to bulk-promote rows."""
276
+ """Convenience used by the scanner panel to bulk-promote rows.
277
+
278
+ :raises ValueError: if ``length`` is over ``MAX_ENTRY_LENGTH`` -- checked
279
+ once up front so a refusal adds none of the rows rather than some.
280
+ """
281
+ if int(length) > MAX_ENTRY_LENGTH:
282
+ raise ValueError(
283
+ "length %d is over the %d-byte maximum for a cheat-table entry"
284
+ % (int(length), MAX_ENTRY_LENGTH)
285
+ )
240
286
  for addr in addresses:
241
287
  self.add_entry(
242
288
  CheatEntry(
@@ -356,8 +402,9 @@ class CheatTable(QWidget):
356
402
  # next poll tick reads the value back and corrects the cell if the
357
403
  # write didn't take, and an outright failure returns via
358
404
  # write_failed → _on_write_failed.
359
- # Nothing to route it to once shutdown() handed the worker over.
360
- if self._poller_stopped:
405
+ # Nothing to route it to once shutdown() handed the worker over,
406
+ # and nothing to write to while the target is known to be gone.
407
+ if self._poller_stopped or self._paused:
361
408
  return
362
409
  self._poller.request_write(
363
410
  entry.address, entry.spec.pytype, entry.length, value
@@ -469,13 +516,33 @@ class CheatTable(QWidget):
469
516
  def _on_write_failed(self, failure) -> None:
470
517
  """Surface a manual value write that failed on the worker thread.
471
518
 
472
- The write is now async (queued onto the poll worker), so its failure
473
- arrives here via the ``write_failed`` signal rather than inline. The
474
- optimistic cell update made when the edit was queued is corrected by the
475
- next poll tick, which reads back the unchanged value.
519
+ Writes are async (queued onto the poll worker), so a failure arrives
520
+ here via the ``write_failed`` signal rather than inline. The optimistic
521
+ cell update made when the edit was queued is corrected by the next poll
522
+ tick, which reads back the unchanged value.
523
+
524
+ Failures are collected and shown by ``_flush_write_failures`` on the
525
+ next event-loop pass: the worker drains a bulk edit's queue in one
526
+ tick, so its failures land here back to back and read as one report.
476
527
  """
477
- _address, _pytype, _length, message = failure
478
- QMessageBox.critical(self, "Write Failed", message)
528
+ address, _pytype, _length, message = failure
529
+ if not self._pending_write_failures:
530
+ QTimer.singleShot(0, self._flush_write_failures)
531
+ self._pending_write_failures.append((address, message))
532
+
533
+ def _flush_write_failures(self) -> None:
534
+ failures = self._pending_write_failures
535
+ self._pending_write_failures = []
536
+ if not failures:
537
+ return
538
+ if len(failures) == 1:
539
+ QMessageBox.critical(self, "Write Failed", failures[0][1])
540
+ return
541
+ preview = "\n".join(f"0x{addr:X}: {msg}" for addr, msg in failures[:10])
542
+ extra = "" if len(failures) <= 10 else f"\n…and {len(failures) - 10} more."
543
+ QMessageBox.critical(
544
+ self, "Write Failed", f"{len(failures)} write(s) failed:\n\n{preview}{extra}"
545
+ )
479
546
 
480
547
  def _editing_row(self) -> int:
481
548
  """Return the row currently being edited, or -1 if none."""
@@ -603,15 +670,20 @@ class CheatTable(QWidget):
603
670
  if spec.accepts_length_override:
604
671
  entry.length = effective_length
605
672
 
606
- try:
607
- self._process.write_process_memory(
608
- entry.address, spec.pytype, entry.length, value
609
- )
610
- except Exception as exc: # noqa: BLE001
673
+ # Same path as an inline edit: the syscall runs on the poll
674
+ # worker, never on the UI thread (a slow target or a page
675
+ # fault would freeze the interface for every row). A write
676
+ # that fails comes back via write_failed → one coalesced
677
+ # dialog; the optimistic update below is corrected by the
678
+ # next tick's read-back.
679
+ if self._poller_stopped or self._paused:
611
680
  failures.append(
612
- (entry.address, f"{type(exc).__name__}: {exc}")
681
+ (entry.address, "the target process is no longer available")
613
682
  )
614
683
  continue
684
+ self._poller.request_write(
685
+ entry.address, spec.pytype, entry.length, value
686
+ )
615
687
 
616
688
  entry.last_value = value
617
689
  if entry.frozen:
@@ -873,7 +945,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
873
945
  "Buffer length (bytes):",
874
946
  value=spec.length or 4,
875
947
  minValue=1,
876
- maxValue=1024,
948
+ maxValue=MAX_ENTRY_LENGTH,
877
949
  )
878
950
  if not ok:
879
951
  return None