Compare commits
2 Commits
d00a533dd9
...
573aec8ad7
| Author | SHA1 | Date | |
|---|---|---|---|
| 573aec8ad7 | |||
| ece1e865ca |
3
.vscode/bookbuild.code-workspace
vendored
3
.vscode/bookbuild.code-workspace
vendored
@ -5,6 +5,7 @@
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"python.terminal.activateEnvInCurrentTerminal": true
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"pylint.path" : ["${interpreter}", "-m", "pylint"]
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from .main import app
|
||||
app(prog_name="bookbuild")
|
||||
|
||||
@ -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
122
src/bookbuild/defaults.py
Normal 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}")
|
||||
|
||||
@ -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
62
src/bookbuild/minizine.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user