"""DataTools desktop launcher. This is the entry point PyInstaller wraps for Mac / Windows / Linux installers. Double-clicking the produced binary boots a local Streamlit server (``127.0.0.1:``), opens the user's default browser at that URL, and keeps the server alive until the window is closed or the binary is killed. Why a launcher instead of pointing PyInstaller at ``src/gui/app.py``: * Streamlit's CLI normally bootstraps the server via the ``streamlit run`` command. PyInstaller-bundled apps can't shell out to ``streamlit`` because the CLI script lives inside the bundle. We invoke Streamlit's bootstrap directly via :func:`streamlit.web.bootstrap.run`. * A free port has to be picked at runtime — buyers will have other services running on 8501. * The "open browser" step is the buyer's only feedback that something happened; without it they'd see a black terminal flash on Windows and conclude the app didn't start. Local-dev equivalent (no installer): streamlit run src/gui/app.py """ from __future__ import annotations import os import socket import sys import threading import time import webbrowser from pathlib import Path def _find_free_port(start: int = 8501, span: int = 50) -> int: """Return a TCP port that's free on the loopback interface. Prefer 8501 (Streamlit's traditional default — buyer recognises the URL from any docs they've read) and fall back to the next free port in a small range. We don't fall back to OS-allocated (port=0) because the buyer's URL should look stable across restarts within one session. """ for offset in range(span): port = start + offset with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("127.0.0.1", port)) return port except OSError: continue # Last resort: kernel-assigned ephemeral port. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _resolve_app_path() -> Path: """Locate ``src/gui/app.py`` whether running from source or a frozen bundle. PyInstaller's ``onefile`` mode unpacks resources into a temp directory pointed at by ``sys._MEIPASS``. Bundled mode uses that directory; source mode walks up from this file. """ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): # Frozen: app.py was bundled as a data file (see datatools.spec). return Path(sys._MEIPASS) / "src" / "gui" / "app.py" # type: ignore[attr-defined] return Path(__file__).resolve().parent.parent / "src" / "gui" / "app.py" def _open_browser_when_ready(url: str, delay: float = 1.5) -> None: """Open the buyer's default browser to *url* after a short delay. The delay gives Streamlit's HTTP server time to bind. Without it, the browser races the server and renders a "couldn't connect" page that confuses non-technical buyers. 1.5 s is conservative on slow Windows machines; faster machines will see a brief blank tab. """ def _open() -> None: time.sleep(delay) webbrowser.open(url, new=2) threading.Thread(target=_open, daemon=True).start() def main() -> int: """Boot the local Streamlit server and open the browser.""" app_path = _resolve_app_path() if not app_path.exists(): sys.stderr.write( f"DataTools could not find its UI script at {app_path}.\n" "This is usually a bundle-build error. Re-install or " "contact support@datatools.app.\n" ) return 2 port = _find_free_port() url = f"http://127.0.0.1:{port}/" # Pre-set Streamlit options the bundle ships locked. ``server.address`` # = 127.0.0.1 enforces "no network exposure" — Streamlit's default # is 0.0.0.0 which would expose the GUI to the LAN. The privacy # claim on the landing pages depends on this. os.environ.setdefault("STREAMLIT_SERVER_ADDRESS", "127.0.0.1") os.environ.setdefault("STREAMLIT_SERVER_PORT", str(port)) os.environ.setdefault("STREAMLIT_SERVER_HEADLESS", "true") os.environ.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false") # Print before opening the browser so the terminal log doesn't # scroll behind the new browser tab on macOS. print(f"DataTools is running at {url}") print("Close this window or press Ctrl+C to stop.") _open_browser_when_ready(url) # Streamlit's bootstrap entry point — equivalent to running # ``streamlit run app.py`` but in-process so PyInstaller's bundled # interpreter handles it without shelling out to a separate script. from streamlit.web import bootstrap bootstrap.run( str(app_path), is_hello=False, args=[], flag_options={ "server.address": "127.0.0.1", "server.port": port, "server.headless": True, "browser.gatherUsageStats": False, }, ) return 0 if __name__ == "__main__": sys.exit(main())