Clean up pkgutils and update Windows installer (#2196)

This commit is contained in:
Veronica Berglyd Olsen
2025-01-16 22:23:32 +01:00
committed by GitHub
16 changed files with 1269 additions and 1377 deletions
+1
View File
@@ -1,6 +1,7 @@
# Setup/Install
/MANIFEST
/build/
/build_*/
/deploy/
/dist/
/dist_*/
+1 -1
View File
@@ -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 = <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000" height="128" width="128"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z" /></svg>
+4 -4
View File
@@ -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): # pragma: no cover
# 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
+36 -1365
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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]
-6
View File
@@ -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):
+279
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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, (
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<!DOCTYPE TS>\n"
f"<TS version=\"2.0\" language=\"{langCode}\" sourcelanguage=\"en_GB\"/>\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
+166
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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 appImage(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
+44
View File
@@ -0,0 +1,44 @@
"""
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import argparse
def runPyinstaller() -> None:
"""Run the pyinstaller."""
import PyInstaller.__main__
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
+220
View File
@@ -0,0 +1,220 @@
"""
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import argparse
import datetime
import email.utils
import shutil
import subprocess
import sys
from utils.common import (
ROOT_DIR, SETUP_DIR, checkAssetsExist, copyPackageFiles, copySourceCode,
extractVersion, makeCheckSum, toUpload, 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 <code@vkbo.net> {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 ""
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")
sys.exit(1)
signKey = SIGN_KEY if args.sign else None
makeDebianPackage(signKey)
return
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")
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
+249
View File
@@ -0,0 +1,249 @@
"""
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 <https://www.gnu.org/licenses/>.
"""
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, readFile, writeFile
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 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("")
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(bindDir, 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
+182
View File
@@ -0,0 +1,182 @@
"""
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import shutil
import subprocess
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 = ROOT_DIR / "novelwriter" / "__init__.py"
try:
for aLine in initFile.read_text(encoding="utf-8").splitlines():
if aLine.startswith("__version__"):
numVers = getValue((aLine))
if aLine.startswith("__hexversion__"):
hexVers = getValue((aLine))
if aLine.startswith("__date__"):
relDate = getValue((aLine))
except Exception as exc:
print("Could not read file: %s" % initFile)
print(str(exc))
if not beQuiet:
print("novelWriter version: %s (%s) at %s" % (numVers, hexVers, relDate))
return numVers, hexVers, relDate
def stripVersion(version: str) -> str:
"""Strip the pre-release part from a version number."""
if "a" in version:
return version.partition("a")[0]
elif "b" in version:
return version.partition("b")[0]
elif "rc" in version:
return version.partition("rc")[0]
else:
return version
def 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 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.
"""
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")
def writeFile(file: Path, text: str) -> int:
"""Write string to file."""
return file.write_text(text, encoding="utf-8")
+85 -1
View File
@@ -20,12 +20,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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