PyMemoryEditor 3.3.0__py3-none-any.whl → 3.3.2__py3-none-any.whl

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 (38) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_auto_refresh_dialog.py +6 -7
  3. PyMemoryEditor/app/_widgets.py +7 -7
  4. PyMemoryEditor/app/application.py +3 -4
  5. PyMemoryEditor/app/cheat_poll_worker.py +5 -5
  6. PyMemoryEditor/app/cheat_table.py +17 -15
  7. PyMemoryEditor/app/main_window.py +11 -11
  8. PyMemoryEditor/app/memory_viewer_dialog.py +5 -5
  9. PyMemoryEditor/app/open_process_dialog.py +1 -1
  10. PyMemoryEditor/app/pointer_chain_dialog.py +2 -1
  11. PyMemoryEditor/app/pointer_scan_dialog.py +5 -5
  12. PyMemoryEditor/app/results_view.py +6 -6
  13. PyMemoryEditor/app/scanner_panel.py +1 -1
  14. PyMemoryEditor/app/value_types.py +3 -3
  15. PyMemoryEditor/linux/functions.py +29 -18
  16. PyMemoryEditor/linux/libc.py +2 -3
  17. PyMemoryEditor/linux/process.py +49 -112
  18. PyMemoryEditor/macos/functions.py +38 -25
  19. PyMemoryEditor/macos/process.py +47 -121
  20. PyMemoryEditor/mcp/config.py +12 -13
  21. PyMemoryEditor/mcp/policy.py +6 -6
  22. PyMemoryEditor/mcp/server.py +9 -9
  23. PyMemoryEditor/mcp/session.py +21 -23
  24. PyMemoryEditor/mcp/toolset.py +83 -88
  25. PyMemoryEditor/process/_guard.py +6 -5
  26. PyMemoryEditor/process/abstract.py +224 -25
  27. PyMemoryEditor/process/pointer_scan.py +2 -1
  28. PyMemoryEditor/process/region.py +10 -2
  29. PyMemoryEditor/process/scanning.py +9 -13
  30. PyMemoryEditor/util/scan.py +1 -2
  31. PyMemoryEditor/win32/enums/memory_protections.py +1 -1
  32. PyMemoryEditor/win32/functions.py +26 -15
  33. PyMemoryEditor/win32/process.py +52 -128
  34. {pymemoryeditor-3.3.0.dist-info → pymemoryeditor-3.3.2.dist-info}/METADATA +1 -1
  35. {pymemoryeditor-3.3.0.dist-info → pymemoryeditor-3.3.2.dist-info}/RECORD +38 -38
  36. {pymemoryeditor-3.3.0.dist-info → pymemoryeditor-3.3.2.dist-info}/WHEEL +0 -0
  37. {pymemoryeditor-3.3.0.dist-info → pymemoryeditor-3.3.2.dist-info}/entry_points.txt +0 -0
  38. {pymemoryeditor-3.3.0.dist-info → pymemoryeditor-3.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -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.3.0"
11
+ __version__ = "3.3.2"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -3,13 +3,12 @@
3
3
  Shared base for the read-only, auto-refreshing table dialogs (Memory Map,
4
4
  Modules, Threads).
5
5
 
6
- All three did the same dance — run a one-shot read off the UI thread, repopulate
6
+ All three do the same dance — run a one-shot read off the UI thread, repopulate
7
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.
8
+ safely on close. This owns that lifecycle once, so the fiddly bits (the
9
+ in-flight guard that self-throttles the poll, the detach-on-shutdown safety)
10
+ live in one place; subclasses provide only what differs: how to fetch the
11
+ data, and how to render it.
13
12
 
14
13
  It also owns *when* a failure is worth interrupting the user — see
15
14
  :meth:`AutoRefreshTableDialog._handle_failed`.
@@ -182,7 +181,7 @@ class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
182
181
  Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
183
182
  dialog. A modal runs a nested event loop, so the timer fires again while
184
183
  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).
184
+ user could only escape by killing the app from another TTY.
186
185
 
187
186
  Reporting is therefore tied to the two moments where a failure changes
188
187
  what the user sees: the **first** fetch (empty window) and the one that
@@ -2,8 +2,8 @@
2
2
 
3
3
  """Small Qt widgets shared between dialogs.
4
4
 
5
- Centralises tiny helpers (numeric sort items, hex address parsing) that
6
- previously appeared duplicated across several dialog modules.
5
+ Centralises tiny helpers (numeric sort items, hex address parsing) shared by
6
+ several dialog modules.
7
7
  """
8
8
 
9
9
  import threading
@@ -46,9 +46,9 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
46
46
  cancel()
47
47
  try:
48
48
  # The four-argument static form on purpose: bare `worker.disconnect()`
49
- # raises TypeError in PySide6 (so this except swallowed it and nothing
50
- # was disconnected), and `worker.disconnect(worker)` only drops
51
- # connections whose *receiver* is the worker — ours belong to the dialog.
49
+ # raises TypeError in PySide6, and `worker.disconnect(worker)` only
50
+ # drops connections whose *receiver* is the worker — ours belong to the
51
+ # dialog.
52
52
  QObject.disconnect(worker, None, None, None)
53
53
  except (RuntimeError, TypeError):
54
54
  pass
@@ -141,8 +141,8 @@ class TearsDownOnClose:
141
141
  ``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
142
142
  "Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
143
143
  ``done()``, which hides the dialog and emits ``finished`` *without* any
144
- close event — so a teardown living in ``closeEvent`` never ran, and the
145
- dialog kept polling, hidden and unreachable.
144
+ close event — so a teardown living in ``closeEvent`` would never run, and
145
+ the dialog would keep polling, hidden and unreachable.
146
146
 
147
147
  Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
148
148
  whichever way the dialog is dismissed, and must precede ``QDialog`` in the
@@ -542,8 +542,7 @@ def main(argv=None):
542
542
 
543
543
  ``argv`` defaults to ``sys.argv`` so packaging tools (which call
544
544
  ``main()`` with no arguments) keep working. Tests and embedders can pass
545
- an explicit list — previously a positional ``*args`` was accepted but
546
- ignored, which made the parameter meaningless.
545
+ an explicit list.
547
546
 
548
547
  Terminal users want Ctrl+C to kill the app, which takes a process-wide
549
548
  signal change — see :func:`main_cli`, the console-script entry point.
@@ -611,9 +610,9 @@ def main_cli(argv=None):
611
610
  Entry point for the ``pymemoryeditor`` console script and ``python -m``.
612
611
 
613
612
  Qt's event loop blocks inside C++, so Python's default SIGINT handler only
614
- runs once the interpreter next regains control. In practice it raised
613
+ runs once the interpreter next regains control. In practice it raises
615
614
  KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override,
616
- where PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT
615
+ where PySide6 swallows it and merely prints a traceback. Hand SIGINT
617
616
  back to the OS for the duration of the run so Ctrl+C from a terminal
618
617
  terminates the app immediately.
619
618
 
@@ -56,8 +56,8 @@ class _CheatPollWorker(QThread):
56
56
  with the current ``{key: "ErrorType: message"}`` map. The signal fires only
57
57
  when that set *changes* (a freeze starts or stops failing), so the UI gets a
58
58
  persistent cue without 10 Hz spam, and the first failure of each entry is
59
- logged once. Previously the exception was swallowed silently, so a freeze
60
- that never landed looked identical to one that did.
59
+ logged once. Swallowing the exception instead would make a freeze that
60
+ never landed look identical to one that did.
61
61
  """
62
62
 
63
63
  values_ready = Signal(object) # list[tuple[int, type, int, Any]]
@@ -96,9 +96,9 @@ class _CheatPollWorker(QThread):
96
96
  ) -> None:
97
97
  """Queue a one-shot manual write to be performed on the worker thread.
98
98
 
99
- The cheat table's inline value edits used to call
100
- ``write_process_memory`` directly on the UI thread, freezing the UI when
101
- the target was slow or the page faulted. Routing them here keeps the
99
+ The cheat table's inline value edits come through here: calling
100
+ ``write_process_memory`` directly on the UI thread would freeze the UI
101
+ when the target is slow or the page faults. Routing them here keeps the
102
102
  syscall off the UI thread; a write that fails comes back via the
103
103
  ``write_failed`` signal. Latency is at most one tick (~100 ms), which is
104
104
  imperceptible for a manual edit.
@@ -12,8 +12,8 @@ stays fresh.
12
12
  This module hosts only the Qt widget; the dataclass and the worker thread
13
13
  live in ``cheat_entry.py`` and ``cheat_poll_worker.py`` respectively. The
14
14
  ``CheatEntry`` and ``_CheatPollWorker`` names are re-exported from here for
15
- backward compatibility with code (and tests) that imported them from this
16
- module before the split.
15
+ backward compatibility with code (and tests) that import them from this
16
+ module.
17
17
  """
18
18
  import copy
19
19
  import json
@@ -95,8 +95,8 @@ class CheatTable(QWidget):
95
95
  self._poller.start()
96
96
 
97
97
  # A short cadence to push fresh entry snapshots into the worker. This
98
- # is far cheaper than the previous QTimer that did real syscalls — it
99
- # only copies a small list of tuples.
98
+ # is cheap — it does no syscalls, it only copies a small list of
99
+ # tuples.
100
100
  self._publish_timer = QTimer(self)
101
101
  self._publish_timer.setInterval(TICK_INTERVAL_MS)
102
102
  self._publish_timer.timeout.connect(self._publish_snapshot_to_worker)
@@ -221,8 +221,8 @@ class CheatTable(QWidget):
221
221
  self._table.customContextMenuRequested.connect(self._show_context_menu)
222
222
  layout.addWidget(self._table, 1)
223
223
 
224
- # Delete removes the selected rows — the standard gesture, previously
225
- # only reachable through the context menu. Scoped to the table widget
224
+ # Delete removes the selected rows — the standard gesture, alongside
225
+ # the context menu. Scoped to the table widget
226
226
  # (WidgetShortcut) so Delete still edits text while a cell is in
227
227
  # inline-edit mode (the editor child, not the table, has focus then).
228
228
  delete_shortcut = QShortcut(QKeySequence(QKeySequence.Delete), self._table)
@@ -247,8 +247,9 @@ class CheatTable(QWidget):
247
247
  next, so that is refused -- the import loop reports and skips it.
248
248
 
249
249
  Batched so a bulk promotion costs one address lookup per row and one
250
- rebuild in total: one ``add_entry`` per row did a linear duplicate
251
- scan plus a full table rebuild each, quadratic in the selection.
250
+ rebuild in total: one ``add_entry`` per row would do a linear
251
+ duplicate scan plus a full table rebuild each, quadratic in the
252
+ selection.
252
253
 
253
254
  :raises ValueError: if an entry's length is over ``MAX_ENTRY_LENGTH``;
254
255
  checked for every entry before any is added.
@@ -261,7 +262,7 @@ class CheatTable(QWidget):
261
262
  % (entry.address, length, MAX_ENTRY_LENGTH)
262
263
  )
263
264
 
264
- # First entry per address, as the linear scan used to find.
265
+ # First entry per address wins, should the table hold duplicates.
265
266
  by_address: Dict[int, CheatEntry] = {}
266
267
  for existing in self._entries:
267
268
  by_address.setdefault(existing.address, existing)
@@ -347,8 +348,8 @@ class CheatTable(QWidget):
347
348
  type_label = entry.spec_label
348
349
  # Show the width whenever it belongs to the entry rather than to the
349
350
  # spec. An IDA pattern declares none (length 0) yet carries a real one
350
- # here — writes are refused against it — so hiding it left the user
351
- # told to "widen the entry" with no way to see what it holds.
351
+ # here — writes are refused against it — so hiding it would leave the
352
+ # user told to "widen the entry" with no way to see what it holds.
352
353
  if entry.spec.accepts_length_override or not entry.spec.length:
353
354
  type_label += f" · {entry.length}B"
354
355
  type_item = QTableWidgetItem(type_label)
@@ -428,8 +429,8 @@ class CheatTable(QWidget):
428
429
  # and nothing to write to while the target is known to be gone.
429
430
  if self._poller_stopped or self._paused:
430
431
  # Put the cell back and say so, as the bulk edit does —
431
- # returning quietly left the typed text on screen as if it had
432
- # been written.
432
+ # returning quietly would leave the typed text on screen as if
433
+ # it had been written.
433
434
  self._suspend_signals = True
434
435
  item.setText(self._value_text_for(entry))
435
436
  self._suspend_signals = False
@@ -525,7 +526,8 @@ class CheatTable(QWidget):
525
526
  against it here is cheap and keeps the cue in sync: a red value cell plus
526
527
  an explanatory tooltip while a freeze keeps failing, restored to normal
527
528
  the moment the write lands again. Without this, a frozen value that the
528
- backend silently refuses to write looked identical to one that worked.
529
+ backend silently refuses to write would look identical to one that
530
+ worked.
529
531
  """
530
532
  rows_by_key: Dict[Tuple[int, type, int], int] = {}
531
533
  for row, entry in enumerate(self._entries):
@@ -911,7 +913,7 @@ class CheatTable(QWidget):
911
913
 
912
914
 
913
915
  # Ceiling for an entry's buffer width. Entries are promoted at the width of the
914
- # value scanned for, which the scanner doesn't cap, so 1024 was too tight — but
916
+ # value scanned for, which the scanner doesn't cap, so 1024 is too tight — but
915
917
  # the poll worker allocates this many bytes per entry on every 100 ms tick, so
916
918
  # an unbounded field turns one typo into a multi-gigabyte allocation the tick's
917
919
  # blanket except swallows and retries forever. A megabyte is far past any real
@@ -471,8 +471,8 @@ class MainWindow(QMainWindow):
471
471
  self._cleanup_worker()
472
472
  # A cancelled scan still emits finished_ok (with the partial count) and
473
473
  # a failed one emits error; either way the user asked for no more
474
- # work — Cancel started "Updating values…", and a dead target popped
475
- # a second error dialog from the refresh.
474
+ # work — otherwise Cancel would start "Updating values…", and a dead
475
+ # target would pop a second error dialog from the refresh.
476
476
  if completed:
477
477
  self._fill_initial_values(request)
478
478
 
@@ -547,9 +547,9 @@ class MainWindow(QMainWindow):
547
547
  def _cleanup_worker(self) -> None:
548
548
  # Runs from the worker's `finished`, and only one scan runs at a time,
549
549
  # so self._worker is the worker that just finished. It is a child of
550
- # this window: dropping the reference alone kept every scan's QThread
551
- # (and the address list / value map it was handed) alive until the
552
- # window closed. `finished` is emitted as run() returns, so wait()
550
+ # this window: dropping the reference alone would keep every scan's
551
+ # QThread (and the address list / value map it was handed) alive until
552
+ # the window closed. `finished` is emitted as run() returns, so wait()
553
553
  # comes back at once; it makes sure the thread is really over before
554
554
  # deleteLater() (destroying a running QThread aborts the process).
555
555
  worker = self._worker
@@ -603,8 +603,8 @@ class MainWindow(QMainWindow):
603
603
  """Forget a closed tool dialog and free it.
604
604
 
605
605
  The dialogs are children of this window and are rebuilt on the next
606
- open, so dropping the reference alone kept every closed instance (and
607
- the tables it held) alive for the life of the window. By the time
606
+ open, so dropping the reference alone would keep every closed instance
607
+ (and the tables it held) alive for the life of the window. By the time
608
608
  ``finished`` gets here the dialog's teardown has already run — it is
609
609
  driven from ``done()`` — so its background worker is stopped, or
610
610
  detached from it if it would not stop, and the delete can't take a
@@ -919,8 +919,8 @@ class MainWindow(QMainWindow):
919
919
 
920
920
  # Tear down auxiliary dialogs that hold a reference to the old
921
921
  # process — reopening them rebuilds against the new target. The Memory
922
- # Map belongs here too: it polls on a timer, so leaving it open left it
923
- # reading the handle closed just below (issue #74).
922
+ # Map belongs here too: it polls on a timer, so leaving it open would
923
+ # leave it reading the handle closed just below.
924
924
  for dialog_attr in (
925
925
  "_memory_map",
926
926
  "_threads_dialog",
@@ -1019,8 +1019,8 @@ class MainWindow(QMainWindow):
1019
1019
  """Ask before quitting with unsaved cheat entries. True = go ahead.
1020
1020
 
1021
1021
  The table is not persisted between runs (Export… is the way to keep
1022
- it), so a stray Cmd+Q / window close used to throw away every saved
1023
- address silently.
1022
+ it), so a stray Cmd+Q / window close would otherwise throw away every
1023
+ saved address silently.
1024
1024
  """
1025
1025
  count = self._cheat.entry_count()
1026
1026
  if count == 0 or not self.confirm_close_with_entries:
@@ -151,9 +151,9 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
151
151
  self._timer = QTimer(self)
152
152
  self._timer.timeout.connect(self.refresh)
153
153
 
154
- # Failure bookkeeping, same shape as the auto-refresh dialogs: a dead
155
- # target used to log a warning every tick forever (~20/s at the 50 ms
156
- # floor). Keyed by (address, size, message) so moving the viewer to
154
+ # Failure bookkeeping, same shape as the auto-refresh dialogs: without
155
+ # it a dead target would log a warning every tick forever (~20/s at the
156
+ # 50 ms floor). Keyed by (address, size, message) so moving the viewer to
157
157
  # another failing range still gets logged.
158
158
  self._last_failure: Optional[tuple] = None
159
159
  self._failing_since: Optional[QElapsedTimer] = None
@@ -171,7 +171,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
171
171
  # Skip if a read is still running. The auto-refresh timer fires this
172
172
  # repeatedly, and a large length (up to 65536) on a slow target
173
173
  # (notably macOS Mach-VM) takes long enough that running it on the UI
174
- # thread froze input — so the read now runs on a worker, and this guard
174
+ # thread would freeze input — so the read runs on a worker, and this guard
175
175
  # self-throttles the cadence to the real read time instead of stacking
176
176
  # workers.
177
177
  if self._worker is not None and self._worker.isRunning():
@@ -188,7 +188,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
188
188
  # travel back in the result so the rendered dump stays labelled with
189
189
  # the range actually read even if the user edits the fields mid-read.
190
190
  # Errors are returned (not raised) so the one result handler renders
191
- # them with the same message/log the synchronous path produced.
191
+ # every outcome through a single message/log path.
192
192
  try:
193
193
  data = process.read_process_memory(addr, bytes, size)
194
194
  # A backend returning a non-buffer object would make bytes(data)
@@ -288,7 +288,7 @@ class OpenProcessDialog(TearsDownOnClose, QDialog):
288
288
  selected_pid = self._selected_pid()
289
289
 
290
290
  # Restoring the selection below re-triggers Qt's scrollTo(current), which
291
- # jumped the list back to the selected row on every tick (#75). The
291
+ # would jump the list back to the selected row on every tick. The
292
292
  # rebuild is harmless on its own: Qt defers the scrollbar range update.
293
293
  scrollbar = self._table.verticalScrollBar()
294
294
  scroll_offset = scrollbar.value()
@@ -45,6 +45,7 @@ from PySide6.QtWidgets import (
45
45
  )
46
46
 
47
47
  from PyMemoryEditor import AbstractProcess
48
+ from PyMemoryEditor.process.region import ADDRESS_SPACE_END
48
49
 
49
50
  from ._widgets import (
50
51
  MONOSPACE_FAMILY,
@@ -469,7 +470,7 @@ class PointerChainDialog(QDialog):
469
470
  # and formatted as hex; a negative or >= 2**64 value (raw mode with
470
471
  # large/negative offsets) would raise OverflowError/ValueError. Reject
471
472
  # it cleanly instead of crashing.
472
- if not 0 <= resolved < 2 ** 64:
473
+ if not 0 <= resolved < ADDRESS_SPACE_END:
473
474
  self._resolved_address = None
474
475
  self._output_label.setText("Resolved address: <out of 64-bit range>")
475
476
  self._value_label.setText("Value: —")
@@ -1059,9 +1059,9 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
1059
1059
  for saved in survivors:
1060
1060
  # Survivors are all module paths (the intersection drops the
1061
1061
  # rest). One whose module isn't loaded now has no live base: the
1062
- # rescan drops it, and falling back to its saved absolute base (as
1063
- # this used to) walked the previous run's addresses and could show
1064
- # a value read from unrelated memory as if the path resolved. Its
1062
+ # rescan drops it, and falling back to its saved absolute base
1063
+ # would walk the previous run's addresses and could show a value
1064
+ # read from unrelated memory as if the path resolved. Its
1065
1065
  # membership is already decided, so it stays, shown unresolved.
1066
1066
  live = saved._rebased_onto(module_bases)
1067
1067
  if live is None:
@@ -1090,8 +1090,8 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
1090
1090
  self._export_btn.setEnabled(True)
1091
1091
 
1092
1092
  def _teardown(self) -> None:
1093
- # Unhook every signal (the previous code missed `error`/`finished`, so
1094
- # a late emit could land on a destroyed dialog) and join the worker; if
1093
+ # Unhook every signal (`error`/`finished` included, or a late emit
1094
+ # could land on a destroyed dialog) and join the worker; if
1095
1095
  # it can't stop in time it's detached rather than destroyed under us.
1096
1096
  shutdown_worker_thread(self._worker, wait_ms=2000)
1097
1097
  self._worker = None
@@ -67,7 +67,7 @@ class ResultsModel(QAbstractTableModel):
67
67
  # row). Rows dropped since then are listed, ascending, in ``_removed``:
68
68
  # an address's current row is its base minus the dropped rows above it
69
69
  # (see ``_row_of``). Rebuilding the whole dict after every refine chunk
70
- # made a refine quadratic in the result count.
70
+ # would make a refine quadratic in the result count.
71
71
  self._index: Dict[int, int] = {}
72
72
  self._removed: List[int] = []
73
73
  # Set when an address was appended twice: the base-row bookkeeping
@@ -201,8 +201,8 @@ class ResultsModel(QAbstractTableModel):
201
201
 
202
202
  # One dataChanged per contiguous run of patched rows rather than one
203
203
  # per row: the view repaints on every emit, and a refine chunk is a
204
- # few thousand rows, so per-row emits turned each chunk into a redraw
205
- # storm.
204
+ # few thousand rows, so per-row emits would turn each chunk into a
205
+ # redraw storm.
206
206
  for first, last in _contiguous_runs(sorted(set(rows_changed))):
207
207
  self.dataChanged.emit(
208
208
  self.index(first, COL_VALUE), self.index(last, COL_PREVIOUS)
@@ -219,9 +219,9 @@ class ResultsModel(QAbstractTableModel):
219
219
  a block of neighbours costs one signal, not one per row. Past
220
220
  ``_RESET_RUN_THRESHOLD`` runs (a refine that keeps a sparse scatter of
221
221
  a huge result set) the model is reset instead: one O(n) rebuild and
222
- one repaint, at the price of the view's selection. The previous shape
223
- (one pair plus a mid-list ``list.pop`` per row) was quadratic: a
224
- refine dropping 900k of 1M rows did 900k pops and fired 900k signals.
222
+ one repaint, at the price of the view's selection. One pair plus a
223
+ mid-list ``list.pop`` per row would be quadratic: a refine dropping
224
+ 900k of 1M rows would do 900k pops and fire 900k signals.
225
225
  """
226
226
  count = len(self._addresses)
227
227
  rows = [row for row in rows if 0 <= row < count]
@@ -558,7 +558,7 @@ class ScannerPanel(QWidget):
558
558
  # its own width, and there is no earlier width to fall back on. Dropping
559
559
  # it would send the next Changed/Unchanged refine to the spec default —
560
560
  # 16 for a String scanned at 4 — which is the failure this whole branch
561
- # exists to fix. _has_results tells the two apart: a first scan only
561
+ # exists to prevent. _has_results tells the two apart: a first scan only
562
562
  # runs when there are none yet.
563
563
  if self._has_results:
564
564
  self._pending_scan_length = None
@@ -469,9 +469,9 @@ def parse_value_for_write(
469
469
  # One guard for every path that reaches an address. An entry's length is a
470
470
  # *byte* width — it is the bufflength each poll tick reads — while
471
471
  # prepare_write treats it as a hard cap and truncates past it, counting
472
- # characters for str. So a wider value was written short with no word said
473
- # while the cell kept showing all of it, and a multibyte str could slip the
474
- # other way: "ábc" is 3 characters but 4 bytes, one past a 3-byte entry.
472
+ # characters for str. So a wider value would be written short with no word
473
+ # said while the cell kept showing all of it, and a multibyte str could slip
474
+ # the other way: "ábc" is 3 characters but 4 bytes, one past a 3-byte entry.
475
475
  # Measuring what actually goes on the wire covers both.
476
476
  written = _written_width(value)
477
477
  if length_override is not None and written > length_override:
@@ -12,7 +12,7 @@ import logging
12
12
  import os
13
13
  import signal
14
14
  from ctypes import addressof, sizeof
15
- from typing import Dict, Generator, Optional, Sequence, Tuple, Type, TypeVar, Union
15
+ from typing import Dict, Generator, Optional, Pattern, Sequence, Tuple, Type, TypeVar, Union, cast
16
16
 
17
17
  from ..enums import ScanTypesEnum
18
18
  from ..process.errors import ProcessIDNotExistsError
@@ -60,9 +60,9 @@ class _LinuxPartialIOError(OSError):
60
60
 
61
61
  In practice this happens when the target range straddles a freed or
62
62
  inaccessible page — the kernel transfers what it can and reports the
63
- short count. The previous behavior was to silently accept the short
64
- result, leaving the caller's buffer half-filled with real bytes and
65
- half with zeros (which downstream decoding would treat as valid).
63
+ short count. Accepting the short result would leave the caller's buffer
64
+ half-filled with real bytes and half with zeros (which downstream
65
+ decoding would treat as valid), so it raises instead.
66
66
  Mirrors the partial-read/write check the Win32 backend already does
67
67
  against ``ReadProcessMemory`` / ``WriteProcessMemory``.
68
68
  """
@@ -181,10 +181,9 @@ def _make_read_chunk(pid: int):
181
181
  """
182
182
  Build a `read_chunk(address, size)` closure bound to ``pid``.
183
183
 
184
- Each backend used to define this closure inline three times
184
+ One closure per backend, shared by its three scan entry points
185
185
  (``search_addresses_by_value``, ``search_addresses_by_pattern``,
186
- ``search_values_by_addresses``) — drift between them was a recurring source
187
- of subtle bugs. Owning it once per backend keeps the trio in lockstep.
186
+ ``search_values_by_addresses``) so they cannot drift.
188
187
  """
189
188
  def read_chunk(address: int, size: int):
190
189
  buffer = (ctypes.c_byte * size)()
@@ -249,7 +248,7 @@ def get_memory_regions(pid: int) -> Generator["MemoryRegion", None, None]:
249
248
  region_information = line.split(maxsplit=5)
250
249
 
251
250
  try:
252
- addressing_range, privileges, offset, device, inode = (
251
+ addressing_range, privileges, raw_offset, device, raw_inode = (
253
252
  region_information[0:5]
254
253
  )
255
254
  path = region_information[5].strip() if len(region_information) >= 6 else b""
@@ -259,8 +258,8 @@ def get_memory_regions(pid: int) -> Generator["MemoryRegion", None, None]:
259
258
  ]
260
259
  major_id, minor_id = [int(_id, 16) for _id in device.split(b":")]
261
260
 
262
- offset = int(offset, 16)
263
- inode = int(inode) # /proc/<pid>/maps formats the inode as decimal.
261
+ offset = int(raw_offset, 16)
262
+ inode = int(raw_inode) # /proc/<pid>/maps formats the inode as decimal.
264
263
  except (ValueError, IndexError) as exc:
265
264
  # A single malformed line (kernel quirk, racing teardown) must
266
265
  # not abort the whole region walk — skip it and keep going, the
@@ -437,9 +436,9 @@ def read_process_memory(pid: int, address: int, pytype: Type[T], bufflength: int
437
436
  _process_vm_readv(pid, addressof(data), address, bufflength)
438
437
 
439
438
  if pytype is str:
440
- return bytes(data).decode("utf-8", errors="replace")
439
+ return cast(T, bytes(data).decode("utf-8", errors="replace"))
441
440
  elif pytype is bytes:
442
- return bytes(data)
441
+ return cast(T, bytes(data))
443
442
  else:
444
443
  # Narrow widths pad with zeroes, which reads a signed value as
445
444
  # unsigned -- and a scan for the same bytes is signed.
@@ -463,23 +462,28 @@ def search_addresses_by_value(
463
462
  pid: int,
464
463
  pytype: Type[T],
465
464
  bufflength: int,
466
- value: Union[bool, int, float, str, bytes, tuple],
465
+ value: Optional[Union[bool, int, float, str, bytes, tuple]],
467
466
  scan_type: ScanTypesEnum = ScanTypesEnum.EXACT_VALUE,
468
467
  progress_information: bool = False,
469
468
  writeable_only: bool = False,
470
469
  *,
471
470
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
472
471
  aligned: bool = True,
472
+ target_value_bytes: Optional[Union[bytes, Tuple[bytes, ...]]] = None,
473
473
  ) -> Generator[Union[int, Tuple[int, dict]], None, None]:
474
474
  """
475
475
  Search the whole memory space, accessible to the process,
476
476
  for the provided value, returning the found addresses.
477
477
 
478
478
  Passing a `memory_regions` snapshot skips region enumeration.
479
+ ``target_value_bytes`` is ``value`` already encoded (see
480
+ :func:`~PyMemoryEditor.util.prepare_search_target`); when it is given,
481
+ ``value`` is not used and may be ``None``.
479
482
  """
480
483
  _validate_pytype(pytype)
481
484
 
482
- target_value_bytes = values_to_bytes(pytype, bufflength, value)
485
+ if target_value_bytes is None:
486
+ target_value_bytes = values_to_bytes(pytype, bufflength, value)
483
487
 
484
488
  source_regions = (
485
489
  memory_regions if memory_regions is not None else get_memory_regions(pid)
@@ -512,12 +516,19 @@ def search_addresses_by_pattern(
512
516
  byte_length: int = 0,
513
517
  progress_information: bool = False,
514
518
  memory_regions: Optional[Sequence[MemoryRegion]] = None,
519
+ compiled_pattern: Optional[Tuple[Pattern[bytes], int]] = None,
515
520
  ) -> Generator[Union[int, Tuple[int, dict]], None, None]:
516
521
  """
517
522
  AOB scan against every readable, non-shared region of the target. See
518
- :meth:`AbstractProcess.search_by_pattern`.
519
- """
520
- compiled, length = compile_pattern(pattern, byte_length=byte_length)
523
+ :meth:`AbstractProcess.search_by_pattern`. ``compiled_pattern`` is what
524
+ :func:`~PyMemoryEditor.util.pattern.compile_pattern` returned for
525
+ ``pattern``, when the caller already compiled it.
526
+ """
527
+ compiled, length = (
528
+ compiled_pattern
529
+ if compiled_pattern is not None
530
+ else compile_pattern(pattern, byte_length=byte_length)
531
+ )
521
532
 
522
533
  source_regions = (
523
534
  memory_regions if memory_regions is not None else get_memory_regions(pid)
@@ -703,7 +714,7 @@ def get_processes() -> Generator[Tuple[int, str], None, None]:
703
714
 
704
715
  ``comm`` is raw bytes a process can set to anything (``prctl(PR_SET_NAME)``),
705
716
  so it is decoded as UTF-8 with replacement characters, independent of the
706
- locale: one process with a non-UTF-8 name used to raise
717
+ locale, so one process with a non-UTF-8 name cannot raise
707
718
  ``UnicodeDecodeError`` here and break ``OpenProcess(name=...)`` for every
708
719
  other process.
709
720
  """
@@ -21,9 +21,8 @@ libc = ctypes.CDLL(find_library("c"), use_errno=True)
21
21
  # Configuring `argtypes` is not cosmetic: without it, ctypes passes Python ints
22
22
  # through the platform's default C-int width. On a 32-bit Linux build (or any
23
23
  # host where the default int is narrower than the iovec pointer's representation)
24
- # the address gets silently truncated before the kernel sees it — the same class
25
- # of bug that motivated the v2 audit of the Win32 backend, where every API now
26
- # declares argtypes/restype explicitly.
24
+ # the address gets silently truncated before the kernel sees it. The Win32
25
+ # backend declares argtypes/restype explicitly on every API for the same reason.
27
26
  _PROCESS_VM_ARGTYPES = (
28
27
  ctypes.c_int, # pid_t
29
28
  ctypes.POINTER(iovec), # local_iov