PyMemoryEditor 3.3.2__py3-none-any.whl → 3.3.3__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.
@@ -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.2"
11
+ __version__ = "3.3.3"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -0,0 +1,71 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """Run Python's cyclic garbage collector on the GUI thread only.
4
+
5
+ The automatic collector runs in whichever thread happens to allocate when a
6
+ threshold is crossed -- in this app, often a ``QThread`` worker in the middle
7
+ of a scan. If that collection frees a reference cycle holding Qt objects that
8
+ belong to the GUI thread, they are destroyed from the worker, which crashes
9
+ the process (a segfault on macOS, an access violation on Windows; seen on CI
10
+ in the pointer-scan worker's ``_hand_over``).
11
+
12
+ So while the app runs, automatic collection is switched off and a timer on the
13
+ GUI thread collects instead, generation by generation, as the automatic
14
+ collector would have. Plain reference counting still frees everything that is
15
+ not in a cycle immediately, in any thread.
16
+ """
17
+
18
+ import contextlib
19
+ import gc
20
+ from typing import Iterator, Optional
21
+
22
+ from PySide6.QtCore import QObject, QTimer
23
+
24
+
25
+ #: How often the GUI thread checks whether a collection is due.
26
+ COLLECT_INTERVAL_MS = 500
27
+
28
+
29
+ class MainThreadGarbageCollector(QObject):
30
+ """Disable automatic collection and collect from a GUI-thread timer.
31
+
32
+ Create it on the GUI thread; :meth:`stop` restores the collector's previous
33
+ state, so an embedder's own setting survives the app.
34
+ """
35
+
36
+ def __init__(
37
+ self, parent: Optional[QObject] = None, interval_ms: int = COLLECT_INTERVAL_MS
38
+ ) -> None:
39
+ super().__init__(parent)
40
+ self._was_enabled = gc.isenabled()
41
+ self._threshold = gc.get_threshold()
42
+ gc.disable()
43
+ self._timer = QTimer(self)
44
+ self._timer.timeout.connect(self.collect_if_due)
45
+ self._timer.start(interval_ms)
46
+
47
+ def collect_if_due(self) -> None:
48
+ """Collect each generation whose allocation count passed its threshold."""
49
+ count0, count1, count2 = gc.get_count()
50
+ if count0 > self._threshold[0]:
51
+ gc.collect(0)
52
+ if count1 > self._threshold[1]:
53
+ gc.collect(1)
54
+ if count2 > self._threshold[2]:
55
+ gc.collect(2)
56
+
57
+ def stop(self) -> None:
58
+ """Stop the timer and give automatic collection back."""
59
+ self._timer.stop()
60
+ if self._was_enabled:
61
+ gc.enable()
62
+
63
+
64
+ @contextlib.contextmanager
65
+ def garbage_collected_on_gui_thread(parent: Optional[QObject] = None) -> Iterator[None]:
66
+ """Run the body with :class:`MainThreadGarbageCollector` in charge."""
67
+ collector = MainThreadGarbageCollector(parent)
68
+ try:
69
+ yield
70
+ finally:
71
+ collector.stop()
@@ -560,10 +560,7 @@ def main(argv=None):
560
560
  from PySide6.QtCore import QSettings
561
561
  from PySide6.QtWidgets import QApplication
562
562
 
563
- from ._widgets import detached_workers_running
564
- from .main_window import MainWindow
565
- from .open_process_dialog import OpenProcessDialog
566
-
563
+ from ._gc import garbage_collected_on_gui_thread
567
564
  from ._icon import app_icon
568
565
 
569
566
  app = QApplication.instance() or QApplication(argv)
@@ -577,6 +574,18 @@ def main(argv=None):
577
574
  saved_theme = str(QSettings().value("theme", DEFAULT_THEME_ID))
578
575
  apply_theme(app, saved_theme)
579
576
 
577
+ # Every dialog from here on runs QThread workers; see _gc for why the
578
+ # cyclic collector must not run inside them.
579
+ with garbage_collected_on_gui_thread(app):
580
+ _pick_process_and_run(app)
581
+
582
+
583
+ def _pick_process_and_run(app) -> None:
584
+ """The picker, then the main window until the event loop exits."""
585
+ from ._widgets import detached_workers_running
586
+ from .main_window import MainWindow
587
+ from .open_process_dialog import OpenProcessDialog
588
+
580
589
  picker = OpenProcessDialog()
581
590
  if picker.exec() != picker.DialogCode.Accepted:
582
591
  return
@@ -218,6 +218,12 @@ class AccessDecision:
218
218
  needs_approval: bool = False
219
219
 
220
220
 
221
+ def _self_and_ancestor_pids() -> FrozenSet[int]:
222
+ """This process, its parent and every ancestor: what ``protect_self``
223
+ refuses. A function of its own so tests can swap the real chain out."""
224
+ return frozenset({os.getpid(), os.getppid()}) | ancestor_pids()
225
+
226
+
221
227
  class ProcessPolicy:
222
228
  """Decides whether a given ``(pid, name)`` may be attached to.
223
229
 
@@ -282,9 +288,7 @@ class ProcessPolicy:
282
288
  # further up. Taken once: the chain that launched the server does not
283
289
  # change while it runs.
284
290
  self._protected: FrozenSet[int] = (
285
- frozenset({os.getpid(), os.getppid()}) | ancestor_pids()
286
- if protect_self
287
- else frozenset()
291
+ _self_and_ancestor_pids() if protect_self else frozenset()
288
292
  )
289
293
  self._identify = identify
290
294
  # Only Linux truncates names; applying the rule elsewhere would let a
@@ -1303,6 +1303,15 @@ class AbstractProcess(ABC):
1303
1303
  bounds it. (To abort the map-building phase, raise from
1304
1304
  ``progress_callback``.)
1305
1305
 
1306
+ :raises ValueError: ``ptr_size`` other than 4 or 8, ``max_depth`` or
1307
+ ``max_results`` below 1, or a negative ``max_offset``.
1308
+ :raises ClosedProcess: the process was closed (also raised by the
1309
+ returned generator once it is).
1310
+
1311
+ Arguments and access are checked at the call, like every ``search_*``
1312
+ method; the pointer map is built and walked lazily, as the generator
1313
+ is consumed.
1314
+
1306
1315
  Example
1307
1316
  -------
1308
1317
  ::
@@ -1316,11 +1325,26 @@ class AbstractProcess(ABC):
1316
1325
  live = path.rebase(process).to_pointer(process, pytype=int, bufflength=4)
1317
1326
  live.value = 9999
1318
1327
  """
1319
- from .pointer_scan import (
1320
- AddressRanges,
1321
- build_pointer_map,
1322
- find_pointer_paths,
1323
- )
1328
+ # Validate eagerly, like every search_* method: a bad argument or a
1329
+ # closed process raises here, at the call, not on the first next().
1330
+ # The map build and path search stay lazy in _iter_pointer_paths.
1331
+ #
1332
+ # A subclass written against the 3.3 contract implements the listings
1333
+ # itself and need not define these hooks (see __init_subclass__): each
1334
+ # check runs only where the subclass provides it, instead of hitting
1335
+ # the NotImplementedError of the base _require_open.
1336
+ cls = type(self)
1337
+ guarded = cls._require_open is not AbstractProcess._require_open
1338
+ if guarded or cls._require_scanning is not AbstractProcess._require_scanning:
1339
+ self._require_scanning()
1340
+
1341
+ if max_depth < 1:
1342
+ raise ValueError("max_depth must be at least 1.")
1343
+ if max_offset < 0:
1344
+ raise ValueError("max_offset must not be negative.")
1345
+ if max_results is not None and max_results < 1:
1346
+ # 0 would build the whole pointer map only to yield nothing.
1347
+ raise ValueError("max_results must be at least 1 (or None for no cap).")
1324
1348
 
1325
1349
  if ptr_size is None:
1326
1350
  ptr_size = self.pointer_size
@@ -1330,6 +1354,44 @@ class AbstractProcess(ABC):
1330
1354
  "ptr_size must be 4 (32-bit target) or 8 (64-bit target)."
1331
1355
  )
1332
1356
 
1357
+ scan = self._iter_pointer_paths(
1358
+ target_address,
1359
+ max_depth=max_depth,
1360
+ max_offset=max_offset,
1361
+ ptr_size=ptr_size,
1362
+ aligned=aligned,
1363
+ writable_only=writable_only,
1364
+ static_ranges=static_ranges,
1365
+ max_results=max_results,
1366
+ memory_regions=memory_regions,
1367
+ progress_callback=progress_callback,
1368
+ should_stop=should_stop,
1369
+ )
1370
+ return guard_generator(scan, self._require_open) if guarded else scan
1371
+
1372
+ def _iter_pointer_paths(
1373
+ self,
1374
+ target_address: int,
1375
+ *,
1376
+ max_depth: int,
1377
+ max_offset: int,
1378
+ ptr_size: int,
1379
+ aligned: bool,
1380
+ writable_only: bool,
1381
+ static_ranges: Optional[Sequence[Tuple[int, int]]],
1382
+ max_results: Optional[int],
1383
+ memory_regions: Optional[Sequence[MemoryRegion]],
1384
+ progress_callback: Optional["Callable[[float], None]"],
1385
+ should_stop: Optional["Callable[[], bool]"],
1386
+ ) -> Generator["PointerPath", None, None]:
1387
+ """The lazy body of :meth:`scan_pointer_paths`, run once arguments are
1388
+ validated."""
1389
+ from .pointer_scan import (
1390
+ AddressRanges,
1391
+ build_pointer_map,
1392
+ find_pointer_paths,
1393
+ )
1394
+
1333
1395
  if memory_regions is None:
1334
1396
  memory_regions = list(self.get_memory_regions())
1335
1397
 
@@ -71,9 +71,18 @@ def compile_pattern(
71
71
  :raises ValueError: malformed IDA-style token, or ``byte_length`` omitted
72
72
  for a regex / pre-compiled pattern.
73
73
  :raises TypeError: if ``pattern`` is not a ``str``, ``bytes`` or
74
- ``re.Pattern[bytes]``.
74
+ ``re.Pattern[bytes]`` — a compiled ``str`` regex included, as it can
75
+ never match process memory.
75
76
  """
76
77
  if isinstance(pattern, re.Pattern):
78
+ # A str pattern can't match process memory: it would only fail later,
79
+ # deep inside the scan, with "cannot use a string pattern on a
80
+ # bytes-like object".
81
+ if not isinstance(pattern.pattern, bytes):
82
+ raise TypeError(
83
+ "Pattern must be a compiled bytes regex (re.compile(rb'...')), "
84
+ "not a str one."
85
+ )
77
86
  if byte_length <= 0:
78
87
  raise ValueError(
79
88
  "byte_length= is required when passing a pre-compiled regex "
@@ -251,6 +251,8 @@ _PROCESSOR_ARCHITECTURE_64BIT = (6, 9, 12)
251
251
  _OS_IS_64BIT = (
252
252
  _native_system_information.wProcessorArchitecture in _PROCESSOR_ARCHITECTURE_64BIT
253
253
  )
254
+ # The running Python's bitness: the fallback guess when a target's can't be read.
255
+ _HOST_IS_64BIT = ctypes.sizeof(ctypes.c_void_p) == 8
254
256
 
255
257
 
256
258
  def mbi_class_for_handle(process_handle: int):
@@ -318,12 +320,13 @@ def IsProcess64Bit(process_handle: int) -> bool:
318
320
  return detected
319
321
 
320
322
  _logger.warning(
321
- "IsWow64Process failed (err=%d); assuming the target is 64-bit "
322
- "(host bitness). Pointer-width detection may be wrong if the target "
323
- "is actually a 32-bit (WOW64) process.",
323
+ "IsWow64Process failed (err=%d); assuming the target matches the "
324
+ "host bitness (%d-bit). Pointer-width detection may be wrong if it "
325
+ "does not.",
324
326
  ctypes.get_last_error(),
327
+ 64 if _HOST_IS_64BIT else 32,
325
328
  )
326
- return True
329
+ return _HOST_IS_64BIT
327
330
 
328
331
 
329
332
  T = TypeVar("T")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: PyMemoryEditor
3
- Version: 3.3.2
3
+ Version: 3.3.3
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
@@ -1,11 +1,12 @@
1
- PyMemoryEditor/__init__.py,sha256=8avktxpgstr25QlSa_QUiTFYqDRD29h3KhvNQQ0qKD0,3234
1
+ PyMemoryEditor/__init__.py,sha256=bwjUj-mN7Qc2d8r-9cSsNJr4SmKgq8tHIYAFauTtf9Y,3234
2
2
  PyMemoryEditor/__main__.py,sha256=xukvvLjQ0BnYnnxMZda1cvyZ-BurSUk8dAGQybIqMD4,95
3
3
  PyMemoryEditor/enums.py,sha256=q_SWQYgh4F7yyAa7sZvNo2Sw91TSm3aTqPfTA7OqldM,322
4
4
  PyMemoryEditor/app/__init__.py,sha256=DGCYWpqGKPSeBa7cvzL7GmmhIfDDgF6vtim0VtLcfOo,357
5
5
  PyMemoryEditor/app/_auto_refresh_dialog.py,sha256=ktvsOaXUooQ9Hvfw2Y__IzWNZby0sW_0QlhNGrPseoE,10048
6
+ PyMemoryEditor/app/_gc.py,sha256=ulvh129qptIBxzFjfbIDQm4DMPt6WwDEniiRngv3FQ8,2533
6
7
  PyMemoryEditor/app/_icon.py,sha256=1HaaMX3HRCUD2EVIRwj_0BwPkdfj1nsVjwoTRwriJ_k,1447
7
8
  PyMemoryEditor/app/_widgets.py,sha256=vMqBfOHylEHB0jlxWmfbILEIRzjj7d6EIAFG5f7oxE0,12639
8
- PyMemoryEditor/app/application.py,sha256=Ew0KYFhIBafr7vLA1QrYU9PDpG-W645TMaG22GsJFWg,19907
9
+ PyMemoryEditor/app/application.py,sha256=RzPAPKmMmXjT6La5O_lSyqPWont6xM356DjDkWFttmY,20278
9
10
  PyMemoryEditor/app/cheat_entry.py,sha256=KOLcams-xkVEkV7rfrzhEI7IrsVvmGwsIr8cGcWdLM0,2841
10
11
  PyMemoryEditor/app/cheat_poll_worker.py,sha256=ZhDOXAGI9ObAEBSgkZLLOySUTekhlwICIfuA1XOp2Rw,12716
11
12
  PyMemoryEditor/app/cheat_table.py,sha256=FK0ZUkuEvvuXXOB64-hpxSDoGFlHNKhMFyFGgpsyXU0,46278
@@ -35,14 +36,14 @@ PyMemoryEditor/macos/types.py,sha256=pYVGpROHKt8-s1C-sFsG8xEk1vmBVdh5_5-BIqtsSak
35
36
  PyMemoryEditor/mcp/__init__.py,sha256=g30FgXeR3_eHq5hsGSGsPEJQlMPQ3A0jstD-MAFbU2M,2393
36
37
  PyMemoryEditor/mcp/__main__.py,sha256=GXMMDJMMZfFMjzYnRcw20wRRb3v_IKxvv_-ZOyxkK1U,475
37
38
  PyMemoryEditor/mcp/config.py,sha256=bpqkaWMI314byflMrMnySdLuuyqQJT3XTbuKIDgVLME,15862
38
- PyMemoryEditor/mcp/policy.py,sha256=QDu4I2gZRMyA6WvpzJKMNhKc9NwiUOsDSjQuqEwVKmw,25630
39
+ PyMemoryEditor/mcp/policy.py,sha256=cfG1A-IR1kTe_neLZFtd-JAqkZ_vcE4QYrPgNUv3yb0,25844
39
40
  PyMemoryEditor/mcp/procinfo.py,sha256=ufT07yoffyfA7KiBrdaQm7Hkf1WbpGveZMwcSmR8NQQ,10136
40
41
  PyMemoryEditor/mcp/server.py,sha256=YBJDMM2pXaavG_o-CnO8yL6aFecdsNfv6wEIqqard-o,33564
41
42
  PyMemoryEditor/mcp/session.py,sha256=0gy629j8lJrq4VxqUTMbxJ-D-D1IJo47T-UwtAIEzZE,31645
42
43
  PyMemoryEditor/mcp/toolset.py,sha256=kxkByx5YUowX7ldj1o1k0VXCPMoRNR4kxH1NHn3Z8ts,118338
43
44
  PyMemoryEditor/process/__init__.py,sha256=Xh5jngBqZyNIA9y3uwE49LxK8uH8YYsu6qotx1mP_WE,213
44
45
  PyMemoryEditor/process/_guard.py,sha256=91TKDEpFthFn99QNOioMD9hp2kzAOHA_qtkEgW9tJtI,1471
45
- PyMemoryEditor/process/abstract.py,sha256=_e9y8-4Te3VQYWwjH1ogw4KJ1FNZcKMhXR06QCF8zyc,71287
46
+ PyMemoryEditor/process/abstract.py,sha256=JbgKqR6tBPb6aCyuI2onLH-_9g9pICGxrv-PI-Yi6mk,74035
46
47
  PyMemoryEditor/process/errors.py,sha256=485SXkidg9_E_KjuhuF6AsQNuFZlY-Ag-sG1C6NsGMw,2054
47
48
  PyMemoryEditor/process/info.py,sha256=yF7kE-wMEs_h3rpn2dPgBpuwn2EhhVq3lfl5u7GwCfY,1484
48
49
  PyMemoryEditor/process/module_info.py,sha256=D7qAoBymhe6vrL0IYc2R6ZhWE-OPmYQzf6hSp_f2KmA,2365
@@ -54,10 +55,10 @@ PyMemoryEditor/process/thread_info.py,sha256=vwTDd3Yg2gOidU7I630Nsut-iryE5imSXnX
54
55
  PyMemoryEditor/process/util.py,sha256=QHwwjX79nygYB9ochCXtXCa8wYZ8CwU57q-cIhNajvQ,4014
55
56
  PyMemoryEditor/util/__init__.py,sha256=QI_tG9V3HA68IP-BA2-C-rLZWAADhZrbpxXGypBc69s,611
56
57
  PyMemoryEditor/util/convert.py,sha256=L-jhVBhmedd6g9IO0rL4KMKU8EZpxMNbB4eiRQkevZc,21954
57
- PyMemoryEditor/util/pattern.py,sha256=tO-4uaNIpm8tM1H0TS1qqGnDN_NTZd_yVBgm3z1wrK8,7253
58
+ PyMemoryEditor/util/pattern.py,sha256=ONRljRxGYhiElQG1E5D5LusgyZR_YLLdFtZrHFjYYkE,7724
58
59
  PyMemoryEditor/util/scan.py,sha256=-VZdwrBeXB7CQ_DKePUWjb-h-ox6_ogwYrloGkw0v6A,28752
59
60
  PyMemoryEditor/util/scan_numpy.py,sha256=SRTuhrhYP5azSab3k8FBTPVCMK9_1vZHogSSofkFCAA,6940
60
- PyMemoryEditor/win32/functions.py,sha256=35zNISzpVZmQuBY5QM_6BlLlyycWThV8ml0XPzLEhdQ,47816
61
+ PyMemoryEditor/win32/functions.py,sha256=mzgo1DZDFGgK0gFFaw3Hc2ljPDWjeEBzfqmac4F233U,47972
61
62
  PyMemoryEditor/win32/process.py,sha256=9t9xtmW_rBIWh6-Jdr3CPLshmmuBNBonBgzQA3bZGc8,13562
62
63
  PyMemoryEditor/win32/types.py,sha256=QkFRFKviS-7DNGgsK2eCTeiinYm2BZdxsPh_pw6I570,5177
63
64
  PyMemoryEditor/win32/enums/__init__.py,sha256=RZXhXb-yCGHVDEi0OxV27w0g2l6b68BIbKhHXyTM3cw,301
@@ -68,8 +69,8 @@ PyMemoryEditor/win32/enums/process_operations.py,sha256=wn-aMl01VVg-2rFavR9szBNR
68
69
  PyMemoryEditor/win32/enums/standard_access_rights.py,sha256=VGWlTagW4hvbm2AjZ4NK56ZrQwRmEl9Kz9GSCG8o-HI,738
69
70
  PyMemoryEditor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
70
71
  PyMemoryEditor/app/assets/icon.svg,sha256=T7OqR7ldqC_dK2Ib2ntsH2rWO-Z5RzNK4WdD1wFwgM4,7132
71
- pymemoryeditor-3.3.2.dist-info/METADATA,sha256=cH3qLkLOMOVvz73bw1jFV9zgn27si1eDzapY0zs5tEw,11037
72
- pymemoryeditor-3.3.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
73
- pymemoryeditor-3.3.2.dist-info/entry_points.txt,sha256=qxto0CWCXsjLmE0jClEgz1Xriljr6IV6d3AySjIlw7E,127
74
- pymemoryeditor-3.3.2.dist-info/licenses/LICENSE,sha256=YkOx6Nvkavq36bgiAc3eF85SBG0Qu14KMAvxmv6Ssjc,1089
75
- pymemoryeditor-3.3.2.dist-info/RECORD,,
72
+ pymemoryeditor-3.3.3.dist-info/METADATA,sha256=6Pm1UqVnlDrzaK1VWVPqPvWAOLmwujsaw9iZlv56fps,11037
73
+ pymemoryeditor-3.3.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
74
+ pymemoryeditor-3.3.3.dist-info/entry_points.txt,sha256=qxto0CWCXsjLmE0jClEgz1Xriljr6IV6d3AySjIlw7E,127
75
+ pymemoryeditor-3.3.3.dist-info/licenses/LICENSE,sha256=YkOx6Nvkavq36bgiAc3eF85SBG0Qu14KMAvxmv6Ssjc,1089
76
+ pymemoryeditor-3.3.3.dist-info/RECORD,,