Compare commits

...

2 Commits

Author SHA1 Message Date
573aec8ad7 added minizine subcommand, improved robustness and modularity 2025-10-28 14:15:54 +01:00
ece1e865ca modified pylint settings 2025-10-28 11:36:38 +01:00
6 changed files with 214 additions and 81 deletions

View File

@ -5,6 +5,7 @@
}
],
"settings": {
"python.terminal.activateEnvInCurrentTerminal": true
"python.terminal.activateEnvInCurrentTerminal": true,
"pylint.path" : ["${interpreter}", "-m", "pylint"]
}
}

View File

@ -1,2 +1,4 @@
#!/usr/bin/env python
from .main import app
app(prog_name="bookbuild")

View File

@ -1,40 +1,43 @@
from pathlib import Path
from logging import Logger
import typer
from .defaults import LOGGER, OUTPUT_DIR
# instantiate typer app
clean_app = typer.Typer()
def _clean_dir(path: Path, logger: Logger, start_dir = None) -> None:
# ##################
# INTERNAL FUNCTIONS
# ##################
def _clean_dir(path: Path) -> None:
"""
Recursively delete all files and directories in `path`.
"""
# remember start dir (not to be deleted)
if not start_dir:
start_dir = path
for file in path.iterdir():
# if file: delete
if file.is_file():
logger.debug(f"unlinking file {file}")
LOGGER.debug(f"unlinking file {file}")
file.unlink()
# if dir: clean recursively
else:
_clean_dir(file, logger, start_dir)
_clean_dir(file)
# delete empty dir unless it's start_dir
if file != start_dir:
logger.debug(f"removing dir {file}")
if file != OUTPUT_DIR:
LOGGER.debug(f"removing dir {file}")
file.rmdir()
# ##############
# TYPER COMMANDS
# ##############
@clean_app.command()
def clean(ctx: typer.Context):
"""
Remove all files from output directory.
"""
logger, output_dir = ctx.obj["logger"], ctx.obj["paths"]["output"]
logger.info(f"Cleaning directory {output_dir}.")
_clean_dir(output_dir, logger)
LOGGER.info(f"Cleaning directory {OUTPUT_DIR}.")
_clean_dir(OUTPUT_DIR)

122
src/bookbuild/defaults.py Normal file
View File

@ -0,0 +1,122 @@
import logging
import subprocess
from enum import Enum
from pathlib import Path
from shutil import which
from typing import Annotated
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"
# default executables and options
EXECUTABLES = {
"pandoc": {"executable": "", "options": []},
"typst": {"executable": "", "options": ["compile", "--root", "."]}
}
# ##################
# INTERNAL FUNCTIONS
# ##################
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"Command failed with return code {e.returncode}.")
LOGGER.critical(f"Command output: {e.output}")
raise typer.Exit(code=e.returncode)
else:
LOGGER.debug(f"Command finished with return code 0.")
# LOGGER.debug(f"Command output: {stdout}")
def get_executable(cmd: str) -> str:
"""
Returns the path to shell command `cmd` or exits.
"""
exe = which(cmd)
if not exe:
LOGGER.critical(f"{cmd} is not installed!")
typer.Exit(1)
return exe
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
# ##############
# TYPER CALLBACK
# ##############
# callback function for the main typer app
def callback(
ctx: typer.Context,
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}")

View File

@ -1,83 +1,26 @@
#!/usr/bin/env python
import sys
from pathlib import Path
from typing import Annotated
import logging
import typer
from .clean import clean_app
#from .pdf import pdf as pdf_app
# start logger
logger = logging.getLogger(__name__)
from .defaults import callback
# instantiate typer app
app = typer.Typer(
no_args_is_help=True,
help="Build script to make a professionally typeset book from markdown source files that can either be read on screen (PDF or EPUB) or be used in a project with Tredition.",
context_settings={"help_option_names": ["-h", "--help"]}
callback=callback,
context_settings={"help_option_names": ["-h", "--help"]},
help="Build script to make a professionally typeset book from markdown source files that can either be read on screen (PDF or EPUB) or be used in a project with Tredition."
)
#app.add_typer(pdf_app)
# add typer apps handling the subcommands
from .clean import clean_app
app.add_typer(clean_app)
from .minizine import minizine_app
app.add_typer(minizine_app)
# Make CLI runnable from source tree with python src/package
if not __package__:
package_source_path = Path(__file__).parent
sys.path.insert(0, package_source_path)
@app.callback()
def main(
ctx: typer.Context,
log_level: Annotated[
str,
typer.Option("--log-level", "-l", help="Set log level.")
] = "INFO",
output_dir: Annotated[
str,
typer.Option("--output-dir", "-o", help="Specify output directory.")
] = "./output/"
):
# prepare context object
ctx.obj = {}
# validate log_level
if not getattr(logging, log_level.upper()):
logger.error(f"{log_level} is not a recognized log_level, using 'info' instead.")
log_level = "info"
# configure logger
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="{asctime} {levelname}: {message}",
style="{",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger.debug("Logger configured.")
ctx.obj["logger"] = logger
# set directories
paths = {}
paths["base"] = Path.cwd()
paths["config"] = paths["base"] / "config"
paths["content"] = paths["base"] / "content"
paths["cover"] = paths["base"] / "cover"
paths["minizine"] = paths["base"] / "promo" / "minizine"
# set output directory
p = Path(output_dir).expanduser()
paths["output"] = p if p.is_absolute() else paths["base"] / p
logger.debug(f"output_dir is {paths['output']}")
# add paths to context
ctx.obj["paths"] = paths
# make sure output dir exists
paths["output"].mkdir(parents=True, exist_ok=True)
#if __name__ == "__main__":
# app()

62
src/bookbuild/minizine.py Normal file
View File

@ -0,0 +1,62 @@
from logging import Logger
from pathlib import Path
from typing import Annotated
import typer
from .defaults import *
# instantiate typer app
minizine_app = typer.Typer()
@minizine_app.command()
def minizine(
ctx: typer.Context,
format: Annotated[
Format,
typer.Option("--format", "-f", help="Specify output format.")
] = Format.PDF,
input_dir: Annotated[
str,
typer.Option("--input-dir", "-i", help=f"Specify input directory (either absolute or relative to {str(BASE_DIR)}).")
] = str(MINIZINE_DIR)
):
"""
Build the minizine for the book.
"""
LOGGER.info(f"Building minizine as a {format.upper()} file.")
# make sure input_dir exists and is not empty
input_dir = absolutify(input_dir)
if not input_dir.exists():
LOGGER.error(f"Input directory {input_dir} does not exist.")
return
elif not next(input_dir.glob("*"), None):
LOGGER.error(f"Input directory {input_dir} is empty.")
return
# get typst executable
typst = EXECUTABLES["typst"]
# get input files
input_paths = input_dir.glob("*.typ")
# run command on each input file
was_empty = True
while True:
# get next generator item
input_path = next(input_paths, None)
if input_path:
was_empty = False
LOGGER.info(f"Processing file {str(input_path)}")
# get input file name
input_files = [str(input_path)]
# set output file name
output_file = str(OUTPUT_DIR / f"{input_path.stem}.{format.value}")
# run command
run_command(typst["executable"], ex_opts=typst["options"], infiles=input_files, outfile=output_file)
else:
if was_empty:
LOGGER.error(f"No typst source files found in {input_dir}")
break