bookbuild/src/bookbuild/defaults.py

203 lines
5.8 KiB
Python

import logging
import subprocess
from enum import Enum
from pathlib import Path
from shutil import which
from typing import Annotated
import yaml
from pypdf import PdfWriter
import typer
# start logger
LOGGER = logging.getLogger(__name__)
# helper class for available output formats
class Format(str, Enum):
PDF = "pdf"
PNG = "png"
# directory defaults
BASE_DIR = Path.cwd()
CONFIG_DIR = BASE_DIR / "config"
CONTENT_DIR = BASE_DIR / "content"
COVER_DIR = BASE_DIR / "cover"
MINIZINE_DIR = BASE_DIR / "promo" / "minizine"
OUTPUT_DIR = BASE_DIR / "output"
# logging defaults
LOG_LEVEL = "INFO"
# list of shell programs and options
PROGRAMS = {
"pandoc": {"executable": None, "options": []},
"typst": {"executable": None, "options": ["compile", "--root", "."]},
}
OPTIONAL_PROGRAMS = {
"epubcheck": {"executable": None, "options": []}
}
# ##################
# INTERNAL FUNCTIONS
# ##################
def absolutify(path: str) -> Path:
"""
Takes a path in string form and returns a Path object which is either the original path (if it was absolute) or relative to the current working directory.
"""
p = Path(path).expanduser()
return p if p.is_absolute() else BASE_DIR / p
def add_presuffix(path: Path|str, presuffix: str) -> Path:
# cast past to Path
if isinstance(path, str):
path = Path(path)
# add leading dot if nexessary
if presuffix[0] != ".":
presuffix = "." + presuffix
return path.parent / f"{path.stem}{presuffix}{path.suffix}"
def get_data_from_yaml(input_file: str, key: str) -> str|list|dict|None:
with open(input_file, "r") as file:
data = yaml.load(file, Loader=yaml.Loader)
return data.get(key)
def __get_executable(cmd: str, optional: bool = False) -> str|None:
"""
Returns the path to a given shell command as a string. If the command doesn't exist in path, the function exits unless optional is True, in which case the return value is None.
"""
exe = which(cmd)
if not exe:
if not optional:
LOGGER.critical(f"Required program {cmd} is not installed!")
raise typer.Exit()
else:
LOGGER.warning(f"Optional program {cmd} is not installed!")
return None
return exe
def get_input_files(target_format: str) -> list:
# read filenames from file if such a file exists
list_file = CONFIG_DIR / f"source-files.{target_format}.txt"
if list_file.is_file():
with open(list_file, "r") as file:
return [line.strip() for line in file if line[0] != "#"]
# use all files otherwise
else:
paths = CONTENT_DIR.glob('**/*.md')
return list(map(str, sorted(paths)))
def merge_pdf_with_covers(frontcover: str, content: str, backcover: str, keep_coverless: bool=False):
# pdflist = []
#
# # check if files exist
# for filename in [frontcover, content, backcover]:
# if Path(filename).is_file():
# pdflist.append(filename)
# merge files wwith pypdf
LOGGER.debug("merge_pdf_with_covers() with new code stuff")
merger = PdfWriter()
for pdf in [frontcover, content, backcover]:
if pdf and Path(pdf).is_file():
merger.append(pdf)
# write merged file
outfile = str(add_presuffix(content, ".with-covers")) if keep_coverless else content
merger.write(outfile)
merger.close()
def run_command(executable: str, infiles: list, ex_opts: list = [], outfile: str = ""):
"""
Runs a given executable as a shell command using the given options, list of input filenames, and optional output filename.
"""
# put command together
outfile = [outfile] if outfile else []
command = [executable] + ex_opts + infiles + outfile
LOGGER.debug(f"Executing command {command}")
# run shell command
try:
stdout = subprocess.check_output(command, stderr=subprocess.STDOUT)
pass #DEBUG
except subprocess.CalledProcessError as e:
LOGGER.critical(f"run_command(): {executable} failed with return code {e.returncode} and this output:\n{e.output.decode('utf-8')}")
raise typer.Exit(code=e.returncode)
else:
LOGGER.debug(f"run_command(): {executable} finished with return code 0 and this output:\n{stdout.decode('utf-8')}")
# ##################
# CALLBACK FUNCTIONS
# ##################
# callback function for the main typer app
def main_callback(
log_level: Annotated[
str,
typer.Option("--log-level", "-l", help="Set log level.")
] = LOG_LEVEL,
output_dir: Annotated[
str,
typer.Option("--output-dir", "-o", help=f"Specify output directory (either absolute or relative to {str(BASE_DIR)}).")
] = str(OUTPUT_DIR)
):
"""
Typer callback function for the main app. Configures the logger and handles CLI options that do not belong to a subcommand.
"""
# validate log_level
if not getattr(logging, log_level.upper()):
LOGGER.error(f"{log_level} is not a recognized log_level, using default '{LOG_LEVEL}' instead.")
log_level = LOG_LEVEL
# configure logger
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="{asctime} {filename} {levelname}: {message}",
style="{",
datefmt="%Y-%m-%d %H:%M:%S"
)
LOGGER.debug("Logger configured.")
# set output directory and make sure it exists
OUTPUT_DIR = absolutify(output_dir)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
LOGGER.debug(f"output_dir is {OUTPUT_DIR}")
# set paths to executables
for k, v in PROGRAMS.items():
v["executable"] = __get_executable(k)
for k, v in OPTIONAL_PROGRAMS.items():
v["executable"] = __get_executable(k)
def input_dir_callback(input_dir: str) -> str:
"""
Validates the argument input_dir which is used by several commands.
"""
input_dir = absolutify(input_dir)
# make sure dir exists
if not input_dir.exists():
raise typer.BadParameter(f"Input directory {input_dir} does not exist.")
# make sure dir isn't empty
if not next(input_dir.glob("*"), None):
raise typer.BadParameter(f"Input directory {input_dir} is empty.")
# OK
return str(input_dir)