From b9602da177f9805274a5a18a5113f70516c4b3b1 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 19:26:04 +0100 Subject: [PATCH 01/13] Restructure icon theme builder --- novelwriter/assets/icons/font_awesome.icons | 2 +- pkgutils.py | 93 +------------------ utils/icon_themes.py | 86 ++++++++++++++++- utils/{ => icon_themes}/font_awesome.json | 0 utils/{ => icon_themes}/material_symbols.json | 0 utils/{ => icon_themes}/remix.json | 0 6 files changed, 91 insertions(+), 90 deletions(-) rename utils/{ => icon_themes}/font_awesome.json (100%) rename utils/{ => icon_themes}/material_symbols.json (100%) rename utils/{ => icon_themes}/remix.json (100%) diff --git a/novelwriter/assets/icons/font_awesome.icons b/novelwriter/assets/icons/font_awesome.icons index 847cba7d..a216a561 100644 --- a/novelwriter/assets/icons/font_awesome.icons +++ b/novelwriter/assets/icons/font_awesome.icons @@ -3,7 +3,7 @@ # Meta meta:name = Font Awesome 6 meta:author = Fonticons Inc -meta:license = Font Awesome Free License +meta:license = CC BY 4.0 # Icons icon:alert_error = diff --git a/pkgutils.py b/pkgutils.py index 36433327..ce350dbb 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -35,7 +35,8 @@ import zipfile from pathlib import Path -from utils.icon_themes import processFontAwesome, processMaterialIcons, processRemix +import utils.binary_dist +import utils.icon_themes CURR_DIR = Path(__file__).parent SETUP_DIR = CURR_DIR / "setup" @@ -325,90 +326,6 @@ def buildSampleZip(args: argparse.Namespace | None = None) -> None: return -## -# Import Translations (import-i18n) -## - -def buildIconTheme(args: argparse.Namespace) -> None: - """Build icon themes.""" - print("") - print("Build Icon Themes") - print("=================") - print("") - - workDir = Path(args.sources).absolute() - if not workDir.is_dir(): - print(f"Source directory not found: {workDir}") - sys.exit(1) - - iconsDir = CURR_DIR / "novelwriter" / "assets" / "icons" - - style = args.style - if style in ("all", "material"): - processMaterialIcons(workDir, iconsDir, { - "material_rounded_thin": { - "name": "Material Symbols - Rounded Thin", - "style": "rounded", - "filled": False, - "weight": 200, - }, - "material_rounded_normal": { - "name": "Material Symbols - Rounded Medium", - "style": "rounded", - "filled": False, - "weight": 400, - }, - "material_rounded_bold": { - "name": "Material Symbols - Rounded Bold", - "style": "rounded", - "filled": False, - "weight": 600, - }, - "material_filled_thin": { - "name": "Material Symbols - Filled Thin", - "style": "rounded", - "filled": True, - "weight": 200, - }, - "material_filled_normal": { - "name": "Material Symbols - Filled Medium", - "style": "rounded", - "filled": True, - "weight": 400, - }, - "material_filled_bold": { - "name": "Material Symbols - Filled Bold", - "style": "rounded", - "filled": True, - "weight": 600, - }, - }) - - if style in ("all", "fa"): - processFontAwesome(workDir, iconsDir, { - "font_awesome": { - "name": "Font Awesome 6", - }, - }) - - if style in ("all", "remix"): - processRemix(workDir, iconsDir, { - "remix_outline": { - "name": "Remix Icon - Outline", - "filled": False, - }, - "remix_filled": { - "name": "Remix Icon - Filled", - "filled": True, - }, - }) - - print("Done") - print("") - - return - - ## # Import Translations (import-i18n) ## @@ -1487,9 +1404,9 @@ if __name__ == "__main__": cmdIcons = parsers.add_parser( "icons", help="Build icon theme files from source." ) - cmdIcons.add_argument("sources", help="Working directory for sources.") - cmdIcons.add_argument("style", help="What icon style to build.") - cmdIcons.set_defaults(func=buildIconTheme) + cmdIcons.add_argument("--sources", help="Working directory for sources.") + cmdIcons.add_argument("--style", help="What icon style to build.") + cmdIcons.set_defaults(func=utils.icon_themes.main) # Import Translations cmdImportTS = parsers.add_parser( diff --git a/utils/icon_themes.py b/utils/icon_themes.py index 8b9b5249..681956c6 100644 --- a/utils/icon_themes.py +++ b/utils/icon_themes.py @@ -20,12 +20,16 @@ along with this program. If not, see . """ from __future__ import annotations +import argparse import json import subprocess +import sys from pathlib import Path from xml.etree import ElementTree as ET +from utils.common import ROOT_DIR + UTILS = Path(__file__).parent ET.register_namespace("", "http://www.w3.org/2000/svg") ICONS = [ @@ -140,7 +144,7 @@ ICONS = [ def _loadMap(name: str) -> dict[str, str]: """Load a theme map file.""" - data = json.loads((UTILS / f"{name}.json").read_text(encoding="utf-8")) + data = json.loads((UTILS / "icon_themes" / f"{name}.json").read_text(encoding="utf-8")) icons = {} for key in ICONS: if icon := data.get(key, ""): @@ -311,3 +315,83 @@ def processRemix(workDir: Path, iconsDir: Path, jobs: dict) -> None: print("") return + + +def main(args: argparse.Namespace) -> None: + """Build icon themes entry point.""" + print("") + print("Build Icon Themes") + print("=================") + print("") + + workDir = Path(args.sources).absolute() + if not workDir.is_dir(): + print(f"Source directory not found: {workDir}") + sys.exit(1) + + iconsDir = ROOT_DIR / "novelwriter" / "assets" / "icons" + + style = args.style + if style in ("all", "material"): + processMaterialIcons(workDir, iconsDir, { + "material_rounded_thin": { + "name": "Material Symbols - Rounded Thin", + "style": "rounded", + "filled": False, + "weight": 200, + }, + "material_rounded_normal": { + "name": "Material Symbols - Rounded Medium", + "style": "rounded", + "filled": False, + "weight": 400, + }, + "material_rounded_bold": { + "name": "Material Symbols - Rounded Bold", + "style": "rounded", + "filled": False, + "weight": 600, + }, + "material_filled_thin": { + "name": "Material Symbols - Filled Thin", + "style": "rounded", + "filled": True, + "weight": 200, + }, + "material_filled_normal": { + "name": "Material Symbols - Filled Medium", + "style": "rounded", + "filled": True, + "weight": 400, + }, + "material_filled_bold": { + "name": "Material Symbols - Filled Bold", + "style": "rounded", + "filled": True, + "weight": 600, + }, + }) + + if style in ("all", "fa"): + processFontAwesome(workDir, iconsDir, { + "font_awesome": { + "name": "Font Awesome 6", + }, + }) + + if style in ("all", "remix"): + processRemix(workDir, iconsDir, { + "remix_outline": { + "name": "Remix Icon - Outline", + "filled": False, + }, + "remix_filled": { + "name": "Remix Icon - Filled", + "filled": True, + }, + }) + + print("Done") + print("") + + return diff --git a/utils/font_awesome.json b/utils/icon_themes/font_awesome.json similarity index 100% rename from utils/font_awesome.json rename to utils/icon_themes/font_awesome.json diff --git a/utils/material_symbols.json b/utils/icon_themes/material_symbols.json similarity index 100% rename from utils/material_symbols.json rename to utils/icon_themes/material_symbols.json diff --git a/utils/remix.json b/utils/icon_themes/remix.json similarity index 100% rename from utils/remix.json rename to utils/icon_themes/remix.json From 931863d4ee5dedf2702ea39395db217e32fe40df Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 19:55:50 +0100 Subject: [PATCH 02/13] Build binary on linux --- .gitignore | 1 + novelwriter/config.py | 8 ++++---- pkgutils.py | 6 ++++++ utils/binary_dist.py | 43 +++++++++++++++++++++++++++++++++++++++++++ utils/common.py | 25 +++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 utils/binary_dist.py create mode 100644 utils/common.py diff --git a/.gitignore b/.gitignore index a69e4f00..90e51a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Setup/Install /MANIFEST /build/ +/build_*/ /deploy/ /dist/ /dist_*/ diff --git a/novelwriter/config.py b/novelwriter/config.py index 14123d8a..3d6210af 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -109,10 +109,10 @@ class Config: self._appPath = Path(__file__).parent.absolute() self._appRoot = self._appPath.parent - if self._appRoot.is_file(): - # novelWriter is packaged as a single file - self._appRoot = self._appRoot.parent - self._appPath = self._appRoot + if getattr(sys, "frozen", False): + # novelWriter is packaged as an exe + self._appPath = Path(__file__).parent.parent.absolute() + self._appRoot = self._appPath # Runtime Settings and Variables self._hasError = False # True if the config class encountered an error diff --git a/pkgutils.py b/pkgutils.py index ce350dbb..1110b287 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -1509,6 +1509,12 @@ if __name__ == "__main__": ) cmdBuildSetupExe.set_defaults(func=makeWindowsEmbedded) + # Build Binary + cmdBuildBinary = parsers.add_parser( + "build-bin", help="Build a standalone binary package." + ) + cmdBuildBinary.set_defaults(func=utils.binary_dist.main) + # Build Clean cmdBuildClean = parsers.add_parser( "build-clean", help="Recursively delete all build folders." diff --git a/utils/binary_dist.py b/utils/binary_dist.py new file mode 100644 index 00000000..37dce983 --- /dev/null +++ b/utils/binary_dist.py @@ -0,0 +1,43 @@ +""" +novelWriter – Binary Dist Tools +=============================== + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 PyInstaller.__main__ + + +def runPyinstaller() -> None: + """Run the pyinstaller.""" + build = ["novelWriter.py", "--clean", "--windowed", "--onedir", "--noconfirm"] + build += ["--name", "novelwriter"] + build += ["--workpath", "build_bin"] + build += ["--distpath", "dist_bin"] + build += ["--hidden-import", "pyenchant"] + build += ["--add-data", "novelwriter/assets:assets"] + PyInstaller.__main__.run(build) + return + + +def main(args: argparse.Namespace) -> None: + """Entry point function.""" + runPyinstaller() + return diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 00000000..148db07e --- /dev/null +++ b/utils/common.py @@ -0,0 +1,25 @@ +""" +novelWriter – Common Utils +========================== + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 + +from pathlib import Path + +ROOT_DIR = Path(__file__).parent.parent From 767c22f3484ca31c526f97cdd43481d11cbca9df Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:03:00 +0100 Subject: [PATCH 03/13] Move and update Windows build for Qt6 --- pkgutils.py | 343 ++++---------------------------------- setup/win_setup_embed.iss | 2 + utils/common.py | 62 +++++++ utils/windows_build.py | 247 +++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 308 deletions(-) create mode 100644 utils/windows_build.py diff --git a/pkgutils.py b/pkgutils.py index 1110b287..3467a156 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -37,9 +37,10 @@ from pathlib import Path import utils.binary_dist import utils.icon_themes +import utils.windows_build + +from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, readFile, writeFile -CURR_DIR = Path(__file__).parent -SETUP_DIR = CURR_DIR / "setup" SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" OS_LINUX = sys.platform.startswith("linux") @@ -51,36 +52,6 @@ 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: @@ -93,16 +64,6 @@ def stripVersion(version: str) -> str: return version -def readFile(file: Path) -> str: - """Read an entire file and return as a string.""" - return file.read_text(encoding="utf-8") - - -def writeFile(file: Path, text: str) -> int: - """Write string to file.""" - return file.write_text(text, encoding="utf-8") - - 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. @@ -138,17 +99,17 @@ def checkAssetsExist() -> bool: hasManual = False hasQmData = False - sampleZip = CURR_DIR / "novelwriter" / "assets" / "sample.zip" + sampleZip = ROOT_DIR / "novelwriter" / "assets" / "sample.zip" if sampleZip.is_file(): print(f"Found: {sampleZip}") hasSample = True - pdfManual = CURR_DIR / "novelwriter" / "assets" / "manual.pdf" + pdfManual = ROOT_DIR / "novelwriter" / "assets" / "manual.pdf" if pdfManual.is_file(): print(f"Found: {pdfManual}") hasManual = True - i18nAssets = CURR_DIR / "novelwriter" / "assets" / "i18n" + i18nAssets = ROOT_DIR / "novelwriter" / "assets" / "i18n" if len(list(i18nAssets.glob("*.qm"))) > 0: print(f"Found: {i18nAssets}/*.qm") hasQmData = True @@ -214,12 +175,12 @@ def cleanBuildDirs(args: argparse.Namespace) -> None: 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", + ROOT_DIR / "build", + ROOT_DIR / "dist", + ROOT_DIR / "dist_deb", + ROOT_DIR / "dist_minimal", + ROOT_DIR / "dist_appimage", + ROOT_DIR / "novelWriter.egg-info", ] for folder in folders: @@ -252,8 +213,8 @@ def buildPdfManual(args: argparse.Namespace | None = None) -> None: print("===================") print("") - buildFile = CURR_DIR / "docs" / "build" / "latex" / "manual.pdf" - finalFile = CURR_DIR / "novelwriter" / "assets" / "manual.pdf" + buildFile = ROOT_DIR / "docs" / "build" / "latex" / "manual.pdf" + finalFile = ROOT_DIR / "novelwriter" / "assets" / "manual.pdf" finalFile.unlink(missing_ok=True) try: @@ -303,8 +264,8 @@ def buildSampleZip(args: argparse.Namespace | None = None) -> None: print("========================") print("") - srcSample = CURR_DIR / "sample" - dstSample = CURR_DIR / "novelwriter" / "assets" / "sample.zip" + srcSample = ROOT_DIR / "sample" + dstSample = ROOT_DIR / "novelwriter" / "assets" / "sample.zip" if srcSample.is_dir(): dstSample.unlink(missing_ok=True) @@ -342,8 +303,8 @@ def importI18nUpdates(args: argparse.Namespace) -> None: print("File not found ...") sys.exit(1) - dstPath = CURR_DIR / "novelwriter" / "assets" / "i18n" - srcPath = CURR_DIR / "i18n" + dstPath = ROOT_DIR / "novelwriter" / "assets" / "i18n" + srcPath = ROOT_DIR / "i18n" print(f"Loading file: {fileName}") with zipfile.ZipFile(fileName) as zipObj: @@ -384,10 +345,10 @@ def updateTranslationSources(args: argparse.Namespace) -> None: print("Scanning Source Tree:") print("") - sources = list((CURR_DIR / "novelwriter").glob("**/*.py")) - sources.insert(0, CURR_DIR / "i18n" / "qtbase.py") + sources = list((ROOT_DIR / "novelwriter").glob("**/*.py")) + sources.insert(0, ROOT_DIR / "i18n" / "qtbase.py") for source in sources: - print(source.relative_to(CURR_DIR)) + print(source.relative_to(ROOT_DIR)) print("") print("TS Files to Update:") @@ -444,8 +405,8 @@ def buildTranslationAssets(args: argparse.Namespace | None = None) -> None: print("TS Files to Build:") print("") - srcDir = CURR_DIR / "i18n" - dstDir = CURR_DIR / "novelwriter" / "assets" / "i18n" + srcDir = ROOT_DIR / "i18n" + dstDir = ROOT_DIR / "novelwriter" / "assets" / "i18n" srcList = [] for item in srcDir.iterdir(): @@ -469,11 +430,11 @@ def buildTranslationAssets(args: argparse.Namespace | None = None) -> None: print("Moving QM Files to Assets") print("") - dstRel = dstDir.relative_to(CURR_DIR) + dstRel = dstDir.relative_to(ROOT_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("Moved: %s -> %s" % (item.relative_to(ROOT_DIR), dstRel / item.name)) print("") @@ -492,14 +453,14 @@ def cleanBuiltAssets(args: argparse.Namespace | None = None) -> None: print("") assets = [ - CURR_DIR / "novelwriter" / "assets" / "sample.zip", - CURR_DIR / "novelwriter" / "assets" / "manual.pdf", + ROOT_DIR / "novelwriter" / "assets" / "sample.zip", + ROOT_DIR / "novelwriter" / "assets" / "manual.pdf", ] - assets.extend((CURR_DIR / "novelwriter" / "assets" / "i18n").glob("*.qm")) + assets.extend((ROOT_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(f"Deleted: {asset.relative_to(ROOT_DIR)}") print("") @@ -523,29 +484,6 @@ def buildAllAssets(args: argparse.Namespace) -> None: # 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 ## @@ -572,7 +510,7 @@ def copyPackageFiles(dst: Path, setupPy: bool = False) -> None: )) print("Wrote: setup.py") - text = readFile(CURR_DIR / "pyproject.toml") + text = readFile(ROOT_DIR / "pyproject.toml") text = text.replace("setup/description_pypi.md", "data/description_short.txt") writeFile(dst / "pyproject.toml", text) print("Wrote: pyproject.toml") @@ -613,7 +551,7 @@ def makeDebianPackage( # Set Up Folder # ============= - bldDir = CURR_DIR / "dist_deb" + bldDir = ROOT_DIR / "dist_deb" bldPkg = f"novelwriter_{pkgVers}" outDir = bldDir / bldPkg debDir = outDir / "debian" @@ -814,7 +752,7 @@ def buildAppImage(args: argparse.Namespace) -> None: # Set Up Folder # ============= - bldDir = CURR_DIR / "dist_appimage" + bldDir = ROOT_DIR / "dist_appimage" bldPkg = f"novelwriter_{pkgVers}" outDir = bldDir / bldPkg imgDir = bldDir / "appimage" @@ -915,217 +853,6 @@ def buildAppImage(args: argparse.Namespace) -> None: 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, _, _ = extractVersion() - print("Version: %s" % numVers) - - # Set Up Folder - # ============= - - bldDir = CURR_DIR / "dist" - outDir = bldDir / "novelWriter" - libDir = outDir / "lib" - if outDir.exists(): - shutil.rmtree(outDir) - - bldDir.mkdir(exist_ok=True) - 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 / item.name) - 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 = f"python-{pyVers}-embed-amd64.zip" - pyZip = bldDir / zipFile - if not pyZip.is_file(): - 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 ...") - - writeFile(outDir / "novelWriter.pyw", ( - "#!/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 = readFile(SETUP_DIR / "win_setup_embed.iss") - issData = issData.replace(r"%%version%%", numVers) - issData = issData.replace(r"%%dist%%", str(bldDir)) - writeFile(CURR_DIR / "setup.iss", 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 - - ## # Generate MacOS PList ## @@ -1181,7 +908,7 @@ def xdgInstall(args: argparse.Namespace) -> None: if testExec is not None: exOpts.append(testExec) - testExec = CURR_DIR / "novelWriter.py" + testExec = ROOT_DIR / "novelWriter.py" if testExec.is_file(): exOpts.append(str(testExec)) @@ -1214,7 +941,7 @@ def xdgInstall(args: argparse.Namespace) -> None: # =========================== # Generate launcher - desktopFile = CURR_DIR / "novelwriter.desktop" + desktopFile = ROOT_DIR / "novelwriter.desktop" desktopData = readFile(SETUP_DIR / "data" / "novelwriter.desktop") desktopData = desktopData.replace("Exec=novelwriter", f"Exec={useExec}") writeFile(desktopFile, desktopData) @@ -1507,7 +1234,7 @@ if __name__ == "__main__": cmdBuildSetupExe = parsers.add_parser( "build-win-exe", help="Build a setup.exe file with Python embedded for Windows." ) - cmdBuildSetupExe.set_defaults(func=makeWindowsEmbedded) + cmdBuildSetupExe.set_defaults(func=utils.windows_build.main) # Build Binary cmdBuildBinary = parsers.add_parser( diff --git a/setup/win_setup_embed.iss b/setup/win_setup_embed.iss index 042914fc..f4c55ac9 100644 --- a/setup/win_setup_embed.iss +++ b/setup/win_setup_embed.iss @@ -39,9 +39,11 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; Check: not IsAdminInstallMode [InstallDelete] +Type: filesandordirs; Name: "{app}\lib\*" Type: filesandordirs; Name: "{app}\novelwriter\*" [UninstallDelete] +Type: filesandordirs; Name: "{app}\lib\*" Type: filesandordirs; Name: "{app}\novelwriter\*" [Files] diff --git a/utils/common.py b/utils/common.py index 148db07e..2b01781f 100644 --- a/utils/common.py +++ b/utils/common.py @@ -20,6 +20,68 @@ along with this program. If not, see . """ from __future__ import annotations +import shutil + from pathlib import Path ROOT_DIR = Path(__file__).parent.parent +SETUP_DIR = ROOT_DIR / "setup" + + +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 copySourceCode(dst: Path) -> None: + """Copy the novelwriter source tree to path.""" + src = ROOT_DIR / "novelwriter" + for item in src.glob("**/*"): + relSrc = item.relative_to(ROOT_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 + + +def readFile(file: Path) -> str: + """Read an entire file and return as a string.""" + return file.read_text(encoding="utf-8") + + +def writeFile(file: Path, text: str) -> int: + """Write string to file.""" + return file.write_text(text, encoding="utf-8") diff --git a/utils/windows_build.py b/utils/windows_build.py new file mode 100644 index 00000000..0b568c50 --- /dev/null +++ b/utils/windows_build.py @@ -0,0 +1,247 @@ +""" +novelWriter – Windows Build +=========================== + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 compileall +import shutil +import subprocess +import sys +import urllib.request +import zipfile + +from pathlib import Path + +from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, writeFile + +from tests.tools import readFile + + +def prepareCode(outDir: Path) -> None: + """Set up folders and copy code.""" + print("Copying and compiling novelWriter source ...") + print("") + + copySourceCode(outDir) + + files = [ + ROOT_DIR / "CREDITS.md", + ROOT_DIR / "LICENSE.md", + ROOT_DIR / "requirements.txt", + SETUP_DIR / "icons" / "novelwriter.ico", + SETUP_DIR / "iss_license.txt", + + ] + for item in files: + shutil.copyfile(item, outDir / item.name) + print(f"Copied: {item} > {outDir / item.name}") + + compileall.compile_dir(outDir / "novelwriter") + + print("Done") + print("") + + return + + +def embedPython(bldDir: Path, outDir: Path) -> None: + """Embed Python library.""" + print("Adding Python embeddable ...") + + pyVers = "%d.%d.%d" % (sys.version_info[:3]) + zipFile = f"python-{pyVers}-embed-amd64.zip" + pyZip = bldDir / zipFile + if not pyZip.is_file(): + 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("") + + return + + +def installRequirements(libDir: Path) -> None: + """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("") + + return + + +def removeRedundantQt(libDir: Path) -> None: + """Delete Qt files that are not needed""" + + def unlinkIfFound(file: Path) -> None: + if file.is_file(): + file.unlink() + print(f"Deleted: {file}") + + def unlinkIfPrefix(folder: Path, prefix: tuple[str, ...]) -> None: + if folder.is_dir(): + for item in folder.iterdir(): + if item.name.startswith(prefix): + unlinkIfFound(item) + + def deleteFolder(folder: Path) -> None: + if folder.is_dir(): + shutil.rmtree(folder) + print(f"Deleted: {folder}") + + print("Deleting Redundant Files") + print("========================") + print("") + + pyQt6Dir = libDir / "PyQt6" + bindDir = libDir / "PyQt6" / "bindings" + qt6Dir = libDir / "PyQt6" / "Qt6" + binDir = libDir / "PyQt6" / "Qt6" / "bin" + plugDir = libDir / "PyQt6" / "Qt6" / "plugins" + qmDir = libDir / "PyQt6" / "Qt6" / "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) + + bulkDel = ("QtQml", "Qt6Qml", "QtQuick", "Qt6Quick") + unlinkIfPrefix(pyQt6Dir, bulkDel) + unlinkIfPrefix(binDir, bulkDel) + + delQt6 = [ + "Qt6Bluetooth", "Qt6DBus", "Qt6Designer", "Qt6Help", "Qt6Multimedia", + "Qt6MultimediaWidgets", "Qt6Network", "Qt6Nfc", "Qt6OpenGL", "Qt6Positioning", + "Qt6PositioningQuick", "Qt6Sensors", "Qt6SerialPort", "Qt6Sql", "Qt6Test", + "Qt6TextToSpeech", "Qt6WebChannel", "Qt6WebSockets", "Qt6Xml", + ] + for item in delQt6: + qtItem = item.replace("Qt6", "Qt") + unlinkIfFound(binDir / f"{item}.dll") + unlinkIfFound(pyQt6Dir / f"{qtItem}.pyd") + unlinkIfFound(pyQt6Dir / f"{qtItem}.pyi") + deleteFolder(bindDir / qtItem) + + delList = [ + binDir / "opengl32sw.dll", + qt6Dir / "qml", + plugDir / "renderers", + plugDir / "sensors", + plugDir / "sqldrivers", + plugDir / "texttospeech", + plugDir / "webview", + ] + for item in delList: + unlinkIfFound(item) + deleteFolder(item) + + print("Done") + print("") + + return + + +def main(args: argparse.Namespace) -> None: + """Set up a package with embedded Python and dependencies for + Windows installation. + """ + print("") + print("Build Standalone Windows Package") + print("================================") + print("") + + numVers, _, _ = extractVersion() + print("Version: %s" % numVers) + + bldDir = ROOT_DIR / "dist" + outDir = bldDir / "novelWriter" + libDir = outDir / "lib" + if outDir.exists(): + shutil.rmtree(outDir) + + bldDir.mkdir(exist_ok=True) + outDir.mkdir() + libDir.mkdir() + + copySourceCode(outDir) + embedPython(bldDir, outDir) + installRequirements(libDir) + removeRedundantQt(libDir) + + print("Updating starting script ...") + writeFile(outDir / "novelWriter.pyw", ( + "#!/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("") + + print("Running Inno Setup") + print("##################") + print("") + + # Read the iss template + issData = readFile(SETUP_DIR / "win_setup_embed.iss") + issData = issData.replace(r"%%version%%", numVers) + issData = issData.replace(r"%%dist%%", str(bldDir)) + writeFile(ROOT_DIR / "setup.iss", 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 From ca70da2c2895054b0f67b2e07201658b663ab23b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:13:28 +0100 Subject: [PATCH 04/13] Move common functions --- pkgutils.py | 81 +++++-------------------------------------------- utils/common.py | 68 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 75 deletions(-) diff --git a/pkgutils.py b/pkgutils.py index 3467a156..80b5f505 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -6,6 +6,7 @@ novelWriter – Packaging Utils File History: Created: 2019-05-16 [0.5.1] Renamed: 2023-07-26 [2.1b1] +Split: 2025-01-16 [2.7b1] This file is a part of novelWriter Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors @@ -39,7 +40,10 @@ import utils.binary_dist import utils.icon_themes import utils.windows_build -from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, readFile, writeFile +from utils.common import ( + ROOT_DIR, SETUP_DIR, checkAssetsExist, copySourceCode, extractVersion, + makeCheckSum, readFile, stripVersion, toUpload, writeFile +) SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" @@ -48,79 +52,6 @@ OS_DARWIN = sys.platform.startswith("darwin") OS_WIN = sys.platform.startswith("win32") -# =============================================================================================== # -# Utilities -# =============================================================================================== # - -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 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 | None = None) -> str: - """Create a SHA256 checksum file.""" - try: - if cwd is None: - shaFile = f"{sumFile}.sha256" - else: - shaFile = cwd / f"{sumFile}.sha256" - with open(shaFile, mode="w") as fOut: - subprocess.call(["shasum", "-a", "256", sumFile], stdout=fOut, cwd=cwd) - print(f"SHA256 Sum: {shaFile}") - except Exception as exc: - print("Could not generate sha256 file") - print(str(exc)) - return "" - - return str(shaFile) - - -def checkAssetsExist() -> bool: - """Check that the necessary assets exist ahead of a build.""" - hasSample = False - hasManual = False - hasQmData = False - - sampleZip = ROOT_DIR / "novelwriter" / "assets" / "sample.zip" - if sampleZip.is_file(): - print(f"Found: {sampleZip}") - hasSample = True - - pdfManual = ROOT_DIR / "novelwriter" / "assets" / "manual.pdf" - if pdfManual.is_file(): - print(f"Found: {pdfManual}") - hasManual = True - - i18nAssets = ROOT_DIR / "novelwriter" / "assets" / "i18n" - if len(list(i18nAssets.glob("*.qm"))) > 0: - print(f"Found: {i18nAssets}/*.qm") - hasQmData = True - - return hasSample and hasManual and hasQmData - - -# =============================================================================================== # -# General -# =============================================================================================== # - ## # Print Version ## @@ -176,7 +107,9 @@ def cleanBuildDirs(args: argparse.Namespace) -> None: folders = [ ROOT_DIR / "build", + ROOT_DIR / "build_bin", ROOT_DIR / "dist", + ROOT_DIR / "dist_bin", ROOT_DIR / "dist_deb", ROOT_DIR / "dist_minimal", ROOT_DIR / "dist_appimage", diff --git a/utils/common.py b/utils/common.py index 2b01781f..1cc4967b 100644 --- a/utils/common.py +++ b/utils/common.py @@ -21,6 +21,7 @@ along with this program. If not, see . from __future__ import annotations import shutil +import subprocess from pathlib import Path @@ -39,7 +40,7 @@ def extractVersion(beQuiet: bool = False) -> tuple[str, str, str]: numVers = "0" hexVers = "0x0" relDate = "Unknown" - initFile = Path("novelwriter") / "__init__.py" + initFile = ROOT_DIR / "novelwriter" / "__init__.py" try: for aLine in initFile.read_text(encoding="utf-8").splitlines(): if aLine.startswith("__version__"): @@ -58,6 +59,18 @@ def extractVersion(beQuiet: bool = False) -> tuple[str, str, str]: 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 copySourceCode(dst: Path) -> None: """Copy the novelwriter source tree to path.""" src = ROOT_DIR / "novelwriter" @@ -77,6 +90,59 @@ def copySourceCode(dst: Path) -> None: 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 | None = None) -> str: + """Create a SHA256 checksum file.""" + try: + if cwd is None: + shaFile = f"{sumFile}.sha256" + else: + shaFile = cwd / f"{sumFile}.sha256" + with open(shaFile, mode="w") as fOut: + subprocess.call(["shasum", "-a", "256", sumFile], stdout=fOut, cwd=cwd) + print(f"SHA256 Sum: {shaFile}") + except Exception as exc: + print("Could not generate sha256 file") + print(str(exc)) + return "" + + return str(shaFile) + + +def checkAssetsExist() -> bool: + """Check that the necessary assets exist ahead of a build.""" + hasSample = False + hasManual = False + hasQmData = False + + sampleZip = ROOT_DIR / "novelwriter" / "assets" / "sample.zip" + if sampleZip.is_file(): + print(f"Found: {sampleZip}") + hasSample = True + + pdfManual = ROOT_DIR / "novelwriter" / "assets" / "manual.pdf" + if pdfManual.is_file(): + print(f"Found: {pdfManual}") + hasManual = True + + i18nAssets = ROOT_DIR / "novelwriter" / "assets" / "i18n" + if len(list(i18nAssets.glob("*.qm"))) > 0: + print(f"Found: {i18nAssets}/*.qm") + hasQmData = True + + return hasSample and hasManual and hasQmData + + def readFile(file: Path) -> str: """Read an entire file and return as a string.""" return file.read_text(encoding="utf-8") From 9332bd882c2ba4fa9b832f3d47f211c3a19a424d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:20:10 +0100 Subject: [PATCH 05/13] Move debian builder functions --- pkgutils.py | 209 +------------------------------------- utils/debian_build.py | 231 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 204 deletions(-) create mode 100644 utils/debian_build.py diff --git a/pkgutils.py b/pkgutils.py index 80b5f505..5453ed1c 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -28,7 +28,6 @@ from __future__ import annotations import argparse import datetime -import email.utils import shutil import subprocess import sys @@ -37,16 +36,15 @@ import zipfile from pathlib import Path import utils.binary_dist +import utils.debian_build import utils.icon_themes import utils.windows_build from utils.common import ( - ROOT_DIR, SETUP_DIR, checkAssetsExist, copySourceCode, extractVersion, - makeCheckSum, readFile, stripVersion, toUpload, writeFile + ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, makeCheckSum, + readFile, stripVersion, toUpload, writeFile ) -SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" - OS_LINUX = sys.platform.startswith("linux") OS_DARWIN = sys.platform.startswith("darwin") OS_WIN = sys.platform.startswith("win32") @@ -451,203 +449,6 @@ def copyPackageFiles(dst: Path, setupPy: bool = False) -> None: return -## -# Make Debian Package -## - -def makeDebianPackage( - signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable", - buildName: str = "", 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 = ROOT_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, setupPy=True) - - # Copy/Write Debian Files - # ======================= - - shutil.copytree(SETUP_DIR / "debian", debDir) - print("Copied: debian/*") - - writeFile(debDir / "changelog", ( - 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.build: - bldNum = str(args.build) - else: - bldNum = "0" - - distLoop = [ - ("24.04", "noble"), - ("24.10", "oracular"), - ("25.04", "plucky"), - ] - - 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 in distLoop: - buildName = f"ubuntu{distNum}.{bldNum}" - dCmd = makeDebianPackage( - signKey=signKey, - sourceBuild=True, - distName=codeName, - buildName=buildName, - forLaunchpad=True, - ) - dputCmd.append(dCmd) - - print("Packages Built") - print("==============") - print("") - for dCmd in dputCmd: - print(f" > {dCmd}") - print("") - - return - - ## # Build AppImage (build-appimage) ## @@ -1128,7 +929,7 @@ if __name__ == "__main__": ) ) cmdBuildDeb.add_argument("--sign", action="store_true", help="Sign the package.") - cmdBuildDeb.set_defaults(func=buildDebianPackage) + cmdBuildDeb.set_defaults(func=utils.debian_build.mainDebian) # Build Ubuntu Packages cmdBuildUbuntu = parsers.add_parser( @@ -1140,7 +941,7 @@ if __name__ == "__main__": ) cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the package.") cmdBuildUbuntu.add_argument("--build", type=int, help="Set build number.") - cmdBuildUbuntu.set_defaults(func=buildForLaunchpad) + cmdBuildUbuntu.set_defaults(func=utils.debian_build.mainLaunchpad) # Build AppImage cmdBuildAppImage = parsers.add_parser( diff --git a/utils/debian_build.py b/utils/debian_build.py new file mode 100644 index 00000000..4cb3112f --- /dev/null +++ b/utils/debian_build.py @@ -0,0 +1,231 @@ +""" +novelWriter – Debian Build +========================== + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 shutil +import subprocess +import sys + +from pkgutils import copyPackageFiles +from utils.common import ( + ROOT_DIR, SETUP_DIR, checkAssetsExist, copySourceCode, extractVersion, + makeCheckSum, toUpload +) + +from tests.tools import writeFile + +SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" + + +def makeDebianPackage( + signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable", + buildName: str = "", 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 = ROOT_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, setupPy=True) + + # Copy/Write Debian Files + # ======================= + + shutil.copytree(SETUP_DIR / "debian", debDir) + print("Copied: debian/*") + + writeFile(debDir / "changelog", ( + 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 mainDebian(args: argparse.Namespace) -> None: + """Build a .deb package""" + if sys.platform == "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 mainLaunchpad(args: argparse.Namespace) -> None: + """Wrapper for building Debian packages for Launchpad.""" + if sys.platform == "linux": + print("ERROR: Command 'build-ubuntu' can only be used on Linux") + sys.exit(1) + + print("") + print("Launchpad Packages") + print("==================") + print("") + + if args.build: + bldNum = str(args.build) + else: + bldNum = "0" + + distLoop = [ + ("24.04", "noble"), + ("24.10", "oracular"), + ("25.04", "plucky"), + ] + + 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 in distLoop: + buildName = f"ubuntu{distNum}.{bldNum}" + dCmd = makeDebianPackage( + signKey=signKey, + sourceBuild=True, + distName=codeName, + buildName=buildName, + forLaunchpad=True, + ) + dputCmd.append(dCmd) + + print("Packages Built") + print("==============") + print("") + for dCmd in dputCmd: + print(f" > {dCmd}") + print("") + + return From 4bc17d42e3e201cc48d44419b74cd53766bdeaaf Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:20:38 +0100 Subject: [PATCH 06/13] Drop XDG functions --- pkgutils.py | 231 ---------------------------------------------------- 1 file changed, 231 deletions(-) diff --git a/pkgutils.py b/pkgutils.py index 5453ed1c..4bb9d47c 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -614,216 +614,6 @@ def genMacOSPlist(args: argparse.Namespace) -> None: return -# =============================================================================================== # -# General Installers -# =============================================================================================== # - -## -# XDG Installation (xdg-install) -## - -def xdgInstall(args: argparse.Namespace) -> 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 = ROOT_DIR / "novelWriter.py" - if testExec.is_file(): - exOpts.append(str(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 - desktopFile = ROOT_DIR / "novelwriter.desktop" - desktopData = readFile(SETUP_DIR / "data" / "novelwriter.desktop") - desktopData = desktopData.replace("Exec=novelwriter", f"Exec={useExec}") - writeFile(desktopFile, 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 - desktopFile.unlink(missing_ok=True) - - print("") - print("Done!") - print("") - - return - - -## -# XDG Uninstallation (xdg-uninstall) -## - -def xdgUninstall(args: argparse.Namespace) -> 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 # =============================================================================================== # @@ -988,26 +778,5 @@ if __name__ == "__main__": ) cmdBuildMacOSPlist.set_defaults(func=genMacOSPlist) - # General Installers - # ================== - - # Linux XDG Install - cmdXDGInstall = parsers.add_parser( - "xdg-install", help=( - "Install launcher and icons for freedesktop systems. Run as root or with sudo for " - "system-wide install, or as user for single user install." - ) - ) - cmdXDGInstall.set_defaults(func=xdgInstall) - - # Linux XDG Uninstall - cmdXDGUninstall = parsers.add_parser( - "xdg-uninstall", help=( - "Remove the launcher and icons for the current system " - "as installed by the 'xdg-install' command." - ) - ) - cmdXDGUninstall.set_defaults(func=xdgUninstall) - args = parser.parse_args() args.func(args) From c8410e85279edcc4456b1ec4b50b97d163ad4cc5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:22:07 +0100 Subject: [PATCH 07/13] Rename build utils --- pkgutils.py | 14 +++++++------- utils/{binary_dist.py => build_binary.py} | 0 utils/{debian_build.py => build_debian.py} | 0 utils/{windows_build.py => build_windows.py} | 0 4 files changed, 7 insertions(+), 7 deletions(-) rename utils/{binary_dist.py => build_binary.py} (100%) rename utils/{debian_build.py => build_debian.py} (100%) rename utils/{windows_build.py => build_windows.py} (100%) diff --git a/pkgutils.py b/pkgutils.py index 4bb9d47c..2820ebc7 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -35,10 +35,10 @@ import zipfile from pathlib import Path -import utils.binary_dist -import utils.debian_build +import utils.build_binary +import utils.build_debian +import utils.build_windows import utils.icon_themes -import utils.windows_build from utils.common import ( ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, makeCheckSum, @@ -719,7 +719,7 @@ if __name__ == "__main__": ) ) cmdBuildDeb.add_argument("--sign", action="store_true", help="Sign the package.") - cmdBuildDeb.set_defaults(func=utils.debian_build.mainDebian) + cmdBuildDeb.set_defaults(func=utils.build_debian.mainDebian) # Build Ubuntu Packages cmdBuildUbuntu = parsers.add_parser( @@ -731,7 +731,7 @@ if __name__ == "__main__": ) cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the package.") cmdBuildUbuntu.add_argument("--build", type=int, help="Set build number.") - cmdBuildUbuntu.set_defaults(func=utils.debian_build.mainLaunchpad) + cmdBuildUbuntu.set_defaults(func=utils.build_debian.mainLaunchpad) # Build AppImage cmdBuildAppImage = parsers.add_parser( @@ -758,13 +758,13 @@ if __name__ == "__main__": cmdBuildSetupExe = parsers.add_parser( "build-win-exe", help="Build a setup.exe file with Python embedded for Windows." ) - cmdBuildSetupExe.set_defaults(func=utils.windows_build.main) + cmdBuildSetupExe.set_defaults(func=utils.build_windows.main) # Build Binary cmdBuildBinary = parsers.add_parser( "build-bin", help="Build a standalone binary package." ) - cmdBuildBinary.set_defaults(func=utils.binary_dist.main) + cmdBuildBinary.set_defaults(func=utils.build_binary.main) # Build Clean cmdBuildClean = parsers.add_parser( diff --git a/utils/binary_dist.py b/utils/build_binary.py similarity index 100% rename from utils/binary_dist.py rename to utils/build_binary.py diff --git a/utils/debian_build.py b/utils/build_debian.py similarity index 100% rename from utils/debian_build.py rename to utils/build_debian.py diff --git a/utils/windows_build.py b/utils/build_windows.py similarity index 100% rename from utils/windows_build.py rename to utils/build_windows.py From 3ef790fbe335913ce3e80d8b27c76ee864370098 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:27:39 +0100 Subject: [PATCH 08/13] Move AppImage builder functions --- pkgutils.py | 192 +--------------------------------------- utils/build_appimage.py | 166 ++++++++++++++++++++++++++++++++++ utils/build_debian.py | 5 +- utils/common.py | 29 ++++++ 4 files changed, 200 insertions(+), 192 deletions(-) create mode 100644 utils/build_appimage.py diff --git a/pkgutils.py b/pkgutils.py index 2820ebc7..f84db12c 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -35,35 +35,25 @@ import zipfile from pathlib import Path +import utils.build_appimage import utils.build_binary import utils.build_debian import utils.build_windows import utils.icon_themes -from utils.common import ( - ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, makeCheckSum, - readFile, stripVersion, toUpload, writeFile -) +from utils.common import ROOT_DIR, SETUP_DIR, extractVersion, readFile, stripVersion, writeFile OS_LINUX = sys.platform.startswith("linux") OS_DARWIN = sys.platform.startswith("darwin") OS_WIN = sys.platform.startswith("win32") -## -# 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. @@ -93,10 +83,6 @@ def installPackages(args: argparse.Namespace) -> None: return -## -# Clean Build and Dist Folders (build-clean) -## - def cleanBuildDirs(args: argparse.Namespace) -> None: """Recursively delete the 'build' and 'dist' folders.""" print("") @@ -415,178 +401,6 @@ def buildAllAssets(args: argparse.Namespace) -> None: # Python Packaging # =============================================================================================== # -## -# Copy Package Files -## - -def copyPackageFiles(dst: Path, setupPy: 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) - - writeFile(dst / "MANIFEST.in", ( - "include LICENSE.md\n" - "include CREDITS.md\n" - "recursive-include novelwriter/assets *\n" - )) - print("Wrote: MANIFEST.in") - - if setupPy: - writeFile(dst / "setup.py", ( - "import setuptools\n" - "setuptools.setup()\n" - )) - print("Wrote: setup.py") - - text = readFile(ROOT_DIR / "pyproject.toml") - text = text.replace("setup/description_pypi.md", "data/description_short.txt") - writeFile(dst / "pyproject.toml", text) - print("Wrote: pyproject.toml") - - 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 = ROOT_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 = readFile(SETUP_DIR / "description_short.txt") - appdataXML = readFile(SETUP_DIR / "novelwriter.appdata.xml") - appdataXML = appdataXML.format(description=appDescription) - writeFile(imgDir / "novelwriter.appdata.xml", appdataXML) - print("Wrote: novelwriter.appdata.xml") - - writeFile(imgDir / "entrypoint.sh", ( - '#! /bin/bash \n' - '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"' - )) - print("Wrote: entrypoint.sh") - - writeFile(imgDir / "requirements.txt", 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 - - ## # Generate MacOS PList ## @@ -752,7 +566,7 @@ if __name__ == "__main__": cmdBuildAppImage.add_argument( "--python-version", default="3.11", help="Python version (e.g. 3.11)" ) - cmdBuildAppImage.set_defaults(func=buildAppImage) + cmdBuildAppImage.set_defaults(func=utils.build_appimage.main) # Build Windows Inno Setup Installer cmdBuildSetupExe = parsers.add_parser( diff --git a/utils/build_appimage.py b/utils/build_appimage.py new file mode 100644 index 00000000..5ad03854 --- /dev/null +++ b/utils/build_appimage.py @@ -0,0 +1,166 @@ +""" +novelWriter – AppImage Build +============================ + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 shutil +import subprocess +import sys + +from utils.common import ( + ROOT_DIR, SETUP_DIR, copyPackageFiles, copySourceCode, extractVersion, + makeCheckSum, readFile, toUpload, writeFile +) + + +def main(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 sys.platform == "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 = ROOT_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 = readFile(SETUP_DIR / "description_short.txt") + appdataXML = readFile(SETUP_DIR / "novelwriter.appdata.xml") + appdataXML = appdataXML.format(description=appDescription) + writeFile(imgDir / "novelwriter.appdata.xml", appdataXML) + print("Wrote: novelwriter.appdata.xml") + + writeFile(imgDir / "entrypoint.sh", ( + '#! /bin/bash \n' + '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"' + )) + print("Wrote: entrypoint.sh") + + writeFile(imgDir / "requirements.txt", 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 diff --git a/utils/build_debian.py b/utils/build_debian.py index 4cb3112f..f11dea72 100644 --- a/utils/build_debian.py +++ b/utils/build_debian.py @@ -27,10 +27,9 @@ import shutil import subprocess import sys -from pkgutils import copyPackageFiles from utils.common import ( - ROOT_DIR, SETUP_DIR, checkAssetsExist, copySourceCode, extractVersion, - makeCheckSum, toUpload + ROOT_DIR, SETUP_DIR, checkAssetsExist, copyPackageFiles, copySourceCode, + extractVersion, makeCheckSum, toUpload ) from tests.tools import writeFile diff --git a/utils/common.py b/utils/common.py index 1cc4967b..ae7a3b68 100644 --- a/utils/common.py +++ b/utils/common.py @@ -90,6 +90,35 @@ def copySourceCode(dst: Path) -> None: return +def copyPackageFiles(dst: Path, setupPy: 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) + + writeFile(dst / "MANIFEST.in", ( + "include LICENSE.md\n" + "include CREDITS.md\n" + "recursive-include novelwriter/assets *\n" + )) + print("Wrote: MANIFEST.in") + + if setupPy: + writeFile(dst / "setup.py", ( + "import setuptools\n" + "setuptools.setup()\n" + )) + print("Wrote: setup.py") + + text = readFile(ROOT_DIR / "pyproject.toml") + text = text.replace("setup/description_pypi.md", "data/description_short.txt") + writeFile(dst / "pyproject.toml", text) + print("Wrote: pyproject.toml") + + 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. From 0306701e6fc15871eeb5e96a283f7e75fa4a9d02 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:32:56 +0100 Subject: [PATCH 09/13] Move asset builder functions --- pkgutils.py | 312 ++---------------------------------------------- utils/assets.py | 279 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 304 deletions(-) create mode 100644 utils/assets.py diff --git a/pkgutils.py b/pkgutils.py index f84db12c..16bc3fa6 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -31,10 +31,8 @@ import datetime import shutil import subprocess import sys -import zipfile - -from pathlib import Path +import utils.assets import utils.build_appimage import utils.build_binary import utils.build_debian @@ -115,296 +113,6 @@ def cleanBuildDirs(args: argparse.Namespace) -> None: 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 = ROOT_DIR / "docs" / "build" / "latex" / "manual.pdf" - finalFile = ROOT_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 finalFile.is_file(): - 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 = ROOT_DIR / "sample" - dstSample = ROOT_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 = ROOT_DIR / "novelwriter" / "assets" / "i18n" - srcPath = ROOT_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((ROOT_DIR / "novelwriter").glob("**/*.py")) - sources.insert(0, ROOT_DIR / "i18n" / "qtbase.py") - for source in sources: - print(source.relative_to(ROOT_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] - writeFile(item, ( - "\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 = ROOT_DIR / "i18n" - dstDir = ROOT_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("Qt Linguist tools seem to be missing") - print("On Debian/Ubuntu, install: qttools5-dev-tools") - print(str(exc)) - sys.exit(1) - - print("") - print("Moving QM Files to Assets") - print("") - - dstRel = dstDir.relative_to(ROOT_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(ROOT_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 = [ - ROOT_DIR / "novelwriter" / "assets" / "sample.zip", - ROOT_DIR / "novelwriter" / "assets" / "manual.pdf", - ] - assets.extend((ROOT_DIR / "novelwriter" / "assets" / "i18n").glob("*.qm")) - for asset in assets: - if asset.is_file(): - asset.unlink() - print(f"Deleted: {asset.relative_to(ROOT_DIR)}") - - print("") - - return - - -## -# Build Assets (build-assets) -## - -def buildAllAssets(args: argparse.Namespace) -> None: - """Build all assets.""" - cleanBuiltAssets() - buildPdfManual() - buildSampleZip() - buildTranslationAssets() - return - - -# =============================================================================================== # -# Python Packaging -# =============================================================================================== # - -## -# Generate MacOS PList -## - def genMacOSPlist(args: argparse.Namespace) -> None: """Set necessary values for .plist file for MacOS build.""" outDir = SETUP_DIR / "macos" @@ -428,10 +136,6 @@ def genMacOSPlist(args: argparse.Namespace) -> None: return -# =============================================================================================== # -# Process Command Line -# =============================================================================================== # - if __name__ == "__main__": """Parse command line options and run the commands.""" parser = argparse.ArgumentParser( @@ -478,7 +182,7 @@ if __name__ == "__main__": "qtlimport", help="Import updated i18n files from a Crowdin zip file." ) cmdImportTS.add_argument("file", help="Path to zip file from Crowdin") - cmdImportTS.set_defaults(func=importI18nUpdates) + cmdImportTS.set_defaults(func=utils.assets.importI18nUpdates) # Update i18n Sources cmdUpdateTS = parsers.add_parser( @@ -490,37 +194,37 @@ if __name__ == "__main__": ) ) cmdUpdateTS.add_argument("files", nargs="+") - cmdUpdateTS.set_defaults(func=updateTranslationSources) + cmdUpdateTS.set_defaults(func=utils.assets.updateTranslationSources) # Build i18n Files cmdBuildQM = parsers.add_parser( "qtlrelease", help="Build the language files for internationalisation." ) - cmdBuildQM.set_defaults(func=buildTranslationAssets) + cmdBuildQM.set_defaults(func=utils.assets.buildTranslationAssets) # Build Manual cmdBuildManual = parsers.add_parser( "manual", help="Build the help documentation as a PDF (requires LaTeX)." ) - cmdBuildManual.set_defaults(func=buildPdfManual) + cmdBuildManual.set_defaults(func=utils.assets.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) + cmdBuildSample.set_defaults(func=utils.assets.buildSampleZip) # Clean Assets cmdCleanAssets = parsers.add_parser( "clean-assets", help="Delete assets built by manual, sample and qtlrelease." ) - cmdCleanAssets.set_defaults(func=cleanBuiltAssets) + cmdCleanAssets.set_defaults(func=utils.assets.cleanBuiltAssets) # Build Assets cmdBuildAssets = parsers.add_parser( "build-assets", help="Build all assets. Includes manual, sample and qtlrelease." ) - cmdBuildAssets.set_defaults(func=buildAllAssets) + cmdBuildAssets.set_defaults(func=utils.assets.buildAllAssets) # Python Packaging # ================ diff --git a/utils/assets.py b/utils/assets.py new file mode 100644 index 00000000..b9512dfc --- /dev/null +++ b/utils/assets.py @@ -0,0 +1,279 @@ +""" +novelWriter – Assets +==================== + +This file is a part of novelWriter +Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors + +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 subprocess +import sys +import zipfile + +from pathlib import Path + +from utils.common import ROOT_DIR, writeFile + + +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 = ROOT_DIR / "docs" / "build" / "latex" / "manual.pdf" + finalFile = ROOT_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 finalFile.is_file(): + print("No output file was found!") + print("") + sys.exit(1) + + return + + +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 = ROOT_DIR / "sample" + dstSample = ROOT_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 + + +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 = ROOT_DIR / "novelwriter" / "assets" / "i18n" + srcPath = ROOT_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 + + +def updateTranslationSources(args: argparse.Namespace) -> None: + """Build the lang.ts files for Qt Linguist.""" + print("") + print("Building Qt Translation Files") + print("=============================") + + try: + 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((ROOT_DIR / "novelwriter").glob("**/*.py")) + sources.insert(0, ROOT_DIR / "i18n" / "qtbase.py") + for source in sources: + print(source.relative_to(ROOT_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] + writeFile(item, ( + "\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 + + +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 = ROOT_DIR / "i18n" + dstDir = ROOT_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("Qt Linguist tools seem to be missing") + print("On Debian/Ubuntu, install: qttools5-dev-tools") + print(str(exc)) + sys.exit(1) + + print("") + print("Moving QM Files to Assets") + print("") + + dstRel = dstDir.relative_to(ROOT_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(ROOT_DIR), dstRel / item.name)) + + print("") + + return + + +def cleanBuiltAssets(args: argparse.Namespace | None = None) -> None: + """Remove assets built by this script.""" + print("") + print("Removing Built Assets") + print("=====================") + print("") + + assets = [ + ROOT_DIR / "novelwriter" / "assets" / "sample.zip", + ROOT_DIR / "novelwriter" / "assets" / "manual.pdf", + ] + assets.extend((ROOT_DIR / "novelwriter" / "assets" / "i18n").glob("*.qm")) + for asset in assets: + if asset.is_file(): + asset.unlink() + print(f"Deleted: {asset.relative_to(ROOT_DIR)}") + + print("") + + return + + +def buildAllAssets(args: argparse.Namespace) -> None: + """Build all assets.""" + cleanBuiltAssets() + buildPdfManual() + buildSampleZip() + buildTranslationAssets() + return From 0380a7fe720306a54b648e55aa7fe7fb45b76507 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:40:20 +0100 Subject: [PATCH 10/13] Make sure the pkgutils script has no third party dependencies by default --- utils/build_binary.py | 5 +++-- utils/build_debian.py | 4 +--- utils/build_windows.py | 4 +--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/utils/build_binary.py b/utils/build_binary.py index 37dce983..1ffa4797 100644 --- a/utils/build_binary.py +++ b/utils/build_binary.py @@ -22,11 +22,11 @@ from __future__ import annotations import argparse -import PyInstaller.__main__ - def runPyinstaller() -> None: """Run the pyinstaller.""" + import PyInstaller.__main__ + build = ["novelWriter.py", "--clean", "--windowed", "--onedir", "--noconfirm"] build += ["--name", "novelwriter"] build += ["--workpath", "build_bin"] @@ -34,6 +34,7 @@ def runPyinstaller() -> None: build += ["--hidden-import", "pyenchant"] build += ["--add-data", "novelwriter/assets:assets"] PyInstaller.__main__.run(build) + return diff --git a/utils/build_debian.py b/utils/build_debian.py index f11dea72..e993cc7b 100644 --- a/utils/build_debian.py +++ b/utils/build_debian.py @@ -29,11 +29,9 @@ import sys from utils.common import ( ROOT_DIR, SETUP_DIR, checkAssetsExist, copyPackageFiles, copySourceCode, - extractVersion, makeCheckSum, toUpload + extractVersion, makeCheckSum, toUpload, writeFile ) -from tests.tools import writeFile - SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" diff --git a/utils/build_windows.py b/utils/build_windows.py index 0b568c50..b3538ad2 100644 --- a/utils/build_windows.py +++ b/utils/build_windows.py @@ -30,9 +30,7 @@ import zipfile from pathlib import Path -from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, writeFile - -from tests.tools import readFile +from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, readFile, writeFile def prepareCode(outDir: Path) -> None: From 9c04899df582461378cbe10f08889c514a388925 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:46:22 +0100 Subject: [PATCH 11/13] Fix failing test due to config init change --- novelwriter/config.py | 2 +- tests/test_base/test_base_config.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/novelwriter/config.py b/novelwriter/config.py index 3d6210af..222fb8d6 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -109,7 +109,7 @@ class Config: self._appPath = Path(__file__).parent.absolute() self._appRoot = self._appPath.parent - if getattr(sys, "frozen", False): + if getattr(sys, "frozen", False): # pragma: no cover # novelWriter is packaged as an exe self._appPath = Path(__file__).parent.parent.absolute() self._appRoot = self._appPath diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index 6d968292..75132aea 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -84,12 +84,6 @@ def testBaseConfig_Constructor(monkeypatch): assert tstConf.osWindows is False assert tstConf.osUnknown is True - # App is single file - with monkeypatch.context() as mp: - mp.setattr("pathlib.Path.is_file", lambda *a: True) - tstConf = Config() - assert tstConf._appPath == tstConf._appRoot - @pytest.mark.base def testBaseConfig_InitLoadSave(monkeypatch, fncPath, tstPaths): From aabc21d611ecd9da33dbf9c9d3def2073fd25a55 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 22:10:22 +0100 Subject: [PATCH 12/13] Clean up some more files for Windows build --- utils/build_windows.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/utils/build_windows.py b/utils/build_windows.py index b3538ad2..c6401dfd 100644 --- a/utils/build_windows.py +++ b/utils/build_windows.py @@ -110,17 +110,20 @@ def removeRedundantQt(libDir: Path) -> None: file.unlink() print(f"Deleted: {file}") - def unlinkIfPrefix(folder: Path, prefix: tuple[str, ...]) -> None: - if folder.is_dir(): - for item in folder.iterdir(): - if item.name.startswith(prefix): - unlinkIfFound(item) - def deleteFolder(folder: Path) -> None: if folder.is_dir(): shutil.rmtree(folder) print(f"Deleted: {folder}") + def unlinkIfPrefix(folder: Path, prefix: tuple[str, ...]) -> None: + if folder.is_dir(): + for item in folder.iterdir(): + if item.name.startswith(prefix): + if item.is_file(): + unlinkIfFound(item) + elif item.is_dir(): + deleteFolder(item) + print("Deleting Redundant Files") print("========================") print("") @@ -143,6 +146,7 @@ def removeRedundantQt(libDir: Path) -> None: bulkDel = ("QtQml", "Qt6Qml", "QtQuick", "Qt6Quick") unlinkIfPrefix(pyQt6Dir, bulkDel) + unlinkIfPrefix(bindDir, bulkDel) unlinkIfPrefix(binDir, bulkDel) delQt6 = [ From d7730e9b04153c4d1d68ed94dd250c692d1a6e0e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 16 Jan 2025 22:19:58 +0100 Subject: [PATCH 13/13] Clean up function names a little --- pkgutils.py | 6 +++--- utils/build_appimage.py | 2 +- utils/build_debian.py | 12 ++---------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/pkgutils.py b/pkgutils.py index 16bc3fa6..a4153e8b 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -237,7 +237,7 @@ if __name__ == "__main__": ) ) cmdBuildDeb.add_argument("--sign", action="store_true", help="Sign the package.") - cmdBuildDeb.set_defaults(func=utils.build_debian.mainDebian) + cmdBuildDeb.set_defaults(func=utils.build_debian.debian) # Build Ubuntu Packages cmdBuildUbuntu = parsers.add_parser( @@ -249,7 +249,7 @@ if __name__ == "__main__": ) cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the package.") cmdBuildUbuntu.add_argument("--build", type=int, help="Set build number.") - cmdBuildUbuntu.set_defaults(func=utils.build_debian.mainLaunchpad) + cmdBuildUbuntu.set_defaults(func=utils.build_debian.launchpad) # Build AppImage cmdBuildAppImage = parsers.add_parser( @@ -270,7 +270,7 @@ if __name__ == "__main__": cmdBuildAppImage.add_argument( "--python-version", default="3.11", help="Python version (e.g. 3.11)" ) - cmdBuildAppImage.set_defaults(func=utils.build_appimage.main) + cmdBuildAppImage.set_defaults(func=utils.build_appimage.appImage) # Build Windows Inno Setup Installer cmdBuildSetupExe = parsers.add_parser( diff --git a/utils/build_appimage.py b/utils/build_appimage.py index 5ad03854..f4fd45fe 100644 --- a/utils/build_appimage.py +++ b/utils/build_appimage.py @@ -32,7 +32,7 @@ from utils.common import ( ) -def main(args: argparse.Namespace) -> None: +def appImage(args: argparse.Namespace) -> None: """Build an AppImage.""" try: import python_appimage # noqa: F401 # type: ignore diff --git a/utils/build_debian.py b/utils/build_debian.py index e993cc7b..1f8e26eb 100644 --- a/utils/build_debian.py +++ b/utils/build_debian.py @@ -155,11 +155,7 @@ def makeDebianPackage( return "" -## -# Build Debian Package (build-deb) -## - -def mainDebian(args: argparse.Namespace) -> None: +def debian(args: argparse.Namespace) -> None: """Build a .deb package""" if sys.platform == "linux": print("ERROR: Command 'build-deb' can only be used on Linux") @@ -169,11 +165,7 @@ def mainDebian(args: argparse.Namespace) -> None: return -## -# Build Launchpad Packages (build-ubuntu) -## - -def mainLaunchpad(args: argparse.Namespace) -> None: +def launchpad(args: argparse.Namespace) -> None: """Wrapper for building Debian packages for Launchpad.""" if sys.platform == "linux": print("ERROR: Command 'build-ubuntu' can only be used on Linux")