Skip to content

Commit fcf16ed

Browse files
committed
feat: Improve logging and console UI with rich formatting
* Use rich and rich-click - colors, panels etc. * Proper syntax highlighting for SVG and YAML * Show directory tree after --create-from * Don't repeat identical subprocess log messages, count them
1 parent 6c3198a commit fcf16ed

13 files changed

Lines changed: 569 additions & 139 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ repos:
4646
- id: pylint
4747
additional_dependencies:
4848
- "cairosvg"
49-
- "click"
5049
- "jinja2"
5150
- "pydantic"
5251
- "pypdf"
5352
- "pytest"
53+
- "rich_click"
5454
- "ruamel.yaml"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors = [
77
]
88
dependencies = [
99
"cairosvg",
10-
"click",
10+
"rich-click",
1111
"jinja2",
1212
"pydantic",
1313
"pypdf",

src/pdfbaker/__main__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import sys
55
from pathlib import Path
66

7-
import click
7+
import rich_click as click
88

99
from pdfbaker import __version__
1010
from pdfbaker.baker import Baker, BakerOptions
11+
from pdfbaker.console import HELP_CONFIG
1112
from pdfbaker.errors import (
1213
DocumentNotFoundError,
1314
DryRunCreateFromCompleted,
@@ -50,6 +51,7 @@
5051
metavar="SVG_FILE",
5152
help="Scaffold CONFIG_FILE and supporting files for your existing SVG.",
5253
)
54+
@click.rich_config(help_config=HELP_CONFIG)
5355
# pylint: disable=too-many-arguments,too-many-positional-arguments
5456
def cli(
5557
config_file: Path,

src/pdfbaker/baker.py

Lines changed: 76 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,30 @@
66
bake() delegates to its documents and reports back the end result.
77
"""
88

9+
import logging
910
import shutil
1011
from pathlib import Path
12+
from typing import NamedTuple
1113

1214
from pydantic import BaseModel, ValidationError
1315
from ruamel.yaml import YAML
1416

1517
from .config import PathSpec
1618
from .config.baker import BakerConfig
19+
from .console import build_create_from_panel, build_outcome_panel, stdout_console
1720
from .document import Document
1821
from .errors import DocumentNotFoundError, DryRunCreateFromCompleted
1922
from .logging import LoggingMixin, setup_logging
2023

21-
__all__ = ["Baker", "BakerOptions"]
24+
__all__ = ["Baker", "BakerOptions", "ProcessedDoc"]
25+
26+
27+
class ProcessedDoc(NamedTuple):
28+
"""The outcome of processing a document, for reporting back to the user."""
29+
30+
document: Document
31+
pdf_files: list[Path] | None
32+
error_message: str | None
2233

2334

2435
class BakerOptions(BaseModel):
@@ -57,7 +68,7 @@ def __init__(
5768
setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose)
5869

5970
if options.create_from:
60-
self.create_from(
71+
project_dir = self.create_from(
6172
svg_path=options.create_from,
6273
config_path=config_file,
6374
dry_run=options.dry_run,
@@ -66,6 +77,10 @@ def __init__(
6677
# Dry run creations don't continue with dry run processing
6778
raise DryRunCreateFromCompleted()
6879

80+
if self.logger.getEffectiveLevel() <= logging.INFO:
81+
panel = build_create_from_panel(options.create_from, project_dir)
82+
stdout_console.print(panel)
83+
6984
self.log_debug_section("Loading main configuration: %s", config_file)
7085
self.config = BakerConfig(
7186
config_file=config_file,
@@ -74,53 +89,32 @@ def __init__(
7489
dry_run=options.dry_run,
7590
**kwargs,
7691
)
77-
self.log_trace(self.config.readable())
92+
self.log_trace_preview(self.config.readable(), syntax="yaml")
7893
self.log_debug("Build directory: %s", self.config.directories.build)
7994

8095
def bake(self, document_names: tuple[str, ...] | None = None) -> None:
8196
"""Bake the documents."""
8297
docs = self._get_selected_documents(document_names)
98+
99+
if self.config.dry_run:
100+
self.log_info("[DRY RUN] No files will be created.")
83101
self.log_debug_subsection("Documents to process:")
84102
self.log_debug(docs)
85103

86-
pdfs_created, failed_docs = self._process_documents(docs)
87-
88-
self.log_info("─" * 80)
89-
if pdfs_created:
90-
if self.config.dry_run:
91-
self.log_info("👀 [DRY RUN] Would have created PDFs:")
92-
else:
93-
self.log_info("Successfully created PDFs:")
94-
for pdf in pdfs_created:
95-
self.log_info(" %s %s", "🟨" if self.config.dry_run else "✅", pdf)
96-
else:
97-
self.log_warning("No PDFs were created.")
104+
processed_docs = self._process_documents(docs)
105+
if not self.config.keep_build:
106+
self.teardown()
98107

99-
if failed_docs:
100-
self.log_warning(
101-
"Failed to process %d document%s:",
102-
len(failed_docs),
103-
"" if len(failed_docs) == 1 else "s",
108+
if self.logger.getEffectiveLevel() <= logging.INFO:
109+
outcome_panel = build_outcome_panel(
110+
processed_docs,
111+
dry_run=self.config.dry_run,
112+
keep_build=self.config.keep_build,
113+
build_dir=self.config.directories.build,
104114
)
105-
for failed_doc, error_message in failed_docs:
106-
name = failed_doc.config.name
107-
if isinstance(failed_doc, Document) and failed_doc.config.is_variant:
108-
name += f' variant "{failed_doc.config.variant["name"]}"'
109-
self.log_error(" %s: %s", name, error_message)
110-
if hasattr(failed_doc, "config"):
111-
self.log_debug(
112-
'Build directory for "%s": %s',
113-
name,
114-
failed_doc.config.directories.build,
115-
)
116-
117-
if self.config.keep_build:
118-
if not self.config.dry_run:
119-
self.log_info("Build files kept in: %s", self.config.directories.build)
120-
else:
121-
self.teardown()
115+
stdout_console.print(outcome_panel)
122116

123-
return not failed_docs
117+
return all(not d.error_message for d in processed_docs)
124118

125119
def _get_selected_documents(
126120
self, selected_names: tuple[str, ...] | None = None
@@ -144,12 +138,8 @@ def _get_selected_documents(
144138

145139
return [doc for doc in self.config.documents if doc.name in selected_names]
146140

147-
def _process_documents(
148-
self, docs: list[PathSpec]
149-
) -> tuple[list[Path], list[tuple[PathSpec, str]]]:
150-
pdfs_created: list[Path] = []
151-
failed_docs: list[tuple[PathSpec, str]] = []
152-
141+
def _process_documents(self, docs: list[PathSpec]) -> list[ProcessedDoc]:
142+
processed_docs: list[ProcessedDoc] = []
153143
for config_path in docs:
154144
try:
155145
document = Document(
@@ -158,7 +148,7 @@ def _process_documents(
158148
except ValidationError as e:
159149
error_message = f'Invalid config for document "{config_path.name}": {e}'
160150
self.log_error(error_message)
161-
failed_docs.append((config_path, error_message))
151+
processed_docs.append(ProcessedDoc(config_path, None, error_message))
162152
continue
163153

164154
pdf_files, error_message = document.process_document()
@@ -169,15 +159,14 @@ def _process_documents(
169159
document.config.name,
170160
error_message,
171161
)
172-
failed_docs.append((document, error_message))
162+
processed_docs.append(ProcessedDoc(document, None, error_message))
173163
else:
174164
if isinstance(pdf_files, Path):
175165
pdf_files = [pdf_files]
176-
pdfs_created.extend(pdf_files)
166+
processed_docs.append(ProcessedDoc(document, pdf_files, None))
177167
if not self.config.keep_build:
178168
document.teardown()
179-
180-
return pdfs_created, failed_docs
169+
return processed_docs
181170

182171
def teardown(self) -> None:
183172
"""Clean up (top-level) build directory after processing."""
@@ -190,7 +179,8 @@ def teardown(self) -> None:
190179
self.log_debug("Removing top-level build directory...")
191180
if self.config.dry_run:
192181
self.log_debug(
193-
"👀 [DRY RUN] Not removing top-level build directory"
182+
":no_entry_sign:"
183+
" [DRY RUN] Not removing top-level build directory"
194184
)
195185
else:
196186
build_dir.rmdir()
@@ -202,8 +192,11 @@ def teardown(self) -> None:
202192
def create_from(
203193
self, svg_path: Path, config_path: Path, dry_run: bool = False
204194
) -> None:
205-
"""Create a minimal project structure from an SVG and config path."""
195+
"""Create a scaffold for templating from an SVG and config path."""
206196
project_dir = config_path.parent
197+
self.log_debug_section(
198+
"Creating from %s in: %s", svg_path.resolve(), project_dir.resolve()
199+
)
207200
doc_name = svg_path.stem
208201
doc_dir = project_dir / doc_name
209202
template_file = doc_dir / "templates" / "main.svg.j2"
@@ -218,22 +211,24 @@ def create_from(
218211

219212
for f in files_to_create:
220213
if f.exists():
221-
raise FileExistsError(f"File already exists: {f}")
214+
raise FileExistsError(f"File already exists: {f.resolve()}")
222215

223216
if dry_run:
224217
for d in dirs_to_create:
225-
self.log_info("👀 [DRY RUN] Would create directory: %s", d)
218+
self.log_info(":no_entry_sign: [DRY RUN] Would create directory: %s", d)
226219
for f in files_to_create:
227-
self.log_info("👀 [DRY RUN] Would create file: %s", f)
228-
self.log_info("👀 [DRY RUN] No files created.")
220+
self.log_info(":no_entry_sign: [DRY RUN] Would create file: %s", f)
221+
self.log_info(":no_entry_sign: [DRY RUN] No files created.")
229222
raise DryRunCreateFromCompleted()
230223

231224
for d in dirs_to_create:
225+
self.log_debug_subsection("Ensuring directory exists: %s", d.resolve())
232226
d.mkdir(parents=True, exist_ok=True)
233227

234228
yaml = YAML()
235229
yaml.indent(mapping=2, sequence=4, offset=2)
236230

231+
self.log_debug_subsection("Writing main config: %s", config_path.resolve())
237232
with open(config_path, "w", encoding="utf-8") as f:
238233
f.write("# PDFBaker main config\n\n")
239234
yaml.dump({"documents": [doc_name]}, f)
@@ -262,8 +257,15 @@ def create_from(
262257
"# font: Arial\n"
263258
"# color: black\n"
264259
)
260+
self.log_trace_preview(
261+
config_path.read_text(encoding="utf-8"),
262+
syntax="yaml",
263+
)
265264
self.log_info("Created main config: %s", config_path)
266265

266+
self.log_debug_subsection(
267+
"Writing document config: %s", doc_config_file.resolve()
268+
)
267269
with open(doc_config_file, "w", encoding="utf-8") as f:
268270
f.write("# Document config\n\n")
269271
yaml.dump({"filename": doc_name, "pages": ["main"]}, f)
@@ -280,8 +282,13 @@ def create_from(
280282
"# font: Arial\n"
281283
"# color: black\n"
282284
)
285+
self.log_trace_preview(
286+
doc_config_file.read_text(encoding="utf-8"),
287+
syntax="yaml",
288+
)
283289
self.log_info("Created document config: %s", doc_config_file)
284290

291+
self.log_debug_subsection("Writing page config: %s", page_file.resolve())
285292
with open(page_file, "w", encoding="utf-8") as f:
286293
f.write("# Page config\n\n")
287294
yaml.dump({"template": "main.svg.j2", "name": "main"}, f)
@@ -293,7 +300,20 @@ def create_from(
293300
"# title: My Document\n"
294301
"# date: 2025-05-19\n"
295302
)
296-
self.log_info("Created page: %s", page_file)
303+
self.log_trace_preview(
304+
page_file.read_text(encoding="utf-8"),
305+
syntax="yaml",
306+
)
307+
self.log_info("Created page config: %s", page_file)
297308

309+
self.log_debug_subsection(
310+
"Copying SVG to template: %s", template_file.resolve()
311+
)
298312
shutil.copy(svg_path, template_file)
313+
self.log_trace_preview(
314+
template_file.read_text(encoding="utf-8"),
315+
syntax="xml",
316+
)
299317
self.log_info("Created template: %s", template_file)
318+
319+
return project_dir

src/pdfbaker/config/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def truncating_representer(representer, data):
177177

178178
stream = io.StringIO()
179179
yaml.dump(self.model_dump(), stream)
180-
return f"\n{stream.getvalue()}"
180+
return stream.getvalue()
181181

182182
def resolve_path(self, path: Path) -> Path:
183183
"""Resolve relative paths relative to the base directory."""

0 commit comments

Comments
 (0)