PyMemoryEditor 3.1.0__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 (204) hide show
  1. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/CONTRIBUTING.md +1 -1
  2. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/Makefile +3 -2
  3. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PKG-INFO +3 -1
  4. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/__init__.py +1 -1
  5. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_widgets.py +52 -1
  6. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/application.py +13 -6
  7. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/main_window.py +13 -8
  8. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
  9. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/functions.py +112 -5
  10. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/process.py +96 -12
  11. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/functions.py +365 -163
  12. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/libsystem.py +15 -0
  13. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/types.py +47 -1
  14. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__init__.py +4 -2
  15. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/config.py +17 -10
  16. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/server.py +40 -22
  17. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/session.py +224 -42
  18. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/toolset.py +218 -67
  19. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/abstract.py +27 -23
  20. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/pointer_scan.py +92 -13
  21. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/scanning.py +210 -96
  22. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/thread_info.py +6 -4
  23. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/pattern.py +38 -6
  24. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan.py +11 -4
  25. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/scan_numpy.py +50 -26
  26. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/functions.py +120 -73
  27. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/process.py +12 -2
  28. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/types.py +23 -16
  29. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/memory-region.md +4 -3
  30. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/openprocess.md +1 -1
  31. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/pointer-path.md +5 -1
  32. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/thread-info.md +10 -7
  33. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/conf.py +1 -1
  34. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/allocate-free.md +13 -10
  35. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/memory-regions.md +3 -2
  36. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/modules-threads.md +5 -2
  37. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/pattern-scan.md +18 -0
  38. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/pointer-scan.md +7 -2
  39. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/searching.md +16 -12
  40. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/installation.md +4 -3
  41. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/mcp.md +24 -14
  42. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/platform-notes.md +30 -3
  43. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/troubleshooting.md +5 -1
  44. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/pyproject.toml +8 -4
  45. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_smoke.py +51 -0
  46. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_widgets.py +93 -0
  47. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_server.py +142 -0
  48. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_toolset.py +688 -5
  49. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_allocate_free.py +5 -4
  50. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness_fallback_warns.py +1 -1
  51. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_linux_error_hints.py +7 -1
  52. pymemoryeditor-3.2.0/tests/platforms/test_linux_pid_reuse.py +214 -0
  53. pymemoryeditor-3.2.0/tests/platforms/test_linux_snapshot_passthrough.py +60 -0
  54. pymemoryeditor-3.2.0/tests/platforms/test_macos_load_commands.py +185 -0
  55. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_protect.py +160 -0
  56. pymemoryeditor-3.2.0/tests/platforms/test_macos_snapshot.py +69 -0
  57. pymemoryeditor-3.2.0/tests/platforms/test_macos_threads.py +171 -0
  58. pymemoryeditor-3.2.0/tests/platforms/test_macos_types.py +97 -0
  59. pymemoryeditor-3.2.0/tests/platforms/test_win32_backend_guards.py +266 -0
  60. pymemoryeditor-3.2.0/tests/platforms/test_win32_toolhelp.py +124 -0
  61. pymemoryeditor-3.2.0/tests/platforms/test_win32_wow64.py +137 -0
  62. pymemoryeditor-3.2.0/tests/pointer/test_pointer_review_fixes.py +224 -0
  63. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_chunking_integration.py +18 -17
  64. pymemoryeditor-3.2.0/tests/scan/test_scan_review_fixes.py +443 -0
  65. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/.flake8 +0 -0
  66. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/.gitignore +0 -0
  67. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/.readthedocs.yaml +0 -0
  68. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/CODE_OF_CONDUCT.md +0 -0
  69. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/LICENSE +0 -0
  70. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/__main__.py +0 -0
  71. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/__init__.py +0 -0
  72. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_auto_refresh_dialog.py +0 -0
  73. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/_icon.py +0 -0
  74. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/assets/icon.svg +0 -0
  75. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_entry.py +0 -0
  76. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_poll_worker.py +0 -0
  77. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/cheat_table.py +0 -0
  78. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/log_console_dialog.py +0 -0
  79. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_map_dialog.py +0 -0
  80. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/memory_viewer_dialog.py +0 -0
  81. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/modules_dialog.py +0 -0
  82. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/open_process_dialog.py +0 -0
  83. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/pointer_chain_dialog.py +0 -0
  84. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/results_view.py +0 -0
  85. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_types.py +0 -0
  86. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scan_worker.py +0 -0
  87. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/scanner_panel.py +0 -0
  88. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/threads_dialog.py +0 -0
  89. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/app/value_types.py +0 -0
  90. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/enums.py +0 -0
  91. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/libc.py +0 -0
  92. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/linux/types.py +0 -0
  93. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/__init__.py +0 -0
  94. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/macos/process.py +0 -0
  95. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/__main__.py +0 -0
  96. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/mcp/policy.py +0 -0
  97. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/__init__.py +0 -0
  98. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/errors.py +0 -0
  99. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/info.py +0 -0
  100. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/module_info.py +0 -0
  101. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/region.py +0 -0
  102. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/remote_pointer.py +0 -0
  103. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/process/util.py +0 -0
  104. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/py.typed +0 -0
  105. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/__init__.py +0 -0
  106. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/util/convert.py +0 -0
  107. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/__init__.py +0 -0
  108. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_allocation_states.py +0 -0
  109. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_protections.py +0 -0
  110. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/memory_types.py +0 -0
  111. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/process_operations.py +0 -0
  112. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/PyMemoryEditor/win32/enums/standard_access_rights.py +0 -0
  113. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/README.md +0 -0
  114. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/SECURITY.md +0 -0
  115. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/assets/screenshots/app.png +0 -0
  116. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/assets/social-preview.html +0 -0
  117. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/assets/social-preview.png +0 -0
  118. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/_static/.gitkeep +0 -0
  119. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/_static/custom.css +0 -0
  120. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/_templates/layout.html +0 -0
  121. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/enums.md +0 -0
  122. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/errors.md +0 -0
  123. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/index.md +0 -0
  124. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/module-info.md +0 -0
  125. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/remote-pointer.md +0 -0
  126. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/api/utilities.md +0 -0
  127. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/app.md +0 -0
  128. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/contributing.md +0 -0
  129. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/funding.md +0 -0
  130. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/glossary.md +0 -0
  131. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/logging.md +0 -0
  132. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/opening-process.md +0 -0
  133. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/pointers.md +0 -0
  134. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/guide/read-write.md +0 -0
  135. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/index.md +0 -0
  136. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/license.md +0 -0
  137. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/quickstart.md +0 -0
  138. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/requirements.txt +0 -0
  139. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/docs/why.md +0 -0
  140. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/__init__.py +0 -0
  141. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_cheat_entry.py +0 -0
  142. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_pointer_parsing.py +0 -0
  143. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_scan_request.py +0 -0
  144. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_app_value_types.py +0 -0
  145. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_auto_refresh_dialog.py +0 -0
  146. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_cheat_poll_worker.py +0 -0
  147. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_main_window_lifecycle.py +0 -0
  148. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_open_process_dialog.py +0 -0
  149. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_refine_scan_worker.py +0 -0
  150. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/app/test_results_model.py +0 -0
  151. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/conftest.py +0 -0
  152. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/core/__init__.py +0 -0
  153. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/core/test_errors.py +0 -0
  154. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/core/test_logger.py +0 -0
  155. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/__init__.py +0 -0
  156. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/conftest.py +0 -0
  157. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_argument_fuzz.py +0 -0
  158. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_fake_fidelity.py +0 -0
  159. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_live.py +0 -0
  160. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_parsing.py +0 -0
  161. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_policy.py +0 -0
  162. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_registry.py +0 -0
  163. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/mcp_server/test_session.py +0 -0
  164. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/__init__.py +0 -0
  165. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_bufflength_inference.py +0 -0
  166. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_editor.py +0 -0
  167. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_narrow_width_consistency.py +0 -0
  168. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_partial_io.py +0 -0
  169. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_read_into.py +0 -0
  170. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_search_argument_errors.py +0 -0
  171. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_str_boundary.py +0 -0
  172. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_str_decode_consistency.py +0 -0
  173. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_typed_accessors.py +0 -0
  174. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_unaligned_scan.py +0 -0
  175. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_write_safety.py +0 -0
  176. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_write_span.py +0 -0
  177. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/memory/test_write_str_bytes_width.py +0 -0
  178. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/__init__.py +0 -0
  179. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_bitness.py +0 -0
  180. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_linux_types.py +0 -0
  181. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_region_walk.py +0 -0
  182. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_macos_transient_reads.py +0 -0
  183. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_win32_permissions.py +0 -0
  184. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/platforms/test_win32_region_walk.py +0 -0
  185. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/pointer/__init__.py +0 -0
  186. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_chain.py +0 -0
  187. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/pointer/test_pointer_scan.py +0 -0
  188. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/pointer/test_remote_pointer.py +0 -0
  189. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/__init__.py +0 -0
  190. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_foreign_process.py +0 -0
  191. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_memory_region.py +0 -0
  192. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_module_enumeration.py +0 -0
  193. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_partial_name_match.py +0 -0
  194. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_process_enumeration.py +0 -0
  195. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_process_lookup.py +0 -0
  196. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/process/test_thread_enumeration.py +0 -0
  197. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/__init__.py +0 -0
  198. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_compile.py +0 -0
  199. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_pattern_scan.py +0 -0
  200. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_region_snapshot.py +0 -0
  201. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_scan.py +0 -0
  202. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_scan_numpy.py +0 -0
  203. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_scan_properties.py +0 -0
  204. {pymemoryeditor-3.1.0 → pymemoryeditor-3.2.0}/tests/scan/test_scanning_helper.py +0 -0
@@ -52,7 +52,7 @@ make pre-commit # lint + type-check + test
52
52
  ```
53
53
 
54
54
  The CI pipeline runs the same lint, mypy and tests, and blocks merges on failure.
55
- The library matrix runs Python 3.10–3.13 on Ubuntu and Windows plus a single
55
+ The library matrix runs Python 3.10–3.15 on Ubuntu and Windows plus a single
56
56
  Python 3.12 cell on macOS, so all three platform backends are exercised on every
57
57
  push. Dedicated jobs also run the suite with the `speed` extra (NumPy) to keep
58
58
  the vectorized scan path covered, and the MCP server suite once per OS. Even so,
@@ -261,11 +261,12 @@ install-docs:
261
261
  $(PIP) install -e .
262
262
  @echo "$(GREEN)Docs dependencies installed!$(NC)"
263
263
 
264
- # 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.
265
266
  .PHONY: docs
266
267
  docs:
267
268
  @echo "$(GREEN)Building HTML documentation...$(NC)"
268
- $(PYTHON) -m sphinx -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
269
+ $(PYTHON) -m sphinx -W --keep-going -b html $(DOCS_DIR) $(DOCS_BUILD_DIR)
269
270
  @echo "$(GREEN)Documentation generated at $(DOCS_BUILD_DIR)/index.html$(NC)"
270
271
 
271
272
  # Live-reload docs server — rebuilds on every save and reloads the browser.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: PyMemoryEditor
3
- Version: 3.1.0
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.1.0"
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
 
@@ -561,6 +561,7 @@ def main(argv=None):
561
561
  from PySide6.QtCore import QSettings
562
562
  from PySide6.QtWidgets import QApplication
563
563
 
564
+ from ._widgets import detached_workers_running
564
565
  from .main_window import MainWindow
565
566
  from .open_process_dialog import OpenProcessDialog
566
567
 
@@ -591,12 +592,18 @@ def main(argv=None):
591
592
  with _scoped_excepthook(_qt_excepthook):
592
593
  app.exec()
593
594
  finally:
594
- try:
595
- # `process` is only the handle the picker opened; File → Change
596
- # Process… released that one and attached the window to another.
597
- window.process.close()
598
- except Exception:
599
- 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
600
607
 
601
608
 
602
609
  def main_cli(argv=None):
@@ -46,7 +46,7 @@ from PySide6.QtWidgets import (
46
46
  from PyMemoryEditor import AbstractProcess, __version__
47
47
 
48
48
  from ._icon import app_icon
49
- from ._widgets import detach_worker
49
+ from ._widgets import detach_worker, run_after_detached_workers
50
50
  from .application import DEFAULT_THEME_ID, THEMES, apply_theme
51
51
  from .cheat_entry import CheatEntry
52
52
  from .cheat_table import CheatTable
@@ -910,13 +910,11 @@ class MainWindow(QMainWindow):
910
910
  old_cheat.shutdown()
911
911
  except Exception:
912
912
  pass
913
- # Accepted window: a worker that blew its join is detached, not
914
- # stopped, so it can still be inside a syscall here. The cheat poller
915
- # writes, which is why it checks the stop flag per entry.
916
- try:
917
- old_process.close()
918
- except Exception:
919
- pass
913
+ # A worker that blew its join is detached, not stopped, so it can still
914
+ # be inside a syscall on the old handle. Release the handle only once
915
+ # every detached worker has finished; closing it now could hand its
916
+ # value to an unrelated object while the cheat poller is mid-write.
917
+ run_after_detached_workers(lambda p=old_process: _close_quietly(p))
920
918
  old_index = self._right_splitter.indexOf(old_cheat)
921
919
  self._cheat = CheatTable(self._process)
922
920
  self._cheat.pointer_scan_for_address.connect(self._open_pointer_scan_dialog)
@@ -1049,6 +1047,13 @@ class MainWindow(QMainWindow):
1049
1047
  worker.deleteLater()
1050
1048
 
1051
1049
 
1050
+ def _close_quietly(process) -> None:
1051
+ try:
1052
+ process.close()
1053
+ except Exception: # noqa: BLE001 — the target may already be gone
1054
+ pass
1055
+
1056
+
1052
1057
  def _safe_for_json(value) -> object:
1053
1058
  if value is None or isinstance(value, (str, int, float, bool)):
1054
1059
  return value
@@ -50,7 +50,10 @@ from PySide6.QtWidgets import (
50
50
  )
51
51
 
52
52
  from PyMemoryEditor import AbstractProcess, PointerPath
53
- from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths
53
+ from PyMemoryEditor.process.pointer_scan import (
54
+ first_module_bases,
55
+ intersect_pointer_paths,
56
+ )
54
57
 
55
58
  from ._widgets import (
56
59
  MONOSPACE_FAMILY,
@@ -141,6 +144,9 @@ class PointerScanWorker(QThread):
141
144
  writable_only=req.writable_only,
142
145
  max_results=req.max_results,
143
146
  progress_callback=self._on_map_progress,
147
+ # The walk can run a long time between two paths; this lets
148
+ # Stop end it there too, not only at the next yield.
149
+ should_stop=lambda: self._cancelled,
144
150
  )
145
151
 
146
152
  batch: List[_ResultRow] = []
@@ -246,7 +252,7 @@ class PointerRescanWorker(QThread):
246
252
  def run(self) -> None:
247
253
  try:
248
254
  # One name→base lookup so rebasing each saved path is O(1).
249
- module_bases = {m.name: m.base_address for m in self._process.get_modules()}
255
+ module_bases = first_module_bases(self._process.get_modules())
250
256
 
251
257
  batch: List[_ResultRow] = []
252
258
  total = len(self._paths) or 1
@@ -284,16 +290,8 @@ class PointerRescanWorker(QThread):
284
290
  def _rebase(self, saved: PointerPath, module_bases: dict) -> Optional[PointerPath]:
285
291
  """Move a saved path onto the live module base, or None if not rebasable."""
286
292
  if saved.module is not None and saved.module_offset is not None:
287
- base = module_bases.get(saved.module)
288
- if base is None:
289
- return None # module not loaded in this run — drop the path
290
- return PointerPath(
291
- base_address=base + saved.module_offset,
292
- offsets=saved.offsets,
293
- module=saved.module,
294
- module_offset=saved.module_offset,
295
- ptr_size=saved.ptr_size,
296
- )
293
+ # None when the module isn't loaded in this run — drop the path.
294
+ return saved._rebased_onto(module_bases)
297
295
  # No module: the absolute base is only a best-effort guess post-restart.
298
296
  return saved
299
297
 
@@ -979,23 +977,23 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
979
977
  # For display, rebase each survivor onto the current process and read its
980
978
  # value — best effort; a path that can't resolve now is still a valid
981
979
  # member of the intersection and is kept (shown dimmed / "—").
982
- module_bases = {m.name: m.base_address for m in self._process.get_modules()}
980
+ unavailable = ""
981
+ try:
982
+ module_bases = first_module_bases(self._process.get_modules())
983
+ except Exception as exc: # noqa: BLE001 — display only; the target may be gone
984
+ # Without the module list every survivor is shown unresolved ("—"),
985
+ # which is what the per-path guard below does for a single one —
986
+ # but say why, or a table of dimmed rows reads as "nothing works".
987
+ module_bases = {}
988
+ unavailable = (
989
+ f" Values not shown: the target's modules could not be listed "
990
+ f"({type(exc).__name__}: {exc})."
991
+ )
983
992
  spec, length = self._current_spec()
984
993
 
985
994
  rows: List[_ResultRow] = []
986
995
  for saved in survivors:
987
- base = module_bases.get(saved.module)
988
- live = (
989
- PointerPath(
990
- base_address=base + saved.module_offset,
991
- offsets=saved.offsets,
992
- module=saved.module,
993
- module_offset=saved.module_offset,
994
- ptr_size=saved.ptr_size,
995
- )
996
- if base is not None and saved.module_offset is not None
997
- else saved
998
- )
996
+ live = saved._rebased_onto(module_bases) or saved
999
997
  try:
1000
998
  resolved = live.resolve(self._process)
1001
999
  value = self._process.read_process_memory(resolved, spec.pytype, length)
@@ -1008,6 +1006,7 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
1008
1006
  self._append_rows(rows)
1009
1007
  self._count_label.setText(
1010
1008
  f"{len(survivors):,} path(s) common to {len(filenames)} scans."
1009
+ + unavailable
1011
1010
  )
1012
1011
  self._export_btn.setEnabled(True)
1013
1012
 
@@ -10,6 +10,7 @@ import ctypes
10
10
  import errno as errno_mod
11
11
  import logging
12
12
  import os
13
+ import signal
13
14
  from ctypes import addressof, sizeof
14
15
  from typing import Dict, Generator, Optional, Sequence, Tuple, Type, TypeVar, Union
15
16
 
@@ -20,6 +21,7 @@ from ..process.region import (
20
21
  MemoryRegion,
21
22
  default_address_filter,
22
23
  default_scan_filter,
24
+ MemoryRegionSnapshot,
23
25
  make_region,
24
26
  )
25
27
  from ..process.scanning import (
@@ -84,6 +86,11 @@ _PTRACE_SCOPE_HINT = (
84
86
  )
85
87
 
86
88
 
89
+ def _errno_or(exc: OSError, default: int) -> int:
90
+ """``exc.errno``, or ``default`` when the exception was raised without one."""
91
+ return exc.errno if exc.errno is not None else default
92
+
93
+
87
94
  def _syscall_error(op: str, errno: int, remote_address: int, length: int) -> OSError:
88
95
  """
89
96
  Build the ``OSError`` for a failed ``process_vm_readv`` / ``process_vm_writev``.
@@ -225,10 +232,11 @@ def get_memory_regions(pid: int) -> Generator["MemoryRegion", None, None]:
225
232
  # prctl(PR_SET_DUMPABLE, 0)). The ptrace_scope hint belongs to the
226
233
  # EPERM from process_vm_readv/writev, not to this path.
227
234
  raise PermissionError(
235
+ _errno_or(exc, errno_mod.EACCES),
228
236
  "Cannot read %s: %s. The target process belongs to another user "
229
237
  "or is not dumpable; run as root or as the owner of the target."
230
- % (mapping_filename, exc)
231
- )
238
+ % (mapping_filename, exc),
239
+ ) from exc
232
240
 
233
241
  with mapping_file:
234
242
  for line in mapping_file:
@@ -552,7 +560,11 @@ def search_values_by_addresses(
552
560
  memory_regions = [
553
561
  region for region in get_memory_regions(pid) if default_address_filter(region)
554
562
  ]
555
- else:
563
+ elif not isinstance(memory_regions, MemoryRegionSnapshot):
564
+ # Materialize any other iterable (it is indexed and walked more than
565
+ # once), but pass a snapshot through untouched: `list(...)` would drop
566
+ # its type tag and make `_ensure_sorted_by_address` re-sort an
567
+ # already-sorted region map on every refine scan.
556
568
  memory_regions = list(memory_regions)
557
569
 
558
570
  yield from iter_values_for_addresses(
@@ -615,10 +627,11 @@ def get_threads(pid: int) -> Generator[ThreadInfo, None, None]:
615
627
  return
616
628
  except PermissionError as exc:
617
629
  raise PermissionError(
630
+ _errno_or(exc, errno_mod.EACCES),
618
631
  "Cannot list %s: %s. The target process belongs to another user "
619
632
  "or is not dumpable; run as root or as the owner of the target."
620
- % (task_dir, exc)
621
- )
633
+ % (task_dir, exc),
634
+ ) from exc
622
635
  except OSError as exc:
623
636
  _logger.debug("get_threads: could not list %s: %s", task_dir, exc)
624
637
  return
@@ -701,3 +714,97 @@ def process_exists(pid: int) -> bool:
701
714
  if pid < 0:
702
715
  return False
703
716
  return os.path.isdir("/proc/{}".format(pid))
717
+
718
+
719
+ # --- Target identity (PID-reuse guard) --------------------------------------
720
+ #
721
+ # Unlike a Windows process handle or a macOS task port, nothing on the Linux
722
+ # path pins the target: process_vm_readv / process_vm_writev and /proc take a
723
+ # bare pid. Once the target exits and is reaped, the kernel may hand that pid
724
+ # to an unrelated process, and every later call would silently act on it. The
725
+ # helpers below let LinuxProcess bind to the *original* process at open time
726
+ # and verify it before touching the target again.
727
+
728
+
729
+ def open_pidfd(pid: int) -> Optional[int]:
730
+ """
731
+ Return a pidfd for ``pid`` (``pidfd_open(2)``, Linux 5.3+), or ``None``
732
+ when pidfds are unavailable: the kernel predates them (``ENOSYS``), a
733
+ seccomp policy blocks the syscall (``EPERM``, e.g. older container
734
+ runtimes), ``pid`` is a non-leader thread id (``EINVAL``), the fd table is
735
+ full, or this Python was built without ``os.pidfd_open`` /
736
+ ``signal.pidfd_send_signal``. Callers then fall back to
737
+ :func:`read_start_time`.
738
+
739
+ Raises :class:`~PyMemoryEditor.ProcessIDNotExistsError` when the process
740
+ is already gone (``ESRCH``) — it exited after the open-time existence
741
+ check.
742
+ """
743
+ pidfd_open = getattr(os, "pidfd_open", None)
744
+ if pidfd_open is None or not hasattr(signal, "pidfd_send_signal"):
745
+ return None
746
+ try:
747
+ return pidfd_open(pid, 0)
748
+ except ProcessLookupError:
749
+ raise ProcessIDNotExistsError(pid)
750
+ except OSError as exc:
751
+ _logger.debug(
752
+ "open_pidfd: pidfd_open(%d) failed (%s); falling back to the "
753
+ "/proc start-time check for PID-reuse detection.",
754
+ pid,
755
+ exc,
756
+ )
757
+ return None
758
+
759
+
760
+ def pidfd_alive(pidfd: int) -> Optional[bool]:
761
+ """
762
+ Whether the process ``pidfd`` refers to still exists, via a null signal
763
+ (``pidfd_send_signal(pidfd, 0, NULL, 0)``): one syscall, no side effect.
764
+
765
+ The answer is about the original process, not its pid number — after the
766
+ target is reaped the call fails with ``ESRCH`` even if the pid has already
767
+ been reused. A zombie still counts as alive (it keeps its pid until it is
768
+ reaped), matching the ``kill(pid, 0)`` semantics of the base class.
769
+ ``EPERM`` (target owned by another user) also means "exists". Returns
770
+ ``None`` when the probe itself cannot answer (e.g. ``EINVAL`` for a
771
+ target outside this pid namespace), so the caller can fall back.
772
+ """
773
+ try:
774
+ signal.pidfd_send_signal(pidfd, 0)
775
+ except ProcessLookupError:
776
+ return False
777
+ except PermissionError:
778
+ return True
779
+ except OSError as exc:
780
+ _logger.debug("pidfd_alive: pidfd_send_signal failed: %s", exc)
781
+ return None
782
+ return True
783
+
784
+
785
+ def read_start_time(pid: int) -> Optional[int]:
786
+ """
787
+ Return the process start time (field 22 of ``/proc/<pid>/stat``, in clock
788
+ ticks since boot), or ``None`` if the file can't be read or parsed (the
789
+ process is gone, or ``/proc`` is mounted with ``hidepid``).
790
+
791
+ ``(pid, start time)`` identifies a process uniquely — a recycled pid gets
792
+ a later start time — which makes it the PID-reuse guard on kernels
793
+ without pidfds.
794
+ """
795
+ try:
796
+ with open("/proc/{}/stat".format(pid), "rb") as fh:
797
+ raw_stat = fh.read()
798
+ except OSError:
799
+ return None
800
+
801
+ # `comm` is parenthesised and may itself contain spaces or ')': split
802
+ # after the *last* ')'. rest[0] is field 3 (state), so field 22 is rest[19].
803
+ close_paren = raw_stat.rfind(b")")
804
+ if close_paren == -1:
805
+ return None
806
+ rest = raw_stat[close_paren + 1 :].split()
807
+ try:
808
+ return int(rest[19])
809
+ except (IndexError, ValueError):
810
+ return None
@@ -1,11 +1,13 @@
1
1
  # -*- coding: utf-8 -*-
2
2
 
3
+ import logging
4
+ import os
3
5
  import warnings
4
6
  from typing import Generator, Optional, Sequence, Tuple, Type, TypeVar, Union
5
7
 
6
8
  from ..enums import ScanTypesEnum
7
9
  from ..process import AbstractProcess
8
- from ..process.errors import ClosedProcess
10
+ from ..process.errors import ClosedProcess, ProcessIDNotExistsError
9
11
  from ..util import (
10
12
  UNSET,
11
13
  prepare_write,
@@ -20,15 +22,22 @@ from .functions import (
20
22
  get_memory_regions,
21
23
  get_modules,
22
24
  get_threads,
25
+ open_pidfd,
26
+ pidfd_alive,
27
+ process_exists,
23
28
  read_process_memory,
24
29
  read_process_memory_into,
25
30
  search_addresses_by_pattern,
26
31
  search_addresses_by_value,
27
32
  search_values_by_addresses,
33
+ read_start_time,
28
34
  write_process_memory,
29
35
  )
30
36
 
31
37
 
38
+ _logger = logging.getLogger("PyMemoryEditor")
39
+
40
+
32
41
  T = TypeVar("T")
33
42
 
34
43
 
@@ -69,6 +78,17 @@ class LinuxProcess(AbstractProcess):
69
78
  exact_match=exact_match,
70
79
  strict_bitness=strict_bitness,
71
80
  )
81
+
82
+ # Bind to *this* process, not to its pid number. Linux I/O takes a
83
+ # bare pid (no handle / task port pins the target as on Windows and
84
+ # macOS), so once the target exits and is reaped the pid can be
85
+ # recycled and every later call would silently hit an unrelated
86
+ # process. A pidfd refers to the original process for its whole
87
+ # lifetime; where pidfds are unavailable, the /proc start time —
88
+ # unique per (pid, boot) — stands in. Both are verified before each
89
+ # operation (see `__require_target`).
90
+ self.__pidfd: Optional[int] = open_pidfd(self.pid)
91
+ self.__start_time: Optional[int] = read_start_time(self.pid)
72
92
  self.__closed = False
73
93
 
74
94
  # `permission` is accepted for cross-platform parity but has no effect
@@ -87,24 +107,88 @@ class LinuxProcess(AbstractProcess):
87
107
  if self.__closed:
88
108
  raise ClosedProcess()
89
109
 
110
+ def __require_target(self, *, hot_path: bool = False) -> None:
111
+ """
112
+ Raise ``ClosedProcess`` after ``close()``, and
113
+ :class:`~PyMemoryEditor.ProcessIDNotExistsError` once the process this
114
+ object was opened on has exited — even if its pid now belongs to
115
+ another process. One ``pidfd_send_signal`` per call on kernels with
116
+ pidfds (5.3+, ~0.3 us); a ``/proc/<pid>/stat`` read otherwise.
117
+
118
+ ``hot_path`` marks single-value reads, which callers issue in tight
119
+ loops: they are verified only when the cheap pidfd probe is
120
+ available. Without a pidfd the start-time read would roughly
121
+ quadruple the cost of every read, so there they keep the pre-pidfd
122
+ behaviour. Writes, scans and listings are always verified.
123
+ """
124
+ self.__require_open()
125
+ if hot_path and self.__pidfd is None:
126
+ return
127
+ if not self._target_alive():
128
+ raise ProcessIDNotExistsError(self.pid)
129
+
130
+ def _target_alive(self) -> bool:
131
+ pidfd = self.__pidfd
132
+ if pidfd is not None:
133
+ alive = pidfd_alive(pidfd)
134
+ if alive is not None:
135
+ return alive
136
+ if self.__start_time is not None:
137
+ current = read_start_time(self.pid)
138
+ if current is not None:
139
+ return current == self.__start_time
140
+ # Unreadable now: gone (no /proc entry) or hidden — ask by pid.
141
+ return process_exists(self.pid)
142
+ return super()._target_alive()
143
+
90
144
  def close(self) -> bool:
145
+ if self.__closed:
146
+ return True
147
+
91
148
  self.__closed = True
149
+ pidfd, self.__pidfd = self.__pidfd, None
150
+ if pidfd is not None:
151
+ try:
152
+ os.close(pidfd)
153
+ except OSError as exc:
154
+ # Never raise from close() (`__exit__` / `__del__` call it);
155
+ # report through the return value like the Windows backend.
156
+ _logger.warning(
157
+ "Closing the pidfd of process %d failed: %s", self.pid, exc
158
+ )
159
+ return False
92
160
  return True
93
161
 
162
+ def __del__(self) -> None:
163
+ """
164
+ Best-effort safety net for callers who forget to ``close()`` / use
165
+ the context manager: releases the pidfd, mirroring how the Windows and
166
+ macOS backends release their handle / task port. ``__del__`` is not
167
+ guaranteed to run, so this is only a fallback.
168
+ """
169
+ # Construction failed before the pidfd was set: nothing to release.
170
+ if getattr(self, "_LinuxProcess__closed", True):
171
+ return
172
+ try:
173
+ self.close()
174
+ except Exception:
175
+ # __del__ must not raise; the interpreter may be shutting down.
176
+ pass
177
+
94
178
  def _detect_is_64bit(self) -> Optional[bool]:
95
- self.__require_open()
179
+ self.__require_target()
96
180
  return _detect_process_64bit(self.pid)
97
181
 
98
182
  def get_memory_regions(self) -> Generator[MemoryRegion, None, None]:
99
- self.__require_open()
183
+ self.__require_target()
100
184
  return get_memory_regions(self.pid)
101
185
 
102
186
  def get_threads(self) -> Generator[ThreadInfo, None, None]:
103
- self.__require_open()
187
+ self.__require_target()
104
188
  return get_threads(self.pid)
105
189
 
106
190
  def get_modules(self) -> Generator[ModuleInfo, None, None]:
107
- self.__require_open()
191
+ self.__require_target()
108
192
  return get_modules(self.pid)
109
193
 
110
194
  def read_process_memory(
@@ -113,13 +197,13 @@ class LinuxProcess(AbstractProcess):
113
197
  pytype: Type[T],
114
198
  bufflength: Optional[int] = None,
115
199
  ) -> T:
116
- self.__require_open()
200
+ self.__require_target(hot_path=True)
117
201
  return read_process_memory(
118
202
  self.pid, address, pytype, resolve_bufflength(pytype, bufflength)
119
203
  )
120
204
 
121
205
  def read_process_memory_into(self, address: int, buffer: object) -> int:
122
- self.__require_open()
206
+ self.__require_target(hot_path=True)
123
207
  return read_process_memory_into(self.pid, address, buffer)
124
208
 
125
209
  def search_by_addresses(
@@ -131,7 +215,7 @@ class LinuxProcess(AbstractProcess):
131
215
  raise_error: bool = False,
132
216
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
133
217
  ) -> Generator[Tuple[int, Optional[T]], None, None]:
134
- self.__require_open()
218
+ self.__require_target()
135
219
  if addresses is UNSET:
136
220
  raise TypeError("addresses is required.")
137
221
  return search_values_by_addresses(
@@ -155,7 +239,7 @@ class LinuxProcess(AbstractProcess):
155
239
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
156
240
  aligned: bool = True,
157
241
  ) -> Generator[Union[int, Tuple[int, dict]], None, None]:
158
- self.__require_open()
242
+ self.__require_target()
159
243
 
160
244
  if scan_type in [ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN]:
161
245
  raise ValueError(
@@ -182,7 +266,7 @@ class LinuxProcess(AbstractProcess):
182
266
  progress_information: bool = False,
183
267
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
184
268
  ) -> Generator[Union[int, Tuple[int, dict]], None, None]:
185
- self.__require_open()
269
+ self.__require_target()
186
270
  return search_addresses_by_pattern(
187
271
  self.pid,
188
272
  pattern,
@@ -204,7 +288,7 @@ class LinuxProcess(AbstractProcess):
204
288
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
205
289
  aligned: bool = True,
206
290
  ) -> Generator[Union[int, Tuple[int, dict]], None, None]:
207
- self.__require_open()
291
+ self.__require_target()
208
292
 
209
293
  scan_type = (
210
294
  ScanTypesEnum.NOT_VALUE_BETWEEN
@@ -230,7 +314,7 @@ class LinuxProcess(AbstractProcess):
230
314
  bufflength: Optional[int] = None,
231
315
  value: Union[bool, int, float, str, bytes] = UNSET,
232
316
  ) -> Union[bool, int, float, str, bytes]:
233
- self.__require_open()
317
+ self.__require_target()
234
318
  w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value)
235
319
  write_process_memory(self.pid, address, w_pytype, w_length, w_value)
236
320
  return value