Pick up and finish yesterday's cut-off Tier B pass. - build/: PyInstaller scaffold (datatools.spec + launcher.py + hook-streamlit.py + README) — folder-mode bundle, locked 127.0.0.1, per-OS recipe - marketing/COPY.md: single source of truth for every customer-facing string — landing H1/sub/CTAs, demo CTAs, email subjects, Gumroad listing, banned phrases - marketing/community-posts/: 9 drafts (3 posts × 3 niches: bookkeeper, revops, shopify-pet) — story / tip / soft-offer - marketing/emails/: 18 drafts (Gumroad delivery + 5-touch onboarding × 3 niches), per-niche segmentation guidance - docs/NEXT-STEPS.md: flip 2.2 / 2.4 / 3.1 / 3.4 to done with pointers to the new assets; add Phase 0 inventory rows - .gitignore: narrow `build/` ignore so PyInstaller spec + launcher + hooks get tracked, only generated artifacts (build/build/, build/__pycache__/, build/dist/) stay ignored Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
5.0 KiB
Python
139 lines
5.0 KiB
Python
"""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:<random-free-port>``), 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())
|