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")