Skip to content

Commit 70f956c

Browse files
committed
feat: Add --dry-run, --fail-if-exists, build in temp dir
Also prep `--create-from` (coming up)
1 parent ef2d2f6 commit 70f956c

10 files changed

Lines changed: 206 additions & 57 deletions

File tree

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ At least `documents` has to be specified.
3232
| `documents` **required** | array | `[]` | List of document configurations. A simple string specifies the name of a directory under `directories.documents`. A directory needs to contain a `config.yaml` file. You can also specify `path` and `name` to a specific directory or configuration YAML file. |
3333
| `directories` | mapping | see the following settings | You can override default directories with the `directories` mapping, just re-define one or more with one of the following settings. |
3434
| `directories.base` **readonly** | string | directory containing the config file | If directories are not absolute paths they will be relative to this base directory. |
35-
| `directories.build` | string | `"build"` | Intermediary SVG and PDF files are written here. Deleted unless you specified `--keep-build`. |
35+
| `directories.build` | string | | Intermediary SVG and PDF files are written here. If not set, will be a temporary directory. Deleted unless you specified `--keep-build`. |
3636
| `directories.dist` | string | `"dist"` | Final PDF files are written here. |
3737
| `directories.documents` | string | `"."` | Location of document configurations. |
3838
| `directories.images` | string | `"images"` | Location of image files. |

src/pdfbaker/__main__.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,51 @@
1717
@click.version_option(version=__version__, prog_name="pdfbaker")
1818
@click.argument(
1919
"config_file",
20-
type=click.Path(exists=True, dir_okay=False, path_type=Path),
20+
type=click.Path(exists=False, dir_okay=False, path_type=Path),
2121
)
22-
@click.argument("documents", nargs=-1)
23-
@click.option("-q", "--quiet", is_flag=True, help="Show errors only")
24-
@click.option("-v", "--verbose", is_flag=True, help="Show debug information")
22+
@click.argument("document_names", nargs=-1)
23+
@click.option("-q", "--quiet", is_flag=True, help="Show errors only.")
24+
@click.option("-v", "--verbose", is_flag=True, help="Show debug information.")
2525
@click.option(
2626
"-t",
2727
"--trace",
2828
is_flag=True,
29-
help="Show trace information (even more detailed than --verbose)",
29+
help="Show trace information (maximum details).",
30+
)
31+
@click.option(
32+
"--keep-build", is_flag=True, help="Keep rendered SVGs and single-page PDFs."
33+
)
34+
@click.option(
35+
"--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)."
36+
)
37+
@click.option(
38+
"--fail-if-exists",
39+
is_flag=True,
40+
help="Abort if a target PDF already exists.",
41+
)
42+
@click.option("--dry-run", is_flag=True, help="Do not write any files.")
43+
@click.option(
44+
"--create-from",
45+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
46+
metavar="SVG_FILE",
47+
help="Scaffold CONFIG_FILE and supporting files for your existing SVG.",
3048
)
31-
@click.option("--keep-build", is_flag=True, help="Keep build artifacts")
32-
@click.option("--debug", is_flag=True, help="Debug mode (--verbose and --keep-build)")
3349
# pylint: disable=too-many-arguments,too-many-positional-arguments
3450
def cli(
3551
config_file: Path,
36-
documents: tuple[str, ...],
52+
document_names: tuple[str, ...],
3753
quiet: bool,
3854
verbose: bool,
3955
trace: bool,
4056
keep_build: bool,
4157
debug: bool,
58+
fail_if_exists: bool,
59+
dry_run: bool,
60+
create_from: Path | None,
4261
) -> None:
4362
"""Generate PDF documents from YAML-configured SVG templates.
4463
45-
Optionally specify one or more document names to only process those documents.
64+
Specify one or more document names to only process those.
4665
"""
4766
if debug:
4867
verbose = True
@@ -54,10 +73,16 @@ def cli(
5473
verbose=verbose,
5574
trace=trace,
5675
keep_build=keep_build,
76+
fail_if_exists=fail_if_exists,
77+
dry_run=dry_run,
78+
create_from=create_from,
5779
)
5880
baker = Baker(config_file, options=options)
59-
success = baker.bake(document_names=documents if documents else None)
81+
success = baker.bake(document_names=document_names)
6082
sys.exit(0 if success else 1)
83+
except FileNotFoundError as exc:
84+
logger.error("❌ %s", str(exc))
85+
sys.exit(2)
6186
except DocumentNotFoundError as exc:
6287
logger.error("❌ %s", str(exc))
6388
sys.exit(2)

src/pdfbaker/baker.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@ class BakerOptions(BaseModel):
2727
verbose: Show debug information
2828
trace: Show trace information (even more detailed than debug)
2929
keep_build: Keep build artifacts after processing
30-
default_config_overrides: Dictionary of values to override the built-in defaults
31-
before loading the main configuration
30+
dry_run: Do not write any files, just log actions
31+
fail_if_exists: Abort if a file already exists in the dist directory
32+
create_from: Path to SVG file for populating a (new) project
3233
"""
3334

3435
quiet: bool = False
3536
verbose: bool = False
3637
trace: bool = False
3738
keep_build: bool = False
39+
fail_if_exists: bool = False
40+
dry_run: bool = False
41+
create_from: Path | None = None
3842

3943

4044
class Baker(LoggingMixin):
@@ -49,13 +53,18 @@ def __init__(
4953
"""Set up logging and load configuration."""
5054
options = options or BakerOptions()
5155
setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose)
56+
self.create_from = options.create_from
57+
# FIXME: use create_from to create a new config file
5258
self.log_debug_section("Loading main configuration: %s", config_file)
5359
self.config = BakerConfig(
5460
config_file=config_file,
5561
keep_build=options.keep_build,
62+
fail_if_exists=options.fail_if_exists,
63+
dry_run=options.dry_run,
5664
**kwargs,
5765
)
5866
self.log_trace(self.config.readable())
67+
self.log_debug("Build directory: %s", self.config.directories.build)
5968

6069
def bake(self, document_names: tuple[str, ...] | None = None) -> None:
6170
"""Bake the documents."""
@@ -65,27 +74,39 @@ def bake(self, document_names: tuple[str, ...] | None = None) -> None:
6574

6675
pdfs_created, failed_docs = self._process_documents(docs)
6776

77+
self.log_info("─" * 80)
6878
if pdfs_created:
69-
self.log_info("Successfully created PDFs:")
79+
if self.config.dry_run:
80+
self.log_info("👀 [DRY RUN] Would have created PDFs:")
81+
else:
82+
self.log_info("Successfully created PDFs:")
7083
for pdf in pdfs_created:
71-
self.log_info(" %s", pdf)
84+
self.log_info(" %s %s", "🟨" if self.config.dry_run else "✅", pdf)
7285
else:
7386
self.log_warning("No PDFs were created.")
7487

75-
if not self.config.keep_build:
76-
self.teardown()
77-
7888
if failed_docs:
7989
self.log_warning(
8090
"Failed to process %d document%s:",
8191
len(failed_docs),
8292
"" if len(failed_docs) == 1 else "s",
8393
)
8494
for failed_doc, error_message in failed_docs:
85-
name = failed_doc.name
86-
if isinstance(failed_doc, Document) and failed_doc.is_variant:
87-
name += f' variant "{failed_doc.variant["name"]}"'
95+
name = failed_doc.config.name
96+
if isinstance(failed_doc, Document) and failed_doc.config.is_variant:
97+
name += f' variant "{failed_doc.config.variant["name"]}"'
8898
self.log_error(" %s: %s", name, error_message)
99+
if hasattr(failed_doc, "config"):
100+
self.log_debug(
101+
'Build directory for "%s": %s',
102+
name,
103+
failed_doc.config.directories.build,
104+
)
105+
106+
if self.config.keep_build:
107+
self.log_info("Build files kept in: %s", self.config.directories.build)
108+
else:
109+
self.teardown()
89110

90111
return not failed_docs
91112

@@ -155,6 +176,13 @@ def teardown(self) -> None:
155176
if build_dir.exists():
156177
try:
157178
self.log_debug("Removing top-level build directory...")
158-
build_dir.rmdir()
179+
if self.config.dry_run:
180+
self.log_debug(
181+
"👀 [DRY RUN] Not removing top-level build directory"
182+
)
183+
else:
184+
build_dir.rmdir()
159185
except OSError:
160186
self.log_warning("Top-level build directory not empty - not removing")
187+
else:
188+
self.log_debug("Top-level build directory does not exist")

src/pdfbaker/config/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class BaseConfig(BaseModel, LoggingMixin):
119119
svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG
120120
compress_pdf: bool = False
121121
keep_build: bool = False
122+
dry_run: bool = False
123+
fail_if_exists: bool = False
122124

123125
model_config = ConfigDict(
124126
strict=True, # don't try to coerce values

src/pdfbaker/config/baker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Baker configuration for pdfbaker."""
22

3+
import tempfile
34
from pathlib import Path
45
from typing import Any
56

@@ -9,7 +10,7 @@
910
from . import BaseConfig, PathSpec
1011

1112
DEFAULT_DIRECTORIES = {
12-
"build": "build",
13+
"build": None,
1314
"dist": "dist",
1415
"documents": ".",
1516
"pages": "pages",
@@ -48,6 +49,10 @@ def load_config(cls, data: Any) -> Any:
4849
for key, default in DEFAULT_DIRECTORIES.items():
4950
directories.setdefault(key, default)
5051

52+
# If build dir is not set, use a temp dir
53+
if not directories.get("build"):
54+
directories["build"] = tempfile.mkdtemp(prefix="pdfbaker-")
55+
5156
if "documents" not in data:
5257
raise ValueError(
5358
'Key "documents" missing - is this the main configuration file?'

src/pdfbaker/document.py

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
import importlib
10-
import os
10+
import shutil
1111
from pathlib import Path
1212

1313
from .config import PathSpec
@@ -49,14 +49,22 @@ def process_document(self) -> tuple[Path | list[Path] | None, str | None]:
4949
self.config.directories.dist /= self.config.name
5050

5151
self.log_info_section('Processing document "%s"...', self.config.name)
52+
5253
self.log_debug(
5354
"Ensuring build directory exists: %s", self.config.directories.build
5455
)
55-
self.config.directories.build.mkdir(parents=True, exist_ok=True)
56+
if self.config.dry_run:
57+
self.log_debug("👀 [DRY RUN] Not creating build directory")
58+
else:
59+
self.config.directories.build.mkdir(parents=True, exist_ok=True)
60+
5661
self.log_debug(
5762
"Ensuring dist directory exists: %s", self.config.directories.dist
5863
)
59-
self.config.directories.dist.mkdir(parents=True, exist_ok=True)
64+
if self.config.dry_run:
65+
self.log_debug("👀 [DRY RUN] Not creating dist directory")
66+
else:
67+
self.config.directories.dist.mkdir(parents=True, exist_ok=True)
6068

6169
try:
6270
if self.config.custom_bake:
@@ -150,31 +158,45 @@ def _finalize(self, pdf_files: list[Path], doc_config: DocumentConfig) -> Path:
150158
"""Combine PDF pages and optionally compress."""
151159
self.log_debug_subsection("Finalizing document...")
152160
self.log_debug("Combining PDF pages...")
153-
try:
154-
combined_pdf = combine_pdfs(
155-
pdf_files,
156-
self.config.directories.build / f"{doc_config.filename}.pdf",
157-
)
158-
except PDFCombineError as exc:
159-
raise PDFBakerError(f"Failed to combine PDFs: {exc}") from exc
161+
162+
if self.config.dry_run:
163+
self.log_debug("👀 [DRY RUN] Not combining PDF pages")
164+
else:
165+
try:
166+
combined_pdf = combine_pdfs(
167+
pdf_files,
168+
self.config.directories.build / f"{doc_config.filename}.pdf",
169+
)
170+
except PDFCombineError as exc:
171+
raise PDFBakerError(f"Failed to combine PDFs: {exc}") from exc
160172

161173
output_path = self.config.directories.dist / f"{doc_config.filename}.pdf"
162174

175+
if self.config.fail_if_exists and output_path.exists():
176+
raise PDFBakerError(f"File already exists: {output_path}")
177+
163178
if doc_config.compress_pdf:
164179
self.log_debug("Compressing PDF document...")
165-
try:
166-
compress_pdf(combined_pdf, output_path)
167-
self.log_info("PDF compressed successfully")
168-
except PDFCompressionError as exc:
169-
self.log_warning(
170-
"Compression failed, using uncompressed PDF: %s",
171-
exc,
172-
)
173-
os.rename(combined_pdf, output_path)
180+
if self.config.dry_run:
181+
self.log_debug("👀 [DRY RUN] Not compressing PDF document")
182+
else:
183+
try:
184+
compress_pdf(combined_pdf, output_path)
185+
self.log_info("PDF compressed successfully")
186+
except PDFCompressionError as exc:
187+
self.log_warning(
188+
"Compression failed, using uncompressed PDF: %s",
189+
exc,
190+
)
191+
shutil.move(combined_pdf, output_path)
174192
else:
175-
os.rename(combined_pdf, output_path)
193+
if not self.config.dry_run:
194+
shutil.move(combined_pdf, output_path)
176195

177-
self.log_info("Created %s", output_path.name)
196+
if self.config.dry_run:
197+
self.log_info("👀 [DRY RUN] Did not create %s", output_path.name)
198+
else:
199+
self.log_info("Created %s", output_path.name)
178200
return output_path
179201

180202
def teardown(self) -> None:
@@ -183,12 +205,21 @@ def teardown(self) -> None:
183205
self.log_debug_subsection("Tearing down build directory: %s", build_dir)
184206
if build_dir.exists():
185207
self.log_debug("Removing files in build directory...")
186-
for file_path in build_dir.iterdir():
187-
if file_path.is_file():
188-
file_path.unlink()
208+
209+
if self.config.dry_run:
210+
self.log_debug("👀 [DRY RUN] Not removing files in build directory")
211+
else:
212+
for file_path in build_dir.iterdir():
213+
if file_path.is_file():
214+
file_path.unlink()
189215

190216
try:
191217
self.log_debug("Removing build directory...")
192-
build_dir.rmdir()
218+
if self.config.dry_run:
219+
self.log_debug("👀 [DRY RUN] Not removing build directory")
220+
else:
221+
build_dir.rmdir()
193222
except OSError:
194223
self.log_warning("Build directory not empty - not removing")
224+
else:
225+
self.log_debug("Build directory does not exist")

src/pdfbaker/page.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,13 @@ def process(self) -> Path:
8585
renderer.value for renderer in self.config.template_renderers
8686
],
8787
)
88-
with open(output_svg, "w", encoding="utf-8") as f:
89-
f.write(rendered_template)
88+
if self.config.dry_run:
89+
self.log_debug(
90+
"👀 [DRY RUN] Not writing rendered template to %s", output_svg
91+
)
92+
else:
93+
with open(output_svg, "w", encoding="utf-8") as f:
94+
f.write(rendered_template)
9095
except TemplateError as exc:
9196
raise SVGTemplateError(
9297
"Failed to render page "
@@ -95,6 +100,9 @@ def process(self) -> Path:
95100
self.log_trace_preview(rendered_template)
96101

97102
self.log_debug("Converting SVG to PDF: %s", output_svg)
103+
if self.config.dry_run:
104+
self.log_debug("👀 [DRY RUN] Not converting SVG to PDF")
105+
return None
98106
try:
99107
return convert_svg_to_pdf(
100108
output_svg,

0 commit comments

Comments
 (0)