#!/usr/bin/env python3 """ novelWriter – Packaging Utils ============================= File History: Created: 2019-05-16 [0.5.1] Renamed: 2023-07-26 [2.1b1] This file is a part of novelWriter Copyright 2018–2024, Veronica Berglyd Olsen This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ from __future__ import annotations import argparse import datetime import email.utils import os import shutil import subprocess import sys import zipfile from pathlib import Path CURR_DIR = Path(__file__).parent SETUP_DIR = CURR_DIR / "setup" SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" OS_LINUX = sys.platform.startswith("linux") OS_DARWIN = sys.platform.startswith("darwin") OS_WIN = sys.platform.startswith("win32") # =============================================================================================== # # Utilities # =============================================================================================== # def extractVersion(beQuiet: bool = False) -> tuple[str, str, str]: """Extract the novelWriter version number without having to import anything else from the main package. """ def getValue(text: str) -> str: bits = text.partition("=") return bits[2].strip().strip('"') numVers = "0" hexVers = "0x0" relDate = "Unknown" initFile = Path("novelwriter") / "__init__.py" try: for aLine in initFile.read_text(encoding="utf-8").splitlines(): if aLine.startswith("__version__"): numVers = getValue((aLine)) if aLine.startswith("__hexversion__"): hexVers = getValue((aLine)) if aLine.startswith("__date__"): relDate = getValue((aLine)) except Exception as exc: print("Could not read file: %s" % initFile) print(str(exc)) if not beQuiet: print("novelWriter version: %s (%s) at %s" % (numVers, hexVers, relDate)) return numVers, hexVers, relDate def stripVersion(version: str) -> str: """Strip the pre-release part from a version number.""" if "a" in version: return version.partition("a")[0] elif "b" in version: return version.partition("b")[0] elif "rc" in version: return version.partition("rc")[0] else: return version def readFile(fileName: str) -> str: """Read an entire file and return as a string.""" return Path(fileName).read_text(encoding="utf-8") def writeFile(fileName: str, text: str) -> None: """Write string to file.""" Path(fileName).write_text(text, encoding="utf-8") return def toUpload(srcPath: str | Path, dstName: str | None = None) -> None: """Copy a file produced by one of the build functions to the upload directory. The file can optionally be given a new name. """ uplDir = Path("dist_upload") uplDir.mkdir(exist_ok=True) srcPath = Path(srcPath) shutil.copyfile(srcPath, uplDir / (dstName or srcPath.name)) return def makeCheckSum(sumFile: str, cwd: Path | str | None = None) -> str: """Create a SHA256 checksum file.""" try: if cwd is None: shaFile = sumFile+".sha256" else: shaFile = os.path.join(cwd, sumFile+".sha256") with open(shaFile, mode="w") as fOut: subprocess.call(["shasum", "-a", "256", sumFile], stdout=fOut, cwd=cwd) print("SHA256 Sum: %s" % shaFile) except Exception as exc: print("Could not generate sha256 file") print(str(exc)) return "" return shaFile def checkAssetsExist() -> bool: """Check that the necessary assets exist ahead of a build.""" hasSample = False hasManual = False hasQmData = False sampleZip = os.path.join("novelwriter", "assets", "sample.zip") if os.path.isfile(sampleZip): print(f"Found: {sampleZip}") hasSample = True pdfManual = os.path.join("novelwriter", "assets", "manual.pdf") if os.path.isfile(pdfManual): print(f"Found: {pdfManual}") hasManual = True i18nAssets = os.path.join("novelwriter", "assets", "i18n") for i18nItem in os.listdir(i18nAssets): i18nPath = os.path.join(i18nAssets, i18nItem) if os.path.isfile(i18nPath) and i18nPath.endswith(".qm"): print(f"Found: {i18nPath}") hasQmData = True return hasSample and hasManual and hasQmData # =============================================================================================== # # General # =============================================================================================== # ## # Print Version ## def printVersion(args: argparse.Namespace) -> None: """Print the novelWriter version and exit.""" print(extractVersion(beQuiet=True)[0], end=None) return ## # Package Installer (pip) ## def installPackages(args: argparse.Namespace) -> None: """Install package dependencies both for this script and for running novelWriter itself. """ print("") print("Installing Dependencies") print("=======================") print("") installQueue = ["pip", "-r requirements.txt"] if args.mac: installQueue.append("pyobjc") elif args.win: installQueue.append("pywin32") pyCmd = [sys.executable, "-m"] pipCmd = ["pip", "install", "--user", "--upgrade"] for stepCmd in installQueue: pkgCmd = stepCmd.split(" ") try: subprocess.call(pyCmd + pipCmd + pkgCmd) except Exception as exc: print("Failed with error:") print(str(exc)) sys.exit(1) return ## # Clean Build and Dist Folders (build-clean) ## def cleanBuildDirs(args: argparse.Namespace) -> None: """Recursively delete the 'build' and 'dist' folders.""" print("") print("Cleaning up build environment ...") print("") folders = [ CURR_DIR / "build", CURR_DIR / "dist", CURR_DIR / "dist_deb", CURR_DIR / "dist_minimal", CURR_DIR / "dist_appimage", CURR_DIR / "novelWriter.egg-info", ] for folder in folders: if folder.is_dir(): try: shutil.rmtree(folder) print("Deleted: %s" % folder) except OSError: print("Failed: %s" % folder) else: print("Missing: %s" % folder) print("") return # =============================================================================================== # # Additional Builds # =============================================================================================== # ## # Build PDF Manual (manual) ## def buildPdfManual(args: argparse.Namespace | None = None) -> None: """This function will build the documentation as manual.pdf.""" print("") print("Building PDF Manual") print("===================") print("") buildFile = CURR_DIR / "docs" / "build" / "latex" / "manual.pdf" finalFile = CURR_DIR / "novelwriter" / "assets" / "manual.pdf" finalFile.unlink(missing_ok=True) try: subprocess.call(["make", "clean"], cwd="docs") exCode = subprocess.call(["make", "latexpdf"], cwd="docs") if exCode == 0: print("") buildFile.rename(finalFile) else: raise Exception(f"Build returned error code {exCode}") print("PDF manual build: OK") print("") except Exception as exc: print("PDF manual build: FAILED") print("") print(str(exc)) print("") print("Dependencies:") print(" * pip install sphinx") print(" * Package latexmk") print(" * LaTeX build system") print("") print(" On Debian/Ubuntu, install: python3-sphinx latexmk texlive texlive-latex-extra") print("") sys.exit(1) if not os.path.isfile(finalFile): print("No output file was found!") print("") sys.exit(1) return ## # Sample Project ZIP File Builder (sample) ## def buildSampleZip(args: argparse.Namespace | None = None) -> None: """Bundle the sample project into a single zip file to be saved into the novelwriter/assets folder for further bundling into builds. """ print("") print("Building Sample ZIP File") print("========================") print("") srcSample = CURR_DIR / "sample" dstSample = CURR_DIR / "novelwriter" / "assets" / "sample.zip" if srcSample.is_dir(): dstSample.unlink(missing_ok=True) with zipfile.ZipFile(dstSample, "w") as zipObj: print("Compressing: nwProject.nwx") zipObj.write(srcSample / "nwProject.nwx", "nwProject.nwx") for doc in (srcSample / "content").iterdir(): print(f"Compressing: content/{doc.name}") zipObj.write(doc, f"content/{doc.name}") else: print("Error: Could not find sample project source directory.") sys.exit(1) print("") print("Built file: %s" % dstSample) print("") return ## # Import Translations (import-i18n) ## def importI18nUpdates(args: argparse.Namespace) -> None: """Import new translation files from a zip file.""" print("") print("Import Updated Translations") print("===========================") print("") fileName = Path(args.file).absolute() if not fileName.is_file(): print("File not found ...") sys.exit(1) dstPath = CURR_DIR / "novelwriter" / "assets" / "i18n" srcPath = CURR_DIR / "i18n" print(f"Loading file: {fileName}") with zipfile.ZipFile(fileName) as zipObj: for item in zipObj.namelist(): if item.startswith("nw_") and item.endswith(".ts"): zipObj.extract(item, srcPath) print(f"Extracted: {item} > {srcPath / item}") elif item.startswith("project_") and item.endswith(".json"): zipObj.extract(item, dstPath) print(f"Extracted: {item} > {dstPath / item}") else: print(f"Skipped: {item}") print("") return ## # Qt Linguist TS Builder (qtlupdate) ## def updateTranslationSources(args: argparse.Namespace) -> None: """Build the lang.ts files for Qt Linguist.""" print("") print("Building Qt Translation Files") print("=============================") try: # Using the pylupdate tool from PyQt6 as it supports TS file format 2.1. from PyQt6.lupdate.lupdate import lupdate except ImportError: print("ERROR: This command requires lupdate from PyQt6") print("On Debian/Ubuntu, install: pyqt6-dev-tools") sys.exit(1) print("") print("Scanning Source Tree:") print("") sources = list((CURR_DIR / "novelwriter").glob("**/*.py")) sources.insert(0, CURR_DIR / "i18n" / "qtbase.py") for source in sources: print(source.relative_to(CURR_DIR)) print("") print("TS Files to Update:") print("") translations = [] for item in [Path(str(f)).absolute() for f in args.files]: if not (item.name.startswith("nw_") and item.suffix == ".ts"): print(f"Skipped: {item}") continue if item.is_file(): translations.append(item) print(f"Added: {item}") elif item.exists(): continue else: # Create an empty new language file langCode = item.name[3:-3] item.write_text( "\n" "\n" f"\n" ) translations.append(item) print(f"Created: {item}") print("") print("Updating Language Files:") print("") lupdate( sources=[str(f) for f in sources], translation_files=[str(f) for f in translations], no_obsolete=True, no_summary=False, ) print("") return ## # Qt Linguist QM Builder (qtlrelease) ## def buildTranslationAssets(args: argparse.Namespace | None = None) -> None: """Build the lang.qm files for Qt Linguist.""" print("") print("Building Qt Localisation Files") print("==============================") print("") print("TS Files to Build:") print("") srcDir = CURR_DIR / "i18n" dstDir = CURR_DIR / "novelwriter" / "assets" / "i18n" srcList = [] for item in srcDir.iterdir(): if item.is_file() and item.suffix == ".ts" and item.name != "nw_base.ts": srcList.append(item) print(item) print("") print("Building Translation Files:") print("") try: subprocess.call(["lrelease", "-verbose", *srcList]) except Exception as exc: print("Qt5 Linguist tools seem to be missing") print("On Debian/Ubuntu, install: qttools5-dev-tools pyqt5-dev-tools") print(str(exc)) sys.exit(1) print("") print("Moving QM Files to Assets") print("") dstRel = dstDir.relative_to(CURR_DIR) for item in srcDir.iterdir(): if item.is_file() and item.suffix == ".qm": item.rename(dstDir / item.name) print("Moved: %s -> %s" % (item.relative_to(CURR_DIR), dstRel / item.name)) print("") return ## # Clean Assets (clean-assets) ## def cleanBuiltAssets(args: argparse.Namespace | None = None) -> None: """Remove assets built by this script.""" print("") print("Removing Built Assets") print("=====================") print("") assets = [ CURR_DIR / "novelwriter" / "assets" / "sample.zip", CURR_DIR / "novelwriter" / "assets" / "manual.pdf", ] assets.extend((CURR_DIR / "novelwriter" / "assets" / "i18n").glob("*.qm")) for asset in assets: if asset.is_file(): asset.unlink() print(f"Deleted: {asset.relative_to(CURR_DIR)}") print("") return ## # Build Assets (build-assets) ## def buildAllAssets(args: argparse.Namespace) -> None: """Build all assets.""" cleanBuiltAssets() buildPdfManual() buildSampleZip() buildTranslationAssets() return ## # Generate MacOS PList ## def genMacOSPlist() -> None: """Set necessary values for .plist file for MacOS build.""" outDir = "setup/macos" numVers = stripVersion(extractVersion()[0]) copyrightYear = datetime.datetime.now().year # These keys are no longer used but are present for compatibility pkgVersMaj, pkgVersMin = numVers.split(".")[:2] plistXML = readFile(f"{outDir}/Info.plist.template").format( macosBundleSVers=numVers, macosBundleVers=numVers, macosBundleVersMajor=pkgVersMaj, macosBundleVersMinor=pkgVersMin, macosBundleCopyright=f"Copyright 2018–{copyrightYear}, Veronica Berglyd Olsen", ) print(f"Writing Info.plist to {outDir}/Info.plist") writeFile(f"{outDir}/Info.plist", plistXML) return # =============================================================================================== # # Python Packaging # =============================================================================================== # ## # Copy Source ## def copySourceCode(dst: Path) -> None: """Copy the novelwriter source tree to path.""" src = CURR_DIR / "novelwriter" for item in src.glob("**/*"): relSrc = item.relative_to(CURR_DIR) if item.suffix in (".pyc", ".pyo"): print(f"Ignore: {relSrc}") continue if item.parent.is_dir() and item.parent.name != "__pycache__": dstDir = dst / relSrc.parent if not dstDir.exists(): dstDir.mkdir(parents=True) print(f"Folder: {dstDir}") if item.is_file(): shutil.copyfile(item, dst / relSrc) print(f"Copied: {dst / relSrc}") return ## # Copy Package Files ## def copyPackageFiles(dst: Path, useCfg: bool = False) -> None: """Copy files needed for packaging.""" copyFiles = ["LICENSE.md", "CREDITS.md", "pyproject.toml"] for copyFile in copyFiles: shutil.copyfile(copyFile, dst / copyFile) print("Copied: %s" % copyFile) (dst / "MANIFEST.in").write_text( "include LICENSE.md\n" "include CREDITS.md\n" # "include data/*\n" "recursive-include novelwriter/assets *\n" ) print("Wrote: MANIFEST.in") if useCfg: # This is needed for Ubuntu up to 22.04 text = (SETUP_DIR / "launchpad_setup.cfg").read_text() text = text.replace("setup/description_pypi.md", "data/description_short.txt") (dst / "setup.cfg").write_text(text) print("Wrote: setup.cfg") (dst / "pyproject.toml").write_text( "[build-system]\n" "requires = [\"setuptools\"]\n" "build-backend = \"setuptools.build_meta\"\n" ) print("Wrote: pyproject.toml") else: text = (CURR_DIR / "pyproject.toml").read_text() text = text.replace("setup/description_pypi.md", "data/description_short.txt") (dst / "pyproject.toml").write_text(text) print("Wrote: pyproject.toml") return ## # Make Debian Package ## def makeDebianPackage( signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable", buildName: str = "", oldSetuptools: bool = False, forLaunchpad: bool = False ) -> str: """Build a Debian package.""" print("") print("Build Debian Package") print("====================") print("On Debian/Ubuntu install: dh-python python3-all debhelper devscripts ") print(" pybuild-plugin-pyproject") print("") # Version Info # ============ numVers, hexVers, relDate = extractVersion() relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") pkgDate = email.utils.format_datetime(relDate.replace(hour=12, tzinfo=None)) print("") if forLaunchpad: pkgVers = numVers.replace("a", "~a").replace("b", "~b").replace("rc", "~rc") else: pkgVers = numVers pkgVers = f"{pkgVers}+{buildName}" if buildName else pkgVers # Set Up Folder # ============= bldDir = CURR_DIR / "dist_deb" bldPkg = f"novelwriter_{pkgVers}" outDir = bldDir / bldPkg debDir = outDir / "debian" datDir = outDir / "data" bldDir.mkdir(exist_ok=True) if outDir.exists(): print("Removing old build files ...") print("") shutil.rmtree(outDir) outDir.mkdir(exist_ok=False) # Check Additional Assets # ======================= if not checkAssetsExist(): print("ERROR: Missing build assets") sys.exit(1) # Copy novelWriter Source # ======================= print("Copying novelWriter source ...") print("") copySourceCode(outDir) print("") print("Copying or generating additional files ...") print("") copyPackageFiles(outDir, oldSetuptools) # Copy/Write Debian Files # ======================= shutil.copytree(SETUP_DIR / "debian", debDir) print("Copied: debian/*") (debDir / "changelog").write_text( f"novelwriter ({pkgVers}) {distName}; urgency=low\n\n" f" * Update to version {pkgVers}\n\n" f" -- Veronica Berglyd Olsen {pkgDate}\n" ) print("Wrote: debian/changelog") # Copy/Write Data Files # ===================== shutil.copytree(SETUP_DIR / "data", datDir) print("Copied: data/*") shutil.copyfile(SETUP_DIR / "description_short.txt", outDir / "data" / "description_short.txt") print("Copied: data/description_short.txt") # Build Package # ============= print("") print("Running dpkg-buildpackage ...") print("") if signKey is None: signArgs = ["-us", "-uc"] else: signArgs = [f"-k{signKey}"] if sourceBuild: subprocess.call(["debuild", "-S"] + signArgs, cwd=outDir) toUpload(bldDir / f"{bldPkg}.tar.xz") else: subprocess.call(["dpkg-buildpackage"] + signArgs, cwd=outDir) shutil.copyfile(bldDir / f"{bldPkg}.tar.xz", bldDir / f"{bldPkg}.debian.tar.xz") toUpload(bldDir / f"{bldPkg}.debian.tar.xz") toUpload(bldDir / f"{bldPkg}_all.deb") toUpload(makeCheckSum(f"{bldPkg}.debian.tar.xz", cwd=bldDir)) toUpload(makeCheckSum(f"{bldPkg}_all.deb", cwd=bldDir)) print("") print("Done!") print("") if sourceBuild: ppaName = "novelwriter" if hexVers[-2] == "f" else "novelwriter-pre" return f"dput {ppaName}/{distName} {bldDir}/{bldPkg}_source.changes" return "" ## # Build Debian Package (build-deb) ## def buildDebianPackage(args: argparse.Namespace) -> None: """Build a .deb package""" if not OS_LINUX: print("ERROR: Command 'build-deb' can only be used on Linux") sys.exit(1) signKey = SIGN_KEY if args.sign else None makeDebianPackage(signKey) return ## # Build Launchpad Packages (build-ubuntu) ## def buildForLaunchpad(args: argparse.Namespace) -> None: """Wrapper for building Debian packages for Launchpad.""" if not OS_LINUX: print("ERROR: Command 'build-ubuntu' can only be used on Linux") sys.exit(1) print("") print("Launchpad Packages") print("==================") print("") if args.first: bldNum = "0" else: bldNum = input("Build number [0]: ") if bldNum == "": bldNum = "0" distLoop = [ ("22.04", "jammy", True), ("23.10", "mantic", False), ("24.04", "noble", False), ] print("Building Ubuntu packages for:") print("") for distNum, codeName, _ in distLoop: print(f" * Ubuntu {distNum} {codeName.title()}") print("") signKey = SIGN_KEY if args.sign else None print(f"Sign Key: {str(signKey)}") print("") dputCmd = [] for distNum, codeName, oldSetup in distLoop: buildName = f"ubuntu{distNum}.{bldNum}" dCmd = makeDebianPackage( signKey=signKey, sourceBuild=True, distName=codeName, buildName=buildName, oldSetuptools=oldSetup, forLaunchpad=True, ) dputCmd.append(dCmd) print("Packages Built") print("==============") print("") for dCmd in dputCmd: print(f" > {dCmd}") print("") return ## # Build AppImage (build-appimage) ## def buildAppImage(args: argparse.Namespace) -> None: """Build an AppImage.""" try: import python_appimage # noqa: F401 # type: ignore except ImportError: print( "ERROR: Package 'python-appimage' is missing on this system.\n" " Please run 'pip install --user python-appimage' to install it.\n" ) sys.exit(1) if not OS_LINUX: print("ERROR: Command 'build-ubuntu' can only be used on Linux") sys.exit(1) print("") print("Build AppImage") print("==============") print("") linuxTag = args.linux_tag pythonVer = args.python_version # Version Info # ============ pkgVers, _, relDate = extractVersion() relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") print("") # Set Up Folder # ============= bldDir = CURR_DIR / "dist_appimage" bldPkg = f"novelwriter_{pkgVers}" outDir = bldDir / bldPkg imgDir = bldDir / "appimage" # Set Up Folders # ============== bldDir.mkdir(exist_ok=True) if outDir.exists(): print("Removing old build files ...") print("") shutil.rmtree(outDir) outDir.mkdir() if imgDir.exists(): print("Removing old build metadata files ...") print("") shutil.rmtree(imgDir) imgDir.mkdir() # Remove old AppImages if images := bldDir.glob("*.AppImage"): print("Removing old AppImages") print("") for image in images: image.unlink() # Copy novelWriter Source # ======================= print("Copying novelWriter source ...") print("") copySourceCode(outDir) print("") print("Copying or generating additional files ...") print("") copyPackageFiles(outDir) # Write Metadata # ============== appDescription = (SETUP_DIR / "description_short.txt").read_text() appdataXML = (SETUP_DIR / "novelwriter.appdata.xml").read_text() appdataXML = appdataXML.format(description=appDescription) (imgDir / "novelwriter.appdata.xml").write_text(appdataXML) print("Wrote: novelwriter.appdata.xml") (imgDir / "entrypoint.sh").write_text( '#! /bin/bash \n' '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"' ) print("Wrote: entrypoint.sh") (imgDir / "requirements.txt").write_text(str(outDir)) print("Wrote: requirements.txt") shutil.copyfile(SETUP_DIR / "data" / "novelwriter.desktop", imgDir / "novelwriter.desktop") print("Copied: novelwriter.desktop") shutil.copyfile(SETUP_DIR / "icons" / "novelwriter.svg", imgDir / "novelwriter.svg") print("Copied: novelwriter.svg") shutil.copyfile( SETUP_DIR / "data" / "hicolor" / "256x256" / "apps" / "novelwriter.png", imgDir / "novelwriter.png" ) print("Copied: novelwriter.png") # Build AppImage # ============== try: subprocess.call([ sys.executable, "-m", "python_appimage", "build", "app", "-l", linuxTag, "-p", pythonVer, "appimage" ], cwd=bldDir) except Exception as exc: print("AppImage build: FAILED") print("") print(str(exc)) print("") sys.exit(1) bldFile = list(bldDir.glob("*.AppImage"))[0] outFile = bldDir / f"novelWriter-{pkgVers}.AppImage" bldFile.rename(outFile) shaFile = makeCheckSum(outFile.name, cwd=bldDir) toUpload(outFile) toUpload(shaFile) return ## # Make Windows Setup EXE (build-win-exe) ## def makeWindowsEmbedded(args: argparse.Namespace) -> None: """Set up a package with embedded Python and dependencies for Windows installation. """ import compileall import urllib.request import zipfile print("") print("Build Standalone Windows Package") print("================================") print("") numVers, hexVers, relDate = extractVersion() print("Version: %s" % numVers) # Set Up Folder # ============= if not os.path.isdir("dist"): os.mkdir("dist") bldDir = CURR_DIR / "dist" outDir = bldDir / "novelWriter" libDir = outDir / "lib" if outDir.exists: shutil.rmtree(outDir) outDir.mkdir() libDir.mkdir() # Copy novelWriter Source # ======================= print("Copying and compiling novelWriter source ...") print("") copySourceCode(outDir) files = [ CURR_DIR / "CREDITS.md", CURR_DIR / "LICENSE.md", CURR_DIR / "requirements.txt", SETUP_DIR / "icons" / "novelwriter.ico", SETUP_DIR / "iss_license.txt", ] for item in files: shutil.copyfile(item, outDir) print(f"Copied: {item} > {outDir / item.name}") compileall.compile_dir(outDir / "novelwriter") print("Done") print("") # Download Python Embeddable # ========================== print("Adding Python embeddable ...") pyVers = "%d.%d.%d" % (sys.version_info[:3]) zipFile = "python-%s-embed-amd64.zip" % pyVers pyZip = os.path.join("dist", zipFile) if not os.path.isfile(pyZip): pyUrl = f"https://www.python.org/ftp/python/{pyVers}/{zipFile}" print("Downloading: %s" % pyUrl) urllib.request.urlretrieve(pyUrl, pyZip) print("Extracting ...") with zipfile.ZipFile(pyZip, "r") as inFile: inFile.extractall(outDir) print("Done") print("") # Install Dependencies # ==================== print("Install dependencies ...") try: subprocess.call([ sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "--target", str(libDir) ]) except Exception as exc: print("Failed with error:") print(str(exc)) sys.exit(1) print("Done") print("") # Update Launch File # ================== print("Updating starting script ...") (outDir / "novelWriter.pyw").write_text( "#!/usr/bin/env python3\n" "import os\n" "import sys\n" "\n" "os.curdir = os.path.abspath(os.path.dirname(__file__))\n" "sys.path.insert(0, os.path.join(os.curdir, \"lib\"))\n" "\n" "if __name__ == \"__main__\":\n" " import novelwriter\n" " novelwriter.main(sys.argv[1:])\n" ) print("Done") print("") # Clean Up Files # ============== def unlinkIfFound(file: Path) -> None: if file.is_file(): file.unlink() print(f"Deleted: {file}") def deleteFolder(folder: Path) -> None: if folder.is_dir(): shutil.rmtree(folder) print(f"Deleted: {folder}") print("Deleting Redundant Files") print("========================") print("") pyQt5Dir = libDir / "PyQt5" bindDir = pyQt5Dir / "bindings" qt5Dir = pyQt5Dir / "Qt5" binDir = qt5Dir / "bin" plugDir = qt5Dir / "plugins" qmDir = qt5Dir / "translations" dictDir = libDir / "enchant" / "data" / "mingw64" / "share" / "enchant" / "hunspell" for item in dictDir.iterdir(): if not item.name.startswith(("en_GB", "en_US")): unlinkIfFound(item) for item in qmDir.iterdir(): if not item.name.startswith("qtbase"): unlinkIfFound(item) delQt5 = [ "Qt5Bluetooth", "Qt5DBus", "Qt5Designer", "Qt5Designer", "Qt5Help", "Qt5Location", "Qt5Multimedia", "Qt5MultimediaWidgets", "Qt5Network", "Qt5Nfc", "Qt5OpenGL", "Qt5Positioning", "Qt5PositioningQuick", "Qt5Qml", "Qt5QmlModels", "Qt5QmlWorkerScript", "Qt5Quick", "Qt5Quick3D", "Qt5Quick3DAssetImport", "Qt5Quick3DRender", "Qt5Quick3DRuntimeRender", "Qt5Quick3DUtils", "Qt5QuickControls2", "Qt5QuickParticles", "Qt5QuickShapes", "Qt5QuickTemplates2", "Qt5QuickTest", "Qt5QuickWidgets", "Qt5Sensors", "Qt5SerialPort", "Qt5Sql", "Qt5Test", "Qt5TextToSpeech", "Qt5WebChannel", "Qt5WebSockets", "Qt5WebView", "Qt5Xml", "Qt5XmlPatterns" ] for item in delQt5: qtItem = item.replace("Qt5", "Qt") unlinkIfFound(binDir / f"{item}.dll") unlinkIfFound(pyQt5Dir / f"{qtItem}.pyd") unlinkIfFound(pyQt5Dir / f"{qtItem}.pyi") deleteFolder(bindDir / qtItem) delList = [ binDir / "opengl32sw.dll", qt5Dir / "qml", plugDir / "geoservices", plugDir / "playlistformats", plugDir / "renderers", plugDir / "sensorgestures", plugDir / "sensors", plugDir / "sqldrivers", plugDir / "texttospeech", plugDir / "webview", ] for item in delList: unlinkIfFound(item) deleteFolder(item) print("Done") print("") print("Running Inno Setup") print("##################") print("") # Read the iss template issData = (SETUP_DIR / "win_setup_embed.iss").read_text() issData = issData.replace(r"%%version%%", numVers) issData = issData.replace(r"%%dist%%", str(bldDir)) (CURR_DIR / "setup.iss").write_text(issData) print("") try: subprocess.call(["iscc", "setup.iss"]) except Exception as exc: print("Inno Setup failed with error:") print(str(exc)) sys.exit(1) print("") print("Done") print("") return # =============================================================================================== # # General Installers # =============================================================================================== # ## # XDG Installation (xdg-install) ## def xdgInstall() -> None: """Will attempt to install icons and make a launcher.""" print("") print("XDG Install") print("===========") print("") # Find Executable(s) # ================== exOpts = [] testExec = shutil.which("novelWriter") if testExec is not None: exOpts.append(testExec) testExec = shutil.which("novelwriter") if testExec is not None: exOpts.append(testExec) testExec = os.path.join(os.getcwd(), "novelWriter.py") if os.path.isfile(testExec): exOpts.append(testExec) useExec = "" nOpts = len(exOpts) if nOpts == 0: print("Error: No executables for novelWriter found.") sys.exit(1) elif nOpts == 1: useExec = exOpts[0] else: print("Found multiple novelWriter executables:") print("") for iExec, anExec in enumerate(exOpts): print(" [%d] %s" % (iExec, anExec)) print("") intVal = int(input("Please select which novelWriter executable to use: ")) print("") if intVal >= 0 and intVal < nOpts: useExec = exOpts[intVal] else: print("Error: Invalid selection.") sys.exit(1) print("Using executable: %s " % useExec) print("") # Create and Install Launcher # =========================== # Generate launcher desktopData = readFile(os.path.join("setup", "data", "novelwriter.desktop")) desktopData = desktopData.replace("Exec=novelwriter", f"Exec={useExec}") writeFile("novelwriter.desktop", desktopData) # Remove old desktop icon exCode = subprocess.call( ["xdg-desktop-icon", "uninstall", "novelwriter.desktop"] ) # Install application launcher exCode = subprocess.call( ["xdg-desktop-menu", "install", "--novendor", "novelwriter.desktop"] ) if exCode == 0: print("Installed menu launcher file") else: print(f"Error {exCode}: Could not install menu launcher file") # Install MimeType # ================ exCode = subprocess.call([ "xdg-mime", "install", "setup/data/x-novelwriter-project.xml" ]) if exCode == 0: print("Installed mimetype") else: print(f"Error {exCode}: Could not install mimetype") # Install Icons # ============= iconRoot = "setup/data/hicolor" sizeArr = ["16", "24", "32", "48", "64", "128", "256"] # App Icon for aSize in sizeArr: exCode = subprocess.call([ "xdg-icon-resource", "install", "--novendor", "--noupdate", "--context", "apps", "--size", aSize, f"{iconRoot}/{aSize}x{aSize}/apps/novelwriter.png", "novelwriter" ]) if exCode == 0: print(f"Installed app icon size {aSize}") else: print(f"Error {exCode}: Could not install app icon size {aSize}") # Mimetype for aSize in sizeArr: exCode = subprocess.call([ "xdg-icon-resource", "install", "--noupdate", "--context", "mimetypes", "--size", aSize, f"{iconRoot}/{aSize}x{aSize}/mimetypes/application-x-novelwriter-project.png", "application-x-novelwriter-project" ]) if exCode == 0: print(f"Installed mime icon size {aSize}") else: print(f"Error {exCode}: Could not install mime icon size {aSize}") # Update Cache exCode = subprocess.call(["xdg-icon-resource", "forceupdate"]) if exCode == 0: print("Updated icon cache") else: print(f"Error {exCode}: Could not update icon cache") # Clean up if os.path.isfile("./novelwriter.desktop"): os.unlink("./novelwriter.desktop") print("") print("Done!") print("") return ## # XDG Uninstallation (xdg-uninstall) ## def xdgUninstall() -> None: """Will attempt to uninstall icons and the launcher.""" print("") print("XDG Uninstall") print("=============") print("") # Application Menu Icon exCode = subprocess.call( ["xdg-desktop-menu", "uninstall", "novelwriter.desktop"] ) if exCode == 0: print("Uninstalled menu launcher file") else: print(f"Error {exCode}: Could not uninstall menu launcher file") # Desktop Icon # (No longer installed) exCode = subprocess.call( ["xdg-desktop-icon", "uninstall", "novelwriter.desktop"] ) if exCode == 0: print("Uninstalled desktop launcher file") else: print(f"Error {exCode}: Could not uninstall desktop launcher file") # Also include no longer used sizes sizeArr = ["16", "22", "24", "32", "48", "64", "96", "128", "256", "512"] # App Icons for aSize in sizeArr: exCode = subprocess.call([ "xdg-icon-resource", "uninstall", "--noupdate", "--context", "apps", "--size", aSize, "novelwriter" ]) if exCode == 0: print(f"Uninstalled app icon size {aSize}") else: print(f"Error {exCode}: Could not uninstall app icon size {aSize}") # Mimetype for aSize in sizeArr: exCode = subprocess.call([ "xdg-icon-resource", "uninstall", "--noupdate", "--context", "mimetypes", "--size", aSize, "application-x-novelwriter-project" ]) if exCode == 0: print(f"Uninstalled mime icon size {aSize}") else: print(f"Error {exCode}: Could not uninstall mime icon size {aSize}") # Update Cache exCode = subprocess.call(["xdg-icon-resource", "forceupdate"]) if exCode == 0: print("Updated icon cache") else: print(f"Error {exCode}: Could not update icon cache") print("") print("Done!") print("") return # =============================================================================================== # # Process Command Line # =============================================================================================== # if __name__ == "__main__": """Parse command line options and run the commands.""" parser = argparse.ArgumentParser() parsers = parser.add_subparsers() helpMsg = [ "", "novelWriter Setup Tool", "======================", "", "This tool provides setup and build commands for installing or distibuting", "novelWriter as a package on Linux, Mac and Windows. The available options", "are as follows:", "", "General:", "", " help Print the help message.", " pip Install all package dependencies for novelWriter using pip.", " version Print the novelWriter version.", " build-clean Will attempt to delete 'build' and 'dist' folders.", "", "Additional Builds:", "", " manual Build the help documentation as PDF (requires LaTeX).", " sample Build the sample project zip file and add it to assets.", " qtlupdate Update translation files for internationalisation.", " The files to be updated must be provided as arguments.", " qtlrelease Build the language files for internationalisation.", " clean-assets Delete assets built by manual, sample and qtlrelease.", " gen-plist Generates an Info.plist for use in a MacOS Bundle", "", "Python Packaging:", "", " import-i18n Import updated i18n files from a zip file.", " windows-zip Creates a minimal zip file of the core application without", " all the other source files. Used for Windows builds.", " build-deb Build a .deb package for Debian and Ubuntu. Add --sign to ", " sign package.", " build-ubuntu Build a .deb packages Launchpad. Add --sign to ", " sign package. Add --first to set build number to 0.", " build-win-exe Build a setup.exe file with Python embedded for Windows.", " The package must be built from a minimal windows zip file.", " build-appimage Build an AppImage. Argument --linux-tag defaults to", " manylinux_2_28_x86_64, and --python-version to 3.11.", "", "System Install:", "", " xdg-install Install launcher and icons for freedesktop systems. Run as", " root or with sudo for system-wide install, or as user for", " single user install.", " xdg-uninstall Remove the launcher and icons for the current system as", " installed by the 'xdg-install' command.", "", ] # Version cmdVersion = parsers.add_parser( "version", help="Print the novelWriter version." ) cmdVersion.set_defaults(func=printVersion) # General # ======= # Pip Install cmdPipInstall = parsers.add_parser( "pip", help="Install all package dependencies for novelWriter using pip." ) cmdPipInstall.add_argument("--linux", action="store_true", help="For Linux.", default=OS_LINUX) cmdPipInstall.add_argument("--mac", action="store_true", help="For MacOS.", default=OS_DARWIN) cmdPipInstall.add_argument("--win", action="store_true", help="For Windows.", default=OS_WIN) cmdPipInstall.set_defaults(func=installPackages) # Build Clean cmdBuildClean = parsers.add_parser( "build-clean", help="Recursively delete all build folders." ) cmdBuildClean.set_defaults(func=cleanBuildDirs) # Additional Builds # ================= # Import Translations cmdImportTS = parsers.add_parser( "qtlimport", help="Import updated i18n files from a zip file." ) cmdImportTS.add_argument("file", help="Path to zip file from Crowdin") cmdImportTS.set_defaults(func=importI18nUpdates) # Update i18n Sources cmdUpdateTS = parsers.add_parser( "qtlupdate", help=( "Update translation files for internationalisation. " "The files to be updated must be provided as arguments. " "New files can be created by giving a 'nw_.ts' file name " "where is a valid language code." ) ) cmdUpdateTS.add_argument("files", nargs="+") cmdUpdateTS.set_defaults(func=updateTranslationSources) # Build i18n Files cmdBuildQM = parsers.add_parser( "qtlrelease", help="Build the language files for internationalisation." ) cmdBuildQM.set_defaults(func=buildTranslationAssets) # Build Manual cmdBuildManual = parsers.add_parser( "manual", help="Build the help documentation as a PDF (requires LaTeX)." ) cmdBuildManual.set_defaults(func=buildPdfManual) # Build Sample cmdBuildSample = parsers.add_parser( "sample", help="Build the sample project zip file and add it to assets." ) cmdBuildSample.set_defaults(func=buildSampleZip) # Clean Assets cmdCleanAssets = parsers.add_parser( "clean-assets", help="Delete assets built by manual, sample and qtlrelease." ) cmdCleanAssets.set_defaults(func=cleanBuiltAssets) # Build Assets cmdBuildAssets = parsers.add_parser( "build-assets", help="Build all assets. Includes manual, sample and qtlrelease." ) cmdBuildAssets.set_defaults(func=buildAllAssets) # Python Packaging # ================ # Build Debian Package cmdBuildDeb = parsers.add_parser( "build-deb", help=( "Build a .deb package for Debian and Ubuntu. " "Add --sign to sign package." ) ) cmdBuildDeb.add_argument("--sign", action="store_true", help="Sign the package.") cmdBuildDeb.set_defaults(func=buildDebianPackage) # Build Ubuntu Packages cmdBuildUbuntu = parsers.add_parser( "build-ubuntu", help=( "Build a .deb package for Debian and Ubuntu. " "Add --sign to sign package. " "Add --first to set build number to 0." ) ) cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the package.") cmdBuildUbuntu.add_argument("--first", action="store_true", help="Set build number to 0.") cmdBuildUbuntu.set_defaults(func=buildForLaunchpad) # Build AppImage cmdBuildAppImage = parsers.add_parser( "build-appimage", help=( "Build an AppImage. " "Argument --linux-tag defaults manylinux_2_28_x86_64, and --python-version to 3.11." ) ) cmdBuildAppImage.add_argument( "--linux-tag", default="manylinux_2_28_x86_64", help=( "Linux compatibility tag (e.g. manylinux_2_28_x86_64) " "see https://python-appimage.readthedocs.io/en/latest/#available-python-appimages " "and https://github.com/pypa/manylinux for a list of valid tags." ), ) cmdBuildAppImage.add_argument( "--python-version", default="3.11", help="Python version (e.g. 3.11)" ) cmdBuildAppImage.set_defaults(func=buildAppImage) # Build Windows Inno Setup Installer cmdBuildSetupExe = parsers.add_parser( "build-win-exe", help="Build a setup.exe file with Python embedded for Windows." ) cmdBuildSetupExe.set_defaults(func=makeWindowsEmbedded) # if "gen-plist" in sysArgs: # sysArgs.remove("gen-plist") # genMacOSPlist() # # General Installers # # ================== # if "xdg-install" in sysArgs: # sysArgs.remove("xdg-install") # if hostOS == OS_WIN: # print("ERROR: Command 'xdg-install' cannot be used on Windows") # sys.exit(1) # else: # xdgInstall() # if "xdg-uninstall" in sysArgs: # sysArgs.remove("xdg-uninstall") # if hostOS == OS_WIN: # print("ERROR: Command 'xdg-uninstall' cannot be used on Windows") # sys.exit(1) # else: # xdgUninstall() args = parser.parse_args() args.func(args)