PyMemoryEditor 2.0.1__tar.gz → 2.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.
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PKG-INFO +2 -28
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/__init__.py +1 -1
- pymemoryeditor-2.2.0/PyMemoryEditor/__main__.py +4 -0
- pymemoryeditor-2.2.0/PyMemoryEditor/app/__init__.py +11 -0
- pymemoryeditor-2.2.0/PyMemoryEditor/app/_auto_refresh_dialog.py +244 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/_widgets.py +101 -9
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/application.py +61 -3
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/cheat_poll_worker.py +12 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/cheat_table.py +136 -14
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/log_console_dialog.py +4 -3
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/main_window.py +49 -33
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/memory_map_dialog.py +17 -4
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/memory_viewer_dialog.py +59 -19
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/modules_dialog.py +17 -4
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/open_process_dialog.py +70 -21
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/pointer_chain_dialog.py +13 -1
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/pointer_scan_dialog.py +27 -9
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/scan_worker.py +58 -20
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/scanner_panel.py +243 -44
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/threads_dialog.py +23 -1
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/value_types.py +172 -1
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/macos/functions.py +11 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/macos/types.py +12 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/abstract.py +10 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/util/pattern.py +36 -11
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/README.md +0 -26
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/pyproject.toml +4 -1
- pymemoryeditor-2.2.0/tests/app/test_app_cheat_entry.py +490 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/test_app_scan_request.py +113 -3
- pymemoryeditor-2.2.0/tests/app/test_app_smoke.py +654 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/test_app_value_types.py +35 -0
- pymemoryeditor-2.2.0/tests/app/test_app_widgets.py +87 -0
- pymemoryeditor-2.2.0/tests/app/test_auto_refresh_dialog.py +475 -0
- pymemoryeditor-2.2.0/tests/app/test_open_process_dialog.py +246 -0
- pymemoryeditor-2.2.0/tests/platforms/test_macos_transient_reads.py +81 -0
- pymemoryeditor-2.0.1/PyMemoryEditor/__main__.py +0 -4
- pymemoryeditor-2.0.1/PyMemoryEditor/app/__init__.py +0 -9
- pymemoryeditor-2.0.1/PyMemoryEditor/app/_auto_refresh_dialog.py +0 -131
- pymemoryeditor-2.0.1/tests/app/test_app_cheat_entry.py +0 -85
- pymemoryeditor-2.0.1/tests/app/test_app_smoke.py +0 -164
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/.flake8 +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/.gitignore +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/.readthedocs.yaml +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/CODE_OF_CONDUCT.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/CONTRIBUTING.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/LICENSE +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/Makefile +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/_icon.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/assets/icon.svg +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/cheat_entry.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/results_view.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/app/scan_types.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/enums.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/linux/functions.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/linux/libc.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/linux/process.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/linux/types.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/macos/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/macos/libsystem.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/macos/process.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/errors.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/info.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/module_info.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/pointer_scan.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/region.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/remote_pointer.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/scanning.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/thread_info.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/process/util.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/py.typed +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/util/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/util/convert.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/util/scan.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/util/scan_numpy.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/memory_allocation_states.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/memory_protections.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/memory_types.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/process_operations.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/enums/standard_access_rights.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/functions.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/process.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/PyMemoryEditor/win32/types.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/SECURITY.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/assets/screenshots/app.png +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/assets/social-preview.html +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/assets/social-preview.png +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/_static/.gitkeep +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/_static/custom.css +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/_templates/layout.html +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/enums.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/errors.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/index.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/memory-region.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/module-info.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/openprocess.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/pointer-path.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/remote-pointer.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/thread-info.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/api/utilities.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/app.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/conf.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/contributing.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/funding.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/glossary.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/allocate-free.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/index.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/logging.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/memory-regions.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/modules-threads.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/opening-process.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/pattern-scan.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/pointer-scan.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/pointers.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/read-write.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/guide/searching.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/index.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/installation.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/license.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/platform-notes.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/quickstart.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/requirements.txt +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/troubleshooting.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/docs/why.md +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/test_app_pointer_parsing.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/test_cheat_poll_worker.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/app/test_refine_scan_worker.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/conftest.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/core/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/core/test_errors.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/core/test_logger.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_allocate_free.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_bufflength_inference.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_editor.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_partial_io.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_read_into.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_str_boundary.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_str_decode_consistency.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_typed_accessors.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_write_safety.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/memory/test_write_str_bytes_width.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/test_bitness.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/test_bitness_fallback_warns.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/test_linux_types.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/test_macos_protect.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/platforms/test_win32_permissions.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/pointer/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/pointer/test_pointer_chain.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/pointer/test_pointer_scan.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/pointer/test_remote_pointer.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_memory_region.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_module_enumeration.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_partial_name_match.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_process_enumeration.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_process_lookup.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/process/test_thread_enumeration.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/__init__.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_chunking_integration.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_pattern_compile.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_pattern_scan.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_region_snapshot.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_scan.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_scan_numpy.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_scan_properties.py +0 -0
- {pymemoryeditor-2.0.1 → pymemoryeditor-2.2.0}/tests/scan/test_scanning_helper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: PyMemoryEditor
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.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
|
|
@@ -138,32 +138,6 @@ That's it — read, write or scan another process in three lines, the same way o
|
|
|
138
138
|
|
|
139
139
|
---
|
|
140
140
|
|
|
141
|
-
## What's inside
|
|
142
|
-
|
|
143
|
-
### 🐍 The Python library
|
|
144
|
-
|
|
145
|
-
Full control over another process's memory — in a few lines of Python:
|
|
146
|
-
|
|
147
|
-
- ✅ **Read & write** values (`int`, `float`, `bool`, `str`, `bytes`)
|
|
148
|
-
- 🔍 **Value scan** with eight comparison modes
|
|
149
|
-
- 🎯 **Pattern scan** (IDA-style AOB & regex)
|
|
150
|
-
- 🔗 **Pointer chains** + a live `RemotePointer` handle
|
|
151
|
-
- 🧭 **Pointer scan** — find static pointers that survive ASLR
|
|
152
|
-
- 🗺️ **Memory map**, **modules**, **threads**
|
|
153
|
-
- 🧱 **Allocate & free** remote memory (Windows / macOS)
|
|
154
|
-
|
|
155
|
-
### 🖥️ The bundled GUI app
|
|
156
|
-
|
|
157
|
-
All the library's power — no code required:
|
|
158
|
-
- ⚡ **Zero setup** — attach to a process and start scanning in seconds
|
|
159
|
-
- 🧲 **Refine workflow** — First Scan → Next Scan with live visual feedback
|
|
160
|
-
- 📋 **Cheat table** — freeze / write values on the fly, JSON import/export
|
|
161
|
-
- 🔬 **Hex viewer** — browse raw memory and write back inline
|
|
162
|
-
- 🧩 **Pointer scan UI** — scan, export & rescan across sessions with a few clicks
|
|
163
|
-
- 🎨 **One-click access** — every feature at your fingertips, no code needed
|
|
164
|
-
|
|
165
|
-
---
|
|
166
|
-
|
|
167
141
|
## 📖 Documentation
|
|
168
142
|
|
|
169
143
|
Full documentation lives at **[pymemoryeditor.readthedocs.io](https://pymemoryeditor.readthedocs.io)** — installation, the Cheat Engine workflow, every method and parameter, the GUI app guide, platform notes and troubleshooting.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
PyMemoryEditor App.
|
|
4
|
+
|
|
5
|
+
A Cheat-Engine-inspired memory editor built on PySide6 (Qt for Python).
|
|
6
|
+
Cross-platform: works on Windows, Linux and macOS.
|
|
7
|
+
|
|
8
|
+
Entry points: PyMemoryEditor.app.application:main_cli for the console script
|
|
9
|
+
(adds Ctrl+C handling), :main to run the app in-process without touching the
|
|
10
|
+
caller's signal handlers.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Shared base for the read-only, auto-refreshing table dialogs (Memory Map,
|
|
4
|
+
Modules, Threads).
|
|
5
|
+
|
|
6
|
+
All three did the same dance — run a one-shot read off the UI thread, repopulate
|
|
7
|
+
a sortable table when it returns, poll on a timer, and tear the worker down
|
|
8
|
+
safely on close — each with its own near-identical ``_*Worker(QThread)`` and
|
|
9
|
+
lifecycle boilerplate. That triplicated the fiddly bits (the in-flight guard
|
|
10
|
+
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
|
|
11
|
+
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
|
|
12
|
+
only what differs: how to fetch the data, and how to render it.
|
|
13
|
+
|
|
14
|
+
It also owns *when* a failure is worth interrupting the user — see
|
|
15
|
+
:meth:`AutoRefreshTableDialog._handle_failed`.
|
|
16
|
+
"""
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
|
|
20
|
+
from PySide6.QtWidgets import QDialog
|
|
21
|
+
|
|
22
|
+
from ._widgets import TearsDownOnClose, shutdown_worker_thread
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# How long a target may keep failing before the auto-refresh gives up. Not
|
|
26
|
+
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
|
|
27
|
+
# transiently while a target loads images), so a blip must not freeze a window
|
|
28
|
+
# that would recover next tick. In wall-clock, not ticks: the intervals differ
|
|
29
|
+
# by more than 10x across the windows that share this budget.
|
|
30
|
+
_FAILURE_GRACE_MS = 3000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _DataWorker(QThread):
|
|
34
|
+
"""Runs a no-arg ``fetch`` callable off the UI thread, once."""
|
|
35
|
+
|
|
36
|
+
ready = Signal(object)
|
|
37
|
+
failed = Signal(str)
|
|
38
|
+
|
|
39
|
+
def __init__(self, fetch: Callable[[], object], parent=None):
|
|
40
|
+
super().__init__(parent)
|
|
41
|
+
self._fetch = fetch
|
|
42
|
+
|
|
43
|
+
def run(self) -> None:
|
|
44
|
+
try:
|
|
45
|
+
data = self._fetch()
|
|
46
|
+
except Exception as exc: # noqa: BLE001 — surfaced to the UI as a string
|
|
47
|
+
self.failed.emit(str(exc))
|
|
48
|
+
return
|
|
49
|
+
self.ready.emit(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
|
|
53
|
+
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.
|
|
54
|
+
|
|
55
|
+
Subclasses must:
|
|
56
|
+
|
|
57
|
+
* call ``super().__init__(process, refresh_interval_ms=..., parent=...)``,
|
|
58
|
+
then build their UI, then ``refresh()``, then ``_start_auto_refresh()``;
|
|
59
|
+
* implement :meth:`_fetch_data` (runs in the worker thread) and
|
|
60
|
+
:meth:`_on_data_ready` (runs on the UI thread);
|
|
61
|
+
* implement :meth:`_on_data_failed` and (optionally) :meth:`_set_loading_hint`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, process, *, refresh_interval_ms: int, parent=None):
|
|
65
|
+
super().__init__(parent)
|
|
66
|
+
self._process = process
|
|
67
|
+
self._worker: Optional[_DataWorker] = None
|
|
68
|
+
self._has_data = False
|
|
69
|
+
self._refresh_interval_ms = refresh_interval_ms
|
|
70
|
+
self._auto_timer: Optional[QTimer] = None
|
|
71
|
+
# Failure state, all reset by a success or an explicit refresh(). See
|
|
72
|
+
# _handle_failed for how they decide what reaches the user.
|
|
73
|
+
self._last_error: Optional[str] = None # doubles as "already reported"
|
|
74
|
+
self._failing_since: Optional[QElapsedTimer] = None # streak clock
|
|
75
|
+
# Read by subclasses that rebuild their status line on user input, so
|
|
76
|
+
# the "stopped" note isn't replaced by a count that looks live.
|
|
77
|
+
self._polling_gave_up = False
|
|
78
|
+
|
|
79
|
+
def _start_auto_refresh(self) -> None:
|
|
80
|
+
"""Begin polling. Call once, after the first ``refresh()``.
|
|
81
|
+
|
|
82
|
+
The ``refresh()`` in-flight guard self-throttles the cadence to however
|
|
83
|
+
long a fetch actually takes, so the timer can't stack workers on a slow
|
|
84
|
+
target.
|
|
85
|
+
"""
|
|
86
|
+
self._auto_timer = QTimer(self)
|
|
87
|
+
self._auto_timer.setInterval(self._refresh_interval_ms)
|
|
88
|
+
self._auto_timer.timeout.connect(self._auto_refresh_tick)
|
|
89
|
+
self._auto_timer.start()
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------ #
|
|
92
|
+
# Hooks for subclasses
|
|
93
|
+
# ------------------------------------------------------------------ #
|
|
94
|
+
|
|
95
|
+
def _fetch_data(self):
|
|
96
|
+
"""Read the data to display. Runs in the worker thread; may raise."""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
def _on_data_ready(self, data) -> None:
|
|
100
|
+
"""Render ``data`` into the table. Runs on the UI thread."""
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
def _on_data_failed(self, message: str) -> None:
|
|
104
|
+
"""Report a failed fetch. Runs on the UI thread.
|
|
105
|
+
|
|
106
|
+
Called only for a failure the user needs to see — the first fetch, or
|
|
107
|
+
the one that makes the poll give up — and never twice for the same
|
|
108
|
+
error (see :meth:`_handle_failed`), so a modal is safe here.
|
|
109
|
+
"""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def _set_loading_hint(self) -> None:
|
|
113
|
+
"""Optionally show a one-time loading message before the first fetch."""
|
|
114
|
+
|
|
115
|
+
def _on_polling_stopped(self) -> None:
|
|
116
|
+
"""Note that the auto-refresh gave up. Runs on the UI thread.
|
|
117
|
+
|
|
118
|
+
Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
|
|
119
|
+
The table is frozen on its last good data from here on, so subclasses
|
|
120
|
+
should say so. Optional.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ #
|
|
124
|
+
# Lifecycle (shared)
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
|
|
127
|
+
def refresh(self) -> None:
|
|
128
|
+
"""Fetch now, on the user's behalf.
|
|
129
|
+
|
|
130
|
+
Clears the error latch and restarts a poll that repeated failures
|
|
131
|
+
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
|
|
132
|
+
which must *not* re-arm — and neither may a dismissed dialog, whose
|
|
133
|
+
teardown latch is one-way.
|
|
134
|
+
"""
|
|
135
|
+
if self._is_dismissed():
|
|
136
|
+
return
|
|
137
|
+
self._last_error = None
|
|
138
|
+
self._failing_since = None
|
|
139
|
+
self._polling_gave_up = False
|
|
140
|
+
if self._auto_timer is not None and not self._auto_timer.isActive():
|
|
141
|
+
self._auto_timer.start()
|
|
142
|
+
self._start_fetch()
|
|
143
|
+
|
|
144
|
+
def _auto_refresh_tick(self) -> None:
|
|
145
|
+
"""Timer-driven fetch — never re-arms after a failure."""
|
|
146
|
+
self._start_fetch()
|
|
147
|
+
|
|
148
|
+
def _start_fetch(self) -> None:
|
|
149
|
+
# A worker started after the teardown could never be joined again.
|
|
150
|
+
if self._is_dismissed():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Skip if a fetch is already in flight — the timer would otherwise stack
|
|
154
|
+
# workers on a slow target. This self-throttles to the real fetch time.
|
|
155
|
+
if self._worker is not None and self._worker.isRunning():
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Loading hint only before the first successful fetch; the periodic
|
|
159
|
+
# refresh updates silently to avoid flicker.
|
|
160
|
+
if not self._has_data:
|
|
161
|
+
self._set_loading_hint()
|
|
162
|
+
|
|
163
|
+
worker = _DataWorker(self._fetch_data, self)
|
|
164
|
+
worker.ready.connect(self._handle_ready)
|
|
165
|
+
worker.failed.connect(self._handle_failed)
|
|
166
|
+
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
|
|
167
|
+
self._worker = worker
|
|
168
|
+
worker.start()
|
|
169
|
+
|
|
170
|
+
def _handle_ready(self, data) -> None:
|
|
171
|
+
if self._is_dismissed():
|
|
172
|
+
return
|
|
173
|
+
self._has_data = True
|
|
174
|
+
self._last_error = None
|
|
175
|
+
self._failing_since = None
|
|
176
|
+
self._on_data_ready(data)
|
|
177
|
+
|
|
178
|
+
def _handle_failed(self, message: str) -> None:
|
|
179
|
+
"""Handle a failed fetch: report it only when the user needs to know.
|
|
180
|
+
|
|
181
|
+
Once the target is gone (or its handle was closed by File → Change
|
|
182
|
+
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
|
|
183
|
+
dialog. A modal runs a nested event loop, so the timer fires again while
|
|
184
|
+
it is up and the failures stack — hundreds of focus-stealing dialogs the
|
|
185
|
+
user could only escape by killing the app from another TTY (issue #74).
|
|
186
|
+
|
|
187
|
+
Reporting is therefore tied to the two moments where a failure changes
|
|
188
|
+
what the user sees: the **first** fetch (empty window) and the one that
|
|
189
|
+
makes the poll **give up** (frozen table). Everything in between heals
|
|
190
|
+
quietly — and that silence is load-bearing: reporting every failure
|
|
191
|
+
re-opens the spam, since a target failing on alternating ticks stacks a
|
|
192
|
+
modal per failure until the stack runs out.
|
|
193
|
+
|
|
194
|
+
A dismissed dialog reports nothing. The teardown disconnects the worker,
|
|
195
|
+
but a delivery already queued then must not raise a modal over a window
|
|
196
|
+
that is gone — during ``MainWindow.closeEvent`` that would be a nested
|
|
197
|
+
event loop in the middle of teardown.
|
|
198
|
+
"""
|
|
199
|
+
if self._is_dismissed():
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if self._failing_since is None:
|
|
203
|
+
self._failing_since = QElapsedTimer()
|
|
204
|
+
self._failing_since.start()
|
|
205
|
+
gave_up = False
|
|
206
|
+
else:
|
|
207
|
+
gave_up = (
|
|
208
|
+
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
|
|
209
|
+
and self._auto_timer is not None
|
|
210
|
+
and self._auto_timer.isActive()
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if gave_up and self._auto_timer is not None:
|
|
214
|
+
self._auto_timer.stop()
|
|
215
|
+
self._polling_gave_up = True
|
|
216
|
+
|
|
217
|
+
never_reported_yet = not self._has_data and self._last_error is None
|
|
218
|
+
if (gave_up or never_reported_yet) and message != self._last_error:
|
|
219
|
+
self._last_error = message
|
|
220
|
+
self._on_data_failed(message)
|
|
221
|
+
|
|
222
|
+
# After the report, so the "stopped" note is what stays on screen.
|
|
223
|
+
if gave_up:
|
|
224
|
+
self._on_polling_stopped()
|
|
225
|
+
|
|
226
|
+
def _on_worker_finished(self, worker) -> None:
|
|
227
|
+
"""Retire the worker that just finished — and only that one.
|
|
228
|
+
|
|
229
|
+
``finished`` is queued, so it can arrive after the next tick already
|
|
230
|
+
started a replacement. Clearing ``self._worker`` blindly would drop the
|
|
231
|
+
reference to that *running* worker and ``deleteLater()`` it — destroying
|
|
232
|
+
a live QThread aborts the process.
|
|
233
|
+
"""
|
|
234
|
+
if worker is self._worker:
|
|
235
|
+
self._worker = None
|
|
236
|
+
worker.deleteLater()
|
|
237
|
+
|
|
238
|
+
def _teardown(self) -> None:
|
|
239
|
+
if self._auto_timer is not None:
|
|
240
|
+
self._auto_timer.stop()
|
|
241
|
+
# Unhook + join the worker; if it can't stop in time it's detached
|
|
242
|
+
# rather than destroyed under us.
|
|
243
|
+
shutdown_worker_thread(self._worker, wait_ms=1000)
|
|
244
|
+
self._worker = None
|
|
@@ -6,12 +6,17 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
|
|
|
6
6
|
previously appeared duplicated across several dialog modules.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from typing import Callable, Iterable, List, Optional, Tuple
|
|
9
|
+
from typing import Any, Callable, Iterable, List, Optional, Tuple
|
|
10
10
|
|
|
11
|
-
from PySide6.QtCore import Qt, QThread
|
|
11
|
+
from PySide6.QtCore import QObject, Qt, QThread
|
|
12
12
|
from PySide6.QtGui import QStandardItem
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# Monospace stack used across the app for address/value text. An explicit
|
|
16
|
+
# family list rather than the platform's default fixed font, so every table
|
|
17
|
+
# renders addresses at the same family and size on every OS.
|
|
18
|
+
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"
|
|
19
|
+
|
|
15
20
|
# Workers that wouldn't stop in time on close are parked here so they are never
|
|
16
21
|
# destroyed while still running (that aborts the whole process with
|
|
17
22
|
# "QThread: Destroyed while thread is still running"). The list is module-level
|
|
@@ -39,7 +44,11 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
|
|
|
39
44
|
if callable(cancel):
|
|
40
45
|
cancel()
|
|
41
46
|
try:
|
|
42
|
-
|
|
47
|
+
# The four-argument static form on purpose: bare `worker.disconnect()`
|
|
48
|
+
# raises TypeError in PySide6 (so this except swallowed it and nothing
|
|
49
|
+
# was disconnected), and `worker.disconnect(worker)` only drops
|
|
50
|
+
# connections whose *receiver* is the worker — ours belong to the dialog.
|
|
51
|
+
QObject.disconnect(worker, None, None, None)
|
|
43
52
|
except (RuntimeError, TypeError):
|
|
44
53
|
pass
|
|
45
54
|
|
|
@@ -47,31 +56,114 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
|
|
|
47
56
|
worker.wait(wait_ms)
|
|
48
57
|
|
|
49
58
|
if worker.isRunning():
|
|
50
|
-
worker
|
|
51
|
-
_DETACHED_WORKERS.append(worker)
|
|
52
|
-
worker.finished.connect(lambda: _reap_detached_worker(worker))
|
|
59
|
+
detach_worker(worker)
|
|
53
60
|
else:
|
|
54
61
|
worker.deleteLater()
|
|
55
62
|
|
|
56
63
|
|
|
64
|
+
def detach_worker(worker: QThread) -> None:
|
|
65
|
+
"""Park a worker that wouldn't stop, so its owner can be destroyed.
|
|
66
|
+
|
|
67
|
+
Reparents it away from the dying widget and holds it in the module-level
|
|
68
|
+
registry until it finishes on its own. Callers that stop workers their own
|
|
69
|
+
way park them here too, so threads that outlived their owner are all in one
|
|
70
|
+
place.
|
|
71
|
+
"""
|
|
72
|
+
worker.setParent(None)
|
|
73
|
+
_DETACHED_WORKERS.append(worker)
|
|
74
|
+
worker.finished.connect(lambda: _reap_detached_worker(worker))
|
|
75
|
+
# A worker that finished before this connect already emitted, so the reaper
|
|
76
|
+
# would never run and it would sit here for good.
|
|
77
|
+
if worker.isFinished():
|
|
78
|
+
_reap_detached_worker(worker)
|
|
79
|
+
|
|
80
|
+
|
|
57
81
|
def _reap_detached_worker(worker: QThread) -> None:
|
|
58
82
|
if worker in _DETACHED_WORKERS:
|
|
59
83
|
_DETACHED_WORKERS.remove(worker)
|
|
60
84
|
worker.deleteLater()
|
|
61
85
|
|
|
62
86
|
|
|
87
|
+
class TearsDownOnClose:
|
|
88
|
+
"""Mixin that runs a dialog's teardown on *every* way out, not just close.
|
|
89
|
+
|
|
90
|
+
``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
|
|
91
|
+
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
|
|
92
|
+
``done()``, which hides the dialog and emits ``finished`` *without* any
|
|
93
|
+
close event — so a teardown living in ``closeEvent`` never ran, and the
|
|
94
|
+
dialog kept polling, hidden and unreachable.
|
|
95
|
+
|
|
96
|
+
Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
|
|
97
|
+
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
|
|
98
|
+
bases so its overrides win. Running once makes instances single-use, which
|
|
99
|
+
is what every caller here assumes.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def _teardown(self) -> None:
|
|
103
|
+
"""Release timers and background threads. Runs exactly once."""
|
|
104
|
+
raise NotImplementedError
|
|
105
|
+
|
|
106
|
+
def _is_dismissed(self) -> bool:
|
|
107
|
+
"""Whether the teardown has run. Guards live on this, not on a repeated
|
|
108
|
+
attribute lookup a typo could silently disable."""
|
|
109
|
+
return getattr(self, "_teardown_done", False)
|
|
110
|
+
|
|
111
|
+
def _run_teardown_once(self) -> None:
|
|
112
|
+
if self._is_dismissed():
|
|
113
|
+
return
|
|
114
|
+
self._teardown_done = True
|
|
115
|
+
self._teardown()
|
|
116
|
+
|
|
117
|
+
def done(self, result: int) -> None: # noqa: N802 — Qt naming
|
|
118
|
+
self._run_teardown_once()
|
|
119
|
+
super().done(result)
|
|
120
|
+
|
|
121
|
+
def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
|
|
122
|
+
self._run_teardown_once()
|
|
123
|
+
super().closeEvent(event)
|
|
124
|
+
|
|
125
|
+
|
|
63
126
|
class NumericItem(QStandardItem):
|
|
64
127
|
"""A QStandardItem that compares by its Qt.UserRole int payload.
|
|
65
128
|
|
|
66
129
|
Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
|
|
67
130
|
table sorts by the underlying value rather than the lexical label.
|
|
131
|
+
|
|
132
|
+
The data storage interface is overridden because Qt keeps item data in a
|
|
133
|
+
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
|
|
134
|
+
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
|
|
135
|
+
memory map hands one such address to the C++ side and gets "OverflowError:
|
|
136
|
+
int too big to convert", leaving the table half-populated. The workaround
|
|
137
|
+
is to keep user-role payloads on the Python side, where an int is an int.
|
|
138
|
+
|
|
139
|
+
The flip side: those payloads never reach the C++ model, so read them off
|
|
140
|
+
the item (``item.data(role)``) and never through ``model.data(index,
|
|
141
|
+
role)`` — that path converts the value back into a QVariant and overflows
|
|
142
|
+
all over again.
|
|
68
143
|
"""
|
|
69
144
|
|
|
70
|
-
def
|
|
145
|
+
def __init__(self, *args, **kwargs):
|
|
146
|
+
super().__init__(*args, **kwargs)
|
|
147
|
+
self._user_data: dict[int, Any] = {}
|
|
148
|
+
|
|
149
|
+
def setData(self, value: Any, role: int = Qt.UserRole):
|
|
150
|
+
if role >= Qt.UserRole:
|
|
151
|
+
self._user_data[int(role)] = value
|
|
152
|
+
self.emitDataChanged()
|
|
153
|
+
else:
|
|
154
|
+
super().setData(value, role)
|
|
155
|
+
|
|
156
|
+
def data(self, role: int = Qt.UserRole) -> Any:
|
|
157
|
+
if role >= Qt.UserRole:
|
|
158
|
+
return self._user_data.get(int(role))
|
|
159
|
+
else:
|
|
160
|
+
return super().data(role)
|
|
161
|
+
|
|
162
|
+
def __lt__(self, other: QStandardItem) -> bool:
|
|
71
163
|
try:
|
|
72
|
-
return int(self.data(
|
|
164
|
+
return int(self.data()) < int(other.data())
|
|
73
165
|
except (TypeError, ValueError):
|
|
74
|
-
return
|
|
166
|
+
return self.text() < other.text()
|
|
75
167
|
|
|
76
168
|
|
|
77
169
|
def parse_hex_address(text: str) -> Optional[int]:
|
|
@@ -6,6 +6,8 @@ A Cheat-Engine-inspired memory scanner built on PySide6 (Qt for Python),
|
|
|
6
6
|
working on Windows, Linux and macOS.
|
|
7
7
|
"""
|
|
8
8
|
import sys
|
|
9
|
+
import signal
|
|
10
|
+
import contextlib
|
|
9
11
|
from dataclasses import dataclass
|
|
10
12
|
|
|
11
13
|
from PyMemoryEditor import __version__
|
|
@@ -451,14 +453,50 @@ QLabel#processBadge {
|
|
|
451
453
|
"""
|
|
452
454
|
|
|
453
455
|
|
|
456
|
+
@contextlib.contextmanager
|
|
457
|
+
def _scoped_signal_handler(signalnum, handler):
|
|
458
|
+
"""
|
|
459
|
+
Temporarily change a signal handler in the scope of a with block.
|
|
460
|
+
|
|
461
|
+
Becomes a no-op off the main thread, where `signal.signal` raises
|
|
462
|
+
ValueError: an embedder driving the app from a worker thread shouldn't be
|
|
463
|
+
hard-crashed just because the Ctrl+C handler can't be installed there.
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
previous_handler = signal.signal(signalnum, handler)
|
|
467
|
+
except ValueError:
|
|
468
|
+
# Off the main thread. (`signal.signal` also raises ValueError for a
|
|
469
|
+
# signal number the platform rejects, but the only call site passes
|
|
470
|
+
# SIGINT, which every supported platform has.)
|
|
471
|
+
yield
|
|
472
|
+
return
|
|
473
|
+
try:
|
|
474
|
+
yield
|
|
475
|
+
finally:
|
|
476
|
+
# `signal.signal` reports None when "an unknown handler is in effect",
|
|
477
|
+
# i.e. one installed outside Python — plausible when the app is
|
|
478
|
+
# embedded in a host that set SIGINT up in C before the signal module
|
|
479
|
+
# initialized. Passing that None back raises TypeError, which would
|
|
480
|
+
# blow up on the way out of a run that otherwise succeeded, so leave
|
|
481
|
+
# the handler alone instead: SIG_DFL is closer to the host's intent
|
|
482
|
+
# than a crash.
|
|
483
|
+
if previous_handler is not None:
|
|
484
|
+
signal.signal(signalnum, previous_handler)
|
|
485
|
+
|
|
486
|
+
|
|
454
487
|
def main(argv=None):
|
|
455
488
|
"""
|
|
456
|
-
|
|
489
|
+
Run the app. Safe to call in-process: it installs no signal handler.
|
|
457
490
|
|
|
458
491
|
``argv`` defaults to ``sys.argv`` so packaging tools (which call
|
|
459
492
|
``main()`` with no arguments) keep working. Tests and embedders can pass
|
|
460
493
|
an explicit list — previously a positional ``*args`` was accepted but
|
|
461
494
|
ignored, which made the parameter meaningless.
|
|
495
|
+
|
|
496
|
+
Terminal users want Ctrl+C to kill the app, which takes a process-wide
|
|
497
|
+
signal change — see :func:`main_cli`, the console-script entry point.
|
|
498
|
+
Library callers get this function untouched so embedding the app can't
|
|
499
|
+
disturb the host's own SIGINT handling.
|
|
462
500
|
"""
|
|
463
501
|
if argv is None:
|
|
464
502
|
argv = sys.argv
|
|
@@ -501,10 +539,30 @@ def main(argv=None):
|
|
|
501
539
|
app.exec()
|
|
502
540
|
finally:
|
|
503
541
|
try:
|
|
504
|
-
process
|
|
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()
|
|
505
545
|
except Exception:
|
|
506
546
|
pass
|
|
507
547
|
|
|
508
548
|
|
|
549
|
+
def main_cli(argv=None):
|
|
550
|
+
"""
|
|
551
|
+
Entry point for the ``pymemoryeditor`` console script and ``python -m``.
|
|
552
|
+
|
|
553
|
+
Qt's event loop blocks inside C++, so Python's default SIGINT handler only
|
|
554
|
+
runs once the interpreter next regains control. In practice it raised
|
|
555
|
+
KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override,
|
|
556
|
+
where PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT
|
|
557
|
+
back to the OS for the duration of the run so Ctrl+C from a terminal
|
|
558
|
+
terminates the app immediately.
|
|
559
|
+
|
|
560
|
+
Kept separate from :func:`main` so that process-wide change only happens
|
|
561
|
+
when the app *is* the process, never when it is embedded in someone else's.
|
|
562
|
+
"""
|
|
563
|
+
with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
|
|
564
|
+
return main(argv)
|
|
565
|
+
|
|
566
|
+
|
|
509
567
|
if __name__ == "__main__":
|
|
510
|
-
|
|
568
|
+
main_cli()
|
|
@@ -106,6 +106,11 @@ class _CheatPollWorker(QThread):
|
|
|
106
106
|
with QMutexLocker(self._mutex):
|
|
107
107
|
self._pending_writes.append((address, pytype, length, value))
|
|
108
108
|
|
|
109
|
+
def _stopping(self) -> bool:
|
|
110
|
+
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
|
|
111
|
+
with QMutexLocker(self._mutex):
|
|
112
|
+
return self._stop
|
|
113
|
+
|
|
109
114
|
def stop(self) -> None:
|
|
110
115
|
with QMutexLocker(self._mutex):
|
|
111
116
|
self._stop = True
|
|
@@ -126,6 +131,8 @@ class _CheatPollWorker(QThread):
|
|
|
126
131
|
|
|
127
132
|
failures: List[Tuple[int, type, int, str]] = []
|
|
128
133
|
for address, pytype, length, value in pending:
|
|
134
|
+
if self._stopping():
|
|
135
|
+
break # the handle is about to go — see _poll_once
|
|
129
136
|
try:
|
|
130
137
|
self._process.write_process_memory(address, pytype, length, value)
|
|
131
138
|
except Exception as exc: # noqa: BLE001
|
|
@@ -206,6 +213,11 @@ class _CheatPollWorker(QThread):
|
|
|
206
213
|
values_by_address = None
|
|
207
214
|
|
|
208
215
|
for address in addresses:
|
|
216
|
+
# Per entry, not per tick: shutdown() closes the handle right
|
|
217
|
+
# after stop(), and a write on a recycled one hits another
|
|
218
|
+
# process.
|
|
219
|
+
if self._stopping():
|
|
220
|
+
return results
|
|
209
221
|
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
|
|
210
222
|
if values_by_address is not None:
|
|
211
223
|
current = values_by_address.get(address)
|