diff --git a/.github/workflows/pyinstaller.yml b/.github/workflows/pyinstaller.yml index ac90d373..60c539f6 100644 --- a/.github/workflows/pyinstaller.yml +++ b/.github/workflows/pyinstaller.yml @@ -61,10 +61,22 @@ jobs: Take into account that `dev` releases may be unstable. Please, use [the development release](https://github.com/soxoj/maigret/releases/tag/main) build from the **main** branch. - Instructions: - - Download the attached file `maigret_standalone.exe` to get the Windows executable. - - Video guide on how to run it: https://youtu.be/qIgwTZOmMmM - - For detailed documentation, visit: https://maigret.readthedocs.io/en/latest/ + ## How to run + + 1. Download the attached `maigret_standalone.exe`. + 2. Either: + - **Double-click it** — Maigret will ask you for a username, run a default search, and pause at the end so the output stays visible. + - **Run it from a terminal** for full options. Press `Win+R`, type `cmd`, hit Enter (or open PowerShell), then: + + ```cmd + cd %USERPROFILE%\Downloads + maigret_standalone.exe USERNAME + maigret_standalone.exe USERNAME --html :: also save an HTML report + maigret_standalone.exe --help :: list all options + ``` + + Video guide: https://youtu.be/qIgwTZOmMmM + Full documentation: https://maigret.readthedocs.io/en/latest/ env: GITHUB_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index dff917e4..9f5746c9 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,19 @@ Don't want to install anything? Use the [community Telegram bot](https://sites.g ### Windows -Download a standalone EXE from [Releases](https://github.com/soxoj/maigret/releases). Video guide: https://youtu.be/qIgwTZOmMmM. +Download `maigret_standalone.exe` from [Releases](https://github.com/soxoj/maigret/releases). You can launch it two ways: + +- **Double-click it** — Maigret will ask for a username, run a default search, and wait at the end so the report links stay visible. +- **Run it from a terminal** — open Command Prompt (press `Win+R`, type `cmd`, hit Enter) or PowerShell to pass extra options: + +```cmd +cd %USERPROFILE%\Downloads +maigret_standalone.exe USERNAME +maigret_standalone.exe USERNAME --html :: also save an HTML report +maigret_standalone.exe --help :: list all options +``` + +Video guide: https://youtu.be/qIgwTZOmMmM. ### Cloud Shells diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 3671449e..8ada727e 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -10,9 +10,31 @@ source code of a bot is `available on GitHub `_ of GitHub repository. +A standalone ``maigret_standalone.exe`` for Windows is published in the +`Releases section `_ of the GitHub +repository. A fresh build is produced automatically after each commit to the +**main** and **dev** branches. -Currently, the new binary is created automatically after each commit to **main** and **dev** branches. +There are two ways to launch the EXE: + +* **Double-click it from Explorer.** Maigret will prompt you for a username, + run a default search, and pause at the end so the printed report links + remain on screen until you press Enter. +* **Run it from a terminal** for full control over options: + + 1. Press ``Win+R``, type ``cmd``, and hit Enter (or use PowerShell). + 2. Change to the folder where you saved the file, e.g. + ``cd %USERPROFILE%\Downloads``. + 3. Run it with at least one username: + + .. code-block:: bat + + maigret_standalone.exe USERNAME + maigret_standalone.exe USERNAME --html :: also save an HTML report + maigret_standalone.exe USERNAME --pdf :: also save a PDF report + maigret_standalone.exe --help :: list all options + +Reports are written next to the EXE in a ``reports\`` subfolder. Video guide on how to run it: https://youtu.be/qIgwTZOmMmM. diff --git a/maigret/resources/db_meta.json b/maigret/resources/db_meta.json index f11e9300..b43e547a 100644 --- a/maigret/resources/db_meta.json +++ b/maigret/resources/db_meta.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-20T21:53:00Z", + "updated_at": "2026-05-21T10:10:02Z", "sites_count": 3158, "min_maigret_version": "0.6.1", "data_sha256": "15283c81e9f18cd07c674a554bbfd0994f18c12c20afd5655d42ba70d1403caa", diff --git a/pyinstaller/maigret_standalone.py b/pyinstaller/maigret_standalone.py index 5b83b447..3470d33e 100755 --- a/pyinstaller/maigret_standalone.py +++ b/pyinstaller/maigret_standalone.py @@ -1,7 +1,77 @@ #!/usr/bin/env python3 import asyncio +import platform +import sys import maigret + +DOUBLE_CLICK_INTRO = """\ +Maigret runs from the command line. You can: + + - call it from cmd/PowerShell: maigret_standalone.exe USERNAME [options] + - or enter a username below for a default search. + +Full options: maigret_standalone.exe --help +Documentation: https://maigret.readthedocs.io/ +""" + + +def _launched_by_double_click() -> bool: + # On Windows, Explorer spawns a fresh console just for our process, so + # GetConsoleProcessList returns 1. When launched from an existing cmd / + # PowerShell session the count is >= 2 (the shell is attached too). + if platform.system() != "Windows": + return False + if len(sys.argv) > 1: + return False + try: + import ctypes + + buf = (ctypes.c_uint * 4)() + count = ctypes.windll.kernel32.GetConsoleProcessList(buf, 4) + return count <= 1 + except Exception: + return False + + +def _pause() -> None: + try: + input("\nPress Enter to exit...") + except EOFError: + pass + + +def _prompt_for_username() -> str: + try: + return input("Username to search: ").strip() + except (EOFError, KeyboardInterrupt): + return "" + + +def main() -> None: + if not _launched_by_double_click(): + asyncio.run(maigret.cli()) + return + + print(DOUBLE_CLICK_INTRO) + username = _prompt_for_username() + + if not username: + print( + "\nNo username entered. Re-run with one, e.g.\n" + " maigret_standalone.exe alice" + ) + _pause() + return + + # Inject the username so maigret's argparse sees it like a normal CLI run. + sys.argv = [sys.argv[0], username] + try: + asyncio.run(maigret.cli()) + finally: + _pause() + + if __name__ == "__main__": - asyncio.run(maigret.cli()) \ No newline at end of file + main() diff --git a/tests/test_standalone_wrapper.py b/tests/test_standalone_wrapper.py new file mode 100644 index 00000000..bdc6798e --- /dev/null +++ b/tests/test_standalone_wrapper.py @@ -0,0 +1,149 @@ +"""Unit tests for the PyInstaller Windows wrapper. + +The wrapper at ``pyinstaller/maigret_standalone.py`` lives outside the +``maigret`` package, so we load it by path. +""" +import importlib.util +import os +import sys +from unittest import mock + +import pytest + + +WRAPPER_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), + "pyinstaller", + "maigret_standalone.py", +) + + +@pytest.fixture +def wrapper(): + spec = importlib.util.spec_from_file_location( + "maigret_standalone_under_test", WRAPPER_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_returns_false_on_non_windows(wrapper): + with mock.patch.object(wrapper.platform, "system", return_value="Darwin"): + assert wrapper._launched_by_double_click() is False + + +def test_returns_false_when_username_argument_present(wrapper): + # Extra argv => user invoked from a shell with a username; never a double-click. + with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe", "alice"]): + assert wrapper._launched_by_double_click() is False + + +def test_double_click_detected_when_only_our_process_attached(wrapper): + fake_ctypes = mock.MagicMock() + fake_ctypes.windll.kernel32.GetConsoleProcessList.return_value = 1 + + with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}): + assert wrapper._launched_by_double_click() is True + + +def test_cmd_invocation_detected_when_shell_also_attached(wrapper): + fake_ctypes = mock.MagicMock() + fake_ctypes.windll.kernel32.GetConsoleProcessList.return_value = 2 + + with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}): + assert wrapper._launched_by_double_click() is False + + +def test_returns_false_when_console_api_raises(wrapper): + fake_ctypes = mock.MagicMock() + fake_ctypes.windll.kernel32.GetConsoleProcessList.side_effect = OSError("boom") + + with mock.patch.object(wrapper.platform, "system", return_value="Windows"), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch.dict(sys.modules, {"ctypes": fake_ctypes}): + assert wrapper._launched_by_double_click() is False + + +def test_main_runs_cli_when_not_double_click(wrapper): + sentinel = object() + with mock.patch.object(wrapper, "_launched_by_double_click", return_value=False), \ + mock.patch.object(wrapper.asyncio, "run") as run_mock, \ + mock.patch.object(wrapper.maigret, "cli", new=mock.Mock(return_value=sentinel)) as cli_mock: + wrapper.main() + + cli_mock.assert_called_once_with() + run_mock.assert_called_once_with(sentinel) + + +def test_main_prompts_and_runs_search_on_double_click(wrapper): + sentinel = object() + original_argv = ["maigret_standalone.exe"] + inputs = iter(["alice", ""]) # username prompt, then pause prompt + + with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \ + mock.patch.object(wrapper.sys, "argv", list(original_argv)), \ + mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \ + mock.patch.object(wrapper.asyncio, "run") as run_mock, \ + mock.patch.object(wrapper.maigret, "cli", new=mock.Mock(return_value=sentinel)): + wrapper.main() + # argv was rewritten to feed argparse the entered username. + assert wrapper.sys.argv == ["maigret_standalone.exe", "alice"] + + run_mock.assert_called_once_with(sentinel) + assert input_mock.call_count == 2 # username prompt + final pause + + +def test_main_skips_search_on_empty_username(wrapper): + inputs = iter(["", ""]) # empty username, then pause prompt + + with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \ + mock.patch.object(wrapper.asyncio, "run") as run_mock, \ + mock.patch.object(wrapper.maigret, "cli") as cli_mock: + wrapper.main() + + cli_mock.assert_not_called() + run_mock.assert_not_called() + assert input_mock.call_count == 2 # asked for username, then paused + + +def test_main_pauses_even_when_cli_raises_system_exit(wrapper): + inputs = iter(["alice", ""]) + + with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch("builtins.input", side_effect=lambda *_: next(inputs)) as input_mock, \ + mock.patch.object(wrapper.asyncio, "run", side_effect=SystemExit(2)), \ + mock.patch.object(wrapper.maigret, "cli", new=mock.Mock()): + with pytest.raises(SystemExit): + wrapper.main() + + # Both inputs should have fired: username prompt + final pause via finally. + assert input_mock.call_count == 2 + + +def test_main_treats_keyboard_interrupt_at_prompt_as_empty(wrapper): + def fake_input(*_args, **_kwargs): + if fake_input.calls == 0: + fake_input.calls += 1 + raise KeyboardInterrupt + fake_input.calls += 1 + return "" + fake_input.calls = 0 + + with mock.patch.object(wrapper, "_launched_by_double_click", return_value=True), \ + mock.patch.object(wrapper.sys, "argv", ["maigret_standalone.exe"]), \ + mock.patch("builtins.input", side_effect=fake_input), \ + mock.patch.object(wrapper.asyncio, "run") as run_mock, \ + mock.patch.object(wrapper.maigret, "cli") as cli_mock: + wrapper.main() + + cli_mock.assert_not_called() + run_mock.assert_not_called()