221 lines
9.4 KiB
Python
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)
|