Files
novelWriter/pkgutils.py
T
2024-07-04 18:34:10 +02:00

1759 lines
50 KiB
Python
Executable File
Raw Blame History

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