Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,12 +6,17 @@
previously appeared duplicated across several dialog modules.
"""

from typing import Callable, Iterable, List, Optional, Tuple
from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtGui import QStandardItem


# Monospace stack used across the app for address/value text. An explicit
# family list rather than the platform's default fixed font, so every table
# renders addresses at the same family and size on every OS.
MONOSPACE_FAMILY = "Menlo, Consolas, Courier New"

# Workers that wouldn't stop in time on close are parked here so they are never
# destroyed while still running (that aborts the whole process with
# "QThread: Destroyed while thread is still running"). The list is module-level
Expand DownExpand Up@@ -65,13 +70,42 @@ class NumericItem(QStandardItem):

Used by columns showing formatted numbers (sizes, addresses, PIDs) so the
table sorts by the underlying value rather than the lexical label.

The data storage interface is overridden because Qt keeps item data in a
QVariant, whose integers cap at qint64. Values past 2**63 can't make that
conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the
memory map hands one such address to the C++ side and gets "OverflowError:
int too big to convert", leaving the table half-populated. The workaround
is to keep user-role payloads on the Python side, where an int is an int.

The flip side: those payloads never reach the C++ model, so read them off
the item (``item.data(role)``) and never through ``model.data(index,
role)`` — that path converts the value back into a QVariant and overflows
all over again.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

def __lt__(self, other):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_data: dict[int, Any] = {}

def setData(self, value: Any, role: int = Qt.UserRole):
if role >= Qt.UserRole:
self._user_data[int(role)] = value
self.emitDataChanged()
else:
super().setData(value, role)

def data(self, role: int = Qt.UserRole) -> Any:
if role >= Qt.UserRole:
return self._user_data.get(int(role))
else:
return super().data(role)

def __lt__(self, other: QStandardItem) -> bool:
try:
return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole))
return int(self.data()) < int(other.data())
except (TypeError, ValueError):
return super().__lt__(other)
return self.text() < other.text()


def parse_hex_address(text: str) -> Optional[int]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem


def _format_size(size: int) -> str:
Expand DownExpand Up@@ -265,7 +265,7 @@ def _build_ui(self) -> None:

self._size_edit = QLineEdit()
self._size_edit.setPlaceholderText("amount")
self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
self._size_edit.setFixedWidth(140)
self._size_edit.returnPressed.connect(self._on_allocate)
footer.addWidget(self._size_edit)
Expand DownExpand Up@@ -380,6 +380,8 @@ def _populate(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for region in self._snapshot:
addr = int(region.address)
Expand All@@ -391,6 +393,7 @@ def _populate(self) -> None:
shown += 1

addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setFont(mono_font)
addr_item.setData(addr, Qt.UserRole)

size_item = NumericItem(_format_size(size))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@
from typing import List, Optional

from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
Expand All@@ -38,7 +38,7 @@
from PyMemoryEditor import AbstractProcess, ModuleInfo

from ._auto_refresh_dialog import AutoRefreshTableDialog
from ._widgets import NumericItem
from ._widgets import MONOSPACE_FAMILY, NumericItem
from .memory_map_dialog import _format_size


Expand DownExpand Up@@ -157,6 +157,8 @@ def _apply_filter(self) -> None:
self._table.setSortingEnabled(False)
self._model.setRowCount(0)

mono_font = QFont(MONOSPACE_FAMILY, 10)

shown = 0
for module in self._modules:
if needle and needle not in module.name.lower() and needle not in module.path.lower():
Expand All@@ -167,6 +169,7 @@ def _apply_filter(self) -> None:

base = int(module.base_address)
base_item = NumericItem(f"0x{base:016X}")
base_item.setFont(mono_font)
base_item.setData(base, Qt.UserRole)

size = int(module.size)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,14 +52,19 @@
from PyMemoryEditor import AbstractProcess, PointerPath
from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths

from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread
from ._widgets import (
MONOSPACE_FAMILY,
NumericItem,
parse_hex_address,
shutdown_worker_thread,
)
from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec


_LOG = logging.getLogger(__name__)

# Monospace stack used elsewhere in the app for address/value text.
_MONO = "Menlo, Consolas, Courier New"
# Short alias for the app-wide monospace stack (used on every row built here).
_MONO = MONOSPACE_FAMILY

# Stream resolved paths to the table in batches this size, so a scan that finds
# thousands of paths updates the UI smoothly instead of one row at a time.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-

"""
Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``.

Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``:
Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at
0xffffffffff600000 (above 2**63), so pushing that address through
``QStandardItem.setData`` raises "OverflowError: int too big to convert" and
leaves the memory map half-populated.

Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough,
so they keep running when ``pytest-qt`` isn't installed.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
"""

import os

import pytest


pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).")

# Offscreen platform plugin: no display server needed, runs on CI.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")


@pytest.fixture(scope="module")
def qapp():
"""A single QApplication for the module (Qt allows only one per process)."""
from PySide6.QtWidgets import QApplication

return QApplication.instance() or QApplication([])


def test_pyside_widget_regressions(qapp):
"""
Test for Pyside related regressions, like potential overflow and comparison in NumericItem.
"""

from PySide6.QtCore import Qt
from PySide6.QtGui import QStandardItemModel

from PyMemoryEditor.app import _widgets

unsigned_64bit_max = 0xffff_ffff_ffff_ffff
big_number = 2 ** 128

# Overflow regressions.
item = _widgets.NumericItem()
item.setData(unsigned_64bit_max)
assert item.data() == unsigned_64bit_max

item2 = _widgets.NumericItem()
item2.setData(big_number)
assert item2.data() == big_number

# Non-numeric payloads must fall back to the labels instead of recursing
# into QStandardItem::operator< (that recursion segfaulted mid-sort).
assert item < item2

item3 = _widgets.NumericItem('aaa')
item3.setData('hello world')
item4 = _widgets.NumericItem('bbb')
item4.setData('hello world')
assert item3 < item4
assert not (item4 < item3)

# Distinct user roles must not share a slot.
item5 = _widgets.NumericItem()
item5.setData(111, Qt.UserRole)
item5.setData(222, Qt.UserRole + 1)
assert item5.data(Qt.UserRole) == 111
assert item5.data(Qt.UserRole + 1) == 222

# The path that actually crashed: the C++ sort driving the comparisons over
# a column mixing payloads and None (the process picker's memory column).
model = QStandardItemModel()
for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)):
row_item = _widgets.NumericItem(label)
row_item.setData(payload, Qt.UserRole)
model.appendRow([row_item])
model.sort(0, Qt.AscendingOrder)
order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())]
assert order.index('8 MB') < order.index('120 MB')
Loading