#!/usr/bin/env python3 """ build.py - turns gitdesk.py into a single executable with PyInstaller. Windows: double-click build.bat (or in a terminal: py -3 build.py) Linux / macOS: ./build.sh (or: python3 build.py) Result: dist/gitdesk.exe on Windows, dist/gitdesk elsewhere. Build it on the system you'll run it on - PyInstaller can't make a Windows exe on Linux or the other way round. Everything is logged to build.log next to this file. The throwaway build environment goes in a .build folder here - or, if this folder is inside OneDrive (whose syncing locks files mid-build), in %LOCALAPPDATA%\\gitdesk-build instead. """ import os import shutil import subprocess import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) IS_WIN = os.name == "nt" if IS_WIN and "onedrive" in HERE.lower() and os.environ.get("LOCALAPPDATA"): CACHE = os.path.join(os.environ["LOCALAPPDATA"], "gitdesk-build") else: CACHE = os.path.join(HERE, ".build") VENV = os.path.join(CACHE, "venv") VPY = os.path.join(VENV, "Scripts", "python.exe") if IS_WIN else os.path.join(VENV, "bin", "python") WORK = os.path.join(CACHE, "work") DIST = os.path.join(HERE, "dist") EXE = os.path.join(DIST, "gitdesk.exe" if IS_WIN else "gitdesk") LOG = os.path.join(HERE, "build.log") _log = None def say(msg: str = "") -> None: try: print(msg, flush=True) except UnicodeEncodeError: # a console code page that can't show some character print(msg.encode("ascii", "replace").decode(), flush=True) if _log: _log.write(msg + "\n") _log.flush() def run(cmd, show=True): """Run a command, echo and log its output. Returns (exit code, last lines).""" say("> " + " ".join(f'"{c}"' if " " in c else c for c in cmd)) env = dict(os.environ, PYTHONUTF8="1", PYTHONIOENCODING="utf-8") # readable logs on any code page try: p = subprocess.Popen(cmd, cwd=HERE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace") except OSError as e: say(f" couldn't start it: {e}") return 1, [str(e)] tail = [] for line in p.stdout: _log.write(line) tail = (tail + [line.rstrip()])[-40:] if show: print(line, end="", flush=True) p.wait() _log.flush() return p.returncode, tail def fail(problem: str, hints=()) -> int: say() say("=" * 70) say("BUILD FAILED: " + problem) for h in hints: say(" * " + h) say() say(f"Everything that happened is in {LOG}") say("If the reason isn't clear, send that file over.") return 1 def common_hints(text: str): hints = [] low = text.lower() if "permission" in low or "access is denied" in low or "winerror 5" in low or "winerror 32" in low: hints.append("A file was locked. Close gitdesk if it's running, and close any window showing the " "dist folder. Antivirus or OneDrive syncing can also lock files - try again in a minute, " "or move this folder somewhere that isn't synced (e.g. C:\\dev\\gitdesk).") if "virus" in low or "winerror 225" in low or "potentially unwanted" in low: hints.append("Your antivirus blocked the new exe. Windows Defender sometimes flags PyInstaller " "builds by mistake: allow it in Windows Security > Protection history, or add the " "dist folder to the exclusions, then build again.") if "could not find a version" in low or "connection" in low or "ssl" in low or "proxy" in low \ or "getaddrinfo" in low or "network" in low: hints.append("pip couldn't download packages - check the internet connection / VPN / proxy.") if "filename too long" in low or "path too long" in low or "winerror 206" in low: hints.append("A path got too long for Windows. Move this folder somewhere shorter, e.g. C:\\dev\\gitdesk.") if "no module named venv" in low or "ensurepip" in low: hints.append("This Python can't make virtual environments. On Debian/Ubuntu: sudo apt install python3-venv. " "On Windows, reinstall Python from python.org.") return hints def free_old_exe() -> bool: """Make sure the old exe can be replaced (it can't be overwritten while it's running).""" if not os.path.exists(EXE): return True try: os.remove(EXE) return True except OSError: pass # Windows lets you rename a running exe even though you can't delete it. old = EXE + f".old-{int(time.time())}" try: os.replace(EXE, old) say(f"The old exe is still running, so it was renamed to {os.path.basename(old)} - delete it later.") return True except OSError: return False def venv_works() -> bool: if not os.path.exists(VPY): return False try: return subprocess.run([VPY, "-c", "import sys, pip"], capture_output=True, timeout=60).returncode == 0 except (OSError, subprocess.SubprocessError): return False def main() -> int: global _log try: _log = open(LOG, "w", encoding="utf-8") except OSError as e: print(f"Can't write {LOG}: {e}") return 1 say(f"gitdesk build - Python {sys.version.split()[0]} ({sys.executable}) on {sys.platform}") say(f"Folder: {HERE}") if sys.version_info < (3, 9): return fail("Python 3.9 or newer is needed.", ["Install a current Python from https://www.python.org/downloads/"]) for name in ("gitdesk.py", "requirements.txt"): if not os.path.exists(os.path.join(HERE, name)): return fail(f"{name} isn't next to build.py.", ["Put gitdesk.py, requirements.txt, build.py and build.bat in the same folder. If they " "came in a .zip, extract it first - building from inside a zip doesn't work."]) say("\n[1/4] Build environment") if not venv_works(): if os.path.exists(VENV): say("The build environment is broken or from an old Python - recreating it.") shutil.rmtree(VENV, ignore_errors=True) os.makedirs(CACHE, exist_ok=True) rc, tail = run([sys.executable, "-m", "venv", VENV]) if rc or not venv_works(): return fail("Couldn't create the build environment.", common_hints("\n".join(tail)) or ["Delete the folder " + CACHE + " and try again."]) say(f"Using {VENV}") say("\n[2/4] Installing PyInstaller and Textual (downloads them the first time)") run([VPY, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pip"], show=False) rc, tail = run([VPY, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pyinstaller", "-r", os.path.join(HERE, "requirements.txt")]) if rc: return fail("pip couldn't install PyInstaller / Textual.", common_hints("\n".join(tail)) or ["Check your internet connection, then try again.", "If it keeps failing, delete " + CACHE + " and try again."]) if IS_WIN and subprocess.run([VPY, "-c", "import tkinter"], capture_output=True).returncode != 0: say("Note: this Python has no tkinter, so Choose... in the exe will use the older Windows folder " "dialog. To get the Explorer one: run the python.org installer again > Modify > tick " "'tcl/tk and IDLE'.") say("\n[3/4] Building the executable (takes a minute)") if not free_old_exe(): return fail(f"{EXE} is in use and can't be replaced.", ["Close gitdesk (every window running it), then build again."]) cmd = [VPY, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", "--name", "gitdesk", # Textual and Rich load some of their modules on demand, so bundle all of them. "--collect-submodules", "textual", "--collect-data", "textual", "--collect-submodules", "rich", "--specpath", WORK, "--workpath", WORK, "--distpath", DIST, os.path.join(HERE, "gitdesk.py")] icon = os.path.join(HERE, "gitdesk.ico") if IS_WIN and os.path.exists(icon): cmd[-1:-1] = ["--icon", icon] rc, tail = run(cmd) if rc or not os.path.exists(EXE): return fail("PyInstaller couldn't build the executable.", common_hints("\n".join(tail))) say("\n[4/4] Checking that the new executable starts") try: p = subprocess.run([EXE, "--selftest"], capture_output=True, text=True, timeout=180, stdin=subprocess.DEVNULL, encoding="utf-8", errors="replace") out = (p.stdout + p.stderr).strip() ok = p.returncode == 0 and "selftest ok" in out except (OSError, subprocess.SubprocessError) as e: out, ok = str(e), False _log.write(out + "\n") if not ok: say(out[-2000:]) return fail("The executable was built but didn't start properly.", common_hints(out)) size = os.path.getsize(EXE) / 1024 / 1024 say() say("=" * 70) say(f"Done: {EXE} ({size:.0f} MB)") say("Copy it anywhere (a folder on your PATH is handy) and run 'gitdesk' inside a repo,") say("or just double-click it.") return 0 if __name__ == "__main__": try: code = main() except KeyboardInterrupt: code = fail("Stopped.") except Exception as e: # never just vanish - always explain import traceback say(traceback.format_exc()) code = fail(f"Unexpected error: {e}") sys.exit(code)