Files
2026-11-06 00:18:31 -08:00

221 lines
9.4 KiB
Python

#!/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)