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
4 changes: 2 additions & 2 deletions PyMemoryEditor/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from PyMemoryEditor.app.application import main
from PyMemoryEditor.app.application import main_cli

if __name__ == "__main__":
main()
main_cli()
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@
A Cheat-Engine-inspired memory editor built on PySide6 (Qt for Python).
Cross-platform: works on Windows, Linux and macOS.

Entry point: PyMemoryEditor.app.application:main
Entry points: PyMemoryEditor.app.application:main_cli for the console script
(adds Ctrl+C handling), :main to run the app in-process without touching the
caller's signal handlers.
"""
60 changes: 58 additions & 2 deletions PyMemoryEditor/app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
working on Windows, Linux and macOS.
"""
import sys
import signal
import contextlib
from dataclasses import dataclass

from PyMemoryEditor import __version__
Expand Down Expand Up @@ -451,14 +453,50 @@ def apply_dark_theme(app) -> None:
"""


@contextlib.contextmanager
def _scoped_signal_handler(signalnum, handler):
"""
Temporarily change a signal handler in the scope of a with block.

Becomes a no-op off the main thread, where `signal.signal` raises
ValueError: an embedder driving the app from a worker thread shouldn't be
hard-crashed just because the Ctrl+C handler can't be installed there.
"""
try:
previous_handler = signal.signal(signalnum, handler)
except ValueError:
# Off the main thread. (`signal.signal` also raises ValueError for a
# signal number the platform rejects, but the only call site passes
# SIGINT, which every supported platform has.)
yield
return
try:
yield
finally:
# `signal.signal` reports None when "an unknown handler is in effect",
# i.e. one installed outside Python — plausible when the app is
# embedded in a host that set SIGINT up in C before the signal module
# initialized. Passing that None back raises TypeError, which would
# blow up on the way out of a run that otherwise succeeded, so leave
# the handler alone instead: SIG_DFL is closer to the host's intent
# than a crash.
if previous_handler is not None:
signal.signal(signalnum, previous_handler)


def main(argv=None):
"""
Entry point for the ``pymemoryeditor`` console script.
Run the app. Safe to call in-process: it installs no signal handler.

``argv`` defaults to ``sys.argv`` so packaging tools (which call
``main()`` with no arguments) keep working. Tests and embedders can pass
an explicit list — previously a positional ``*args`` was accepted but
ignored, which made the parameter meaningless.

Terminal users want Ctrl+C to kill the app, which takes a process-wide
signal change — see :func:`main_cli`, the console-script entry point.
Library callers get this function untouched so embedding the app can't
disturb the host's own SIGINT handling.
"""
if argv is None:
argv = sys.argv
Expand Down Expand Up @@ -506,5 +544,23 @@ def main(argv=None):
pass


def main_cli(argv=None):
"""
Entry point for the ``pymemoryeditor`` console script and ``python -m``.

Qt's event loop blocks inside C++, so Python's default SIGINT handler only
runs once the interpreter next regains control. In practice it raised
KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override,
where PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT
back to the OS for the duration of the run so Ctrl+C from a terminal
terminates the app immediately.

Kept separate from :func:`main` so that process-wide change only happens
when the app *is* the process, never when it is embedded in someone else's.
"""
with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
return main(argv)


if __name__ == "__main__":
main()
main_cli()
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ dev = [
]

[project.scripts]
pymemoryeditor = "PyMemoryEditor.app.application:main"
# main_cli wraps main() with the SIGINT scope guard that makes Ctrl+C kill the
# app from a terminal (#76). Library callers should use main() directly, which
# installs no signal handler.
pymemoryeditor = "PyMemoryEditor.app.application:main_cli"

[project.urls]
Homepage = "https://github.com/JeanExtreme002/PyMemoryEditor"
Expand Down
137 changes: 137 additions & 0 deletions tests/app/test_app_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
version flag).
3. With PySide6 available, the ``MainWindow`` and ``CheatTable`` widgets can
be constructed against a self-PID ``OpenProcess`` and torn down cleanly.
4. Ctrl+C handling stays where it belongs: ``main_cli()`` scopes SIG_DFL to
the run so a terminal can kill the blocked Qt event loop, while ``main()``
leaves the process-wide handler untouched for in-process callers.

Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via
the ``app`` extra).
Expand Down Expand Up @@ -53,6 +56,140 @@ def test_version_flag_prints_and_exits(capsys):
assert result is None


def _stub_cancelled_picker(monkeypatch):
"""Make the process picker cancel, so main() returns before building a window."""
from PyMemoryEditor.app import open_process_dialog

class _RejectedDialog:
class DialogCode:
Accepted = 1

def exec(self):
return 0 # anything != Accepted

process = None

monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _RejectedDialog)


def _sentinel_handler(signum, frame): # pragma: no cover - installed, never raised
pass


def test_main_leaves_the_callers_sigint_handler_alone(monkeypatch):
"""
``main()`` is a supported in-process entry point, so it must not touch the
process-wide SIGINT handler: an embedder's own Ctrl+C handling has to
survive running the app. The terminal-facing behaviour lives in
``main_cli()`` instead.
"""
import signal

from PyMemoryEditor.app import application

_stub_cancelled_picker(monkeypatch)

original = signal.signal(signal.SIGINT, _sentinel_handler)
try:
assert application.main(["pymemoryeditor"]) is None
assert signal.getsignal(signal.SIGINT) is _sentinel_handler
finally:
signal.signal(signal.SIGINT, original)


def test_main_cli_scopes_sig_dfl_to_the_run(monkeypatch):
"""
``main_cli()`` is what the console script and ``python -m`` invoke. It hands
SIGINT to the OS so Ctrl+C kills the blocked Qt event loop (#76), then puts
the previous handler back so the change doesn't outlive the run.
"""
import signal

from PyMemoryEditor.app import application, open_process_dialog

seen = {}

class _ProbingDialog:
class DialogCode:
Accepted = 1

def exec(self):
# Sampled mid-run: this is where the Qt event loop would block.
seen["inside"] = signal.getsignal(signal.SIGINT)
return 0

process = None

monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _ProbingDialog)

original = signal.signal(signal.SIGINT, _sentinel_handler)
try:
assert application.main_cli(["pymemoryeditor"]) is None
assert seen["inside"] is signal.SIG_DFL
assert signal.getsignal(signal.SIGINT) is _sentinel_handler
finally:
signal.signal(signal.SIGINT, original)


def test_scoped_signal_handler_is_a_noop_off_the_main_thread():
"""
``signal.signal`` only works on the main thread. The helper must degrade to
a no-op there instead of raising, so an embedder that drives the app from a
worker thread keeps working.
"""
import signal
import threading

from PyMemoryEditor.app.application import _scoped_signal_handler

outcome = {}

def worker():
try:
with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
outcome["body_ran"] = True
outcome["error"] = None
except BaseException as exc: # noqa: BLE001 - report, don't swallow
outcome["error"] = exc

thread = threading.Thread(target=worker)
thread.start()
thread.join(timeout=10)

assert outcome.get("body_ran") is True
assert outcome.get("error") is None


def test_scoped_signal_handler_tolerates_unknown_previous_handler(monkeypatch):
"""
``signal.signal`` reports ``None`` as the previous handler when "an unknown
handler is in effect" — one installed outside Python, which is what an
embedding host may have done before the signal module initialized. Handing
that ``None`` back to ``signal.signal`` raises TypeError, so the guard has
to skip the restore rather than crash on the way out of a good run.
"""
import signal

from PyMemoryEditor.app.application import _scoped_signal_handler

real_signal = signal.signal
original = signal.getsignal(signal.SIGINT)

def fake_signal(signum, handler):
"""Report None on the way in, like a C-installed handler would."""
real_signal(signum, handler)
return None if handler is signal.SIG_DFL else original

monkeypatch.setattr(signal, "signal", fake_signal)
try:
with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
pass
finally:
monkeypatch.undo()
if original is not None:
signal.signal(signal.SIGINT, original)


def test_app_modules_import_cleanly():
"""Every app submodule should import without side effects beyond Qt setup."""
# Order matches the dependency graph: leaves first, container last.
Expand Down
Loading