diff --git a/PyMemoryEditor/__main__.py b/PyMemoryEditor/__main__.py index bdcf06c..912ae30 100644 --- a/PyMemoryEditor/__main__.py +++ b/PyMemoryEditor/__main__.py @@ -1,4 +1,4 @@ -from PyMemoryEditor.app.application import main +from PyMemoryEditor.app.application import main_cli if __name__ == "__main__": - main() + main_cli() diff --git a/PyMemoryEditor/app/__init__.py b/PyMemoryEditor/app/__init__.py index 23c7032..e311bd9 100644 --- a/PyMemoryEditor/app/__init__.py +++ b/PyMemoryEditor/app/__init__.py @@ -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. """ diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index 663ae45..8841d4b 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -6,6 +6,8 @@ working on Windows, Linux and macOS. """ import sys +import signal +import contextlib from dataclasses import dataclass from PyMemoryEditor import __version__ @@ -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 @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 413fb80..4a360f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index d8b6053..9e48b1a 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -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). @@ -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.