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": {
|
"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
|
from .main import app
|
||||||
app(prog_name="bookbuild")
|
app(prog_name="bookbuild")
|
||||||
|
|||||||
@ -1,40 +1,43 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from logging import Logger
|
|
||||||
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from .defaults import LOGGER, OUTPUT_DIR
|
||||||
|
|
||||||
|
# instantiate typer app
|
||||||
clean_app = typer.Typer()
|
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`.
|
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():
|
for file in path.iterdir():
|
||||||
# if file: delete
|
# if file: delete
|
||||||
if file.is_file():
|
if file.is_file():
|
||||||
logger.debug(f"unlinking file {file}")
|
LOGGER.debug(f"unlinking file {file}")
|
||||||
file.unlink()
|
file.unlink()
|
||||||
# if dir: clean recursively
|
# if dir: clean recursively
|
||||||
else:
|
else:
|
||||||
_clean_dir(file, logger, start_dir)
|
_clean_dir(file)
|
||||||
# delete empty dir unless it's start_dir
|
# delete empty dir unless it's start_dir
|
||||||
if file != start_dir:
|
if file != OUTPUT_DIR:
|
||||||
logger.debug(f"removing dir {file}")
|
LOGGER.debug(f"removing dir {file}")
|
||||||
file.rmdir()
|
file.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
# ##############
|
||||||
|
# TYPER COMMANDS
|
||||||
|
# ##############
|
||||||
|
|
||||||
@clean_app.command()
|
@clean_app.command()
|
||||||
def clean(ctx: typer.Context):
|
def clean(ctx: typer.Context):
|
||||||
"""
|
"""
|
||||||
Remove all files from output directory.
|
Remove all files from output directory.
|
||||||
"""
|
"""
|
||||||
logger, output_dir = ctx.obj["logger"], ctx.obj["paths"]["output"]
|
LOGGER.info(f"Cleaning directory {OUTPUT_DIR}.")
|
||||||
logger.info(f"Cleaning directory {output_dir}.")
|
_clean_dir(OUTPUT_DIR)
|
||||||
_clean_dir(output_dir, logger)
|
|
||||||
|
|||||||
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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from .clean import clean_app
|
from .defaults import callback
|
||||||
#from .pdf import pdf as pdf_app
|
|
||||||
|
|
||||||
# start logger
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
# instantiate typer app
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
no_args_is_help=True,
|
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.",
|
callback=callback,
|
||||||
context_settings={"help_option_names": ["-h", "--help"]}
|
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)
|
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
|
# Make CLI runnable from source tree with python src/package
|
||||||
if not __package__:
|
if not __package__:
|
||||||
package_source_path = Path(__file__).parent
|
package_source_path = Path(__file__).parent
|
||||||
sys.path.insert(0, package_source_path)
|
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