203 lines
5.8 KiB
Python
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) |