simple-resume 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. simple_resume/__init__.py +132 -0
  2. simple_resume/core/__init__.py +47 -0
  3. simple_resume/core/colors.py +215 -0
  4. simple_resume/core/config.py +672 -0
  5. simple_resume/core/constants/__init__.py +207 -0
  6. simple_resume/core/constants/colors.py +98 -0
  7. simple_resume/core/constants/files.py +28 -0
  8. simple_resume/core/constants/layout.py +58 -0
  9. simple_resume/core/dependencies.py +258 -0
  10. simple_resume/core/effects.py +154 -0
  11. simple_resume/core/exceptions.py +261 -0
  12. simple_resume/core/file_operations.py +68 -0
  13. simple_resume/core/generate/__init__.py +21 -0
  14. simple_resume/core/generate/exceptions.py +69 -0
  15. simple_resume/core/generate/html.py +233 -0
  16. simple_resume/core/generate/pdf.py +659 -0
  17. simple_resume/core/generate/plan.py +131 -0
  18. simple_resume/core/hydration.py +55 -0
  19. simple_resume/core/importers/__init__.py +3 -0
  20. simple_resume/core/importers/json_resume.py +284 -0
  21. simple_resume/core/latex/__init__.py +60 -0
  22. simple_resume/core/latex/context.py +56 -0
  23. simple_resume/core/latex/conversion.py +227 -0
  24. simple_resume/core/latex/escaping.py +68 -0
  25. simple_resume/core/latex/fonts.py +93 -0
  26. simple_resume/core/latex/formatting.py +81 -0
  27. simple_resume/core/latex/sections.py +218 -0
  28. simple_resume/core/latex/types.py +84 -0
  29. simple_resume/core/markdown.py +127 -0
  30. simple_resume/core/models.py +102 -0
  31. simple_resume/core/palettes/__init__.py +38 -0
  32. simple_resume/core/palettes/common.py +73 -0
  33. simple_resume/core/palettes/data/default_palettes.json +58 -0
  34. simple_resume/core/palettes/exceptions.py +33 -0
  35. simple_resume/core/palettes/fetch_types.py +52 -0
  36. simple_resume/core/palettes/generators.py +137 -0
  37. simple_resume/core/palettes/registry.py +76 -0
  38. simple_resume/core/palettes/resolution.py +123 -0
  39. simple_resume/core/palettes/sources.py +162 -0
  40. simple_resume/core/paths.py +21 -0
  41. simple_resume/core/protocols.py +134 -0
  42. simple_resume/core/py.typed +0 -0
  43. simple_resume/core/render/__init__.py +37 -0
  44. simple_resume/core/render/manage.py +199 -0
  45. simple_resume/core/render/plan.py +405 -0
  46. simple_resume/core/result.py +226 -0
  47. simple_resume/core/resume.py +609 -0
  48. simple_resume/core/skills.py +60 -0
  49. simple_resume/core/validation.py +321 -0
  50. simple_resume/py.typed +0 -0
  51. simple_resume/shell/__init__.py +3 -0
  52. simple_resume/shell/assets/static/css/README.md +213 -0
  53. simple_resume/shell/assets/static/css/common.css +641 -0
  54. simple_resume/shell/assets/static/css/fonts.css +42 -0
  55. simple_resume/shell/assets/static/css/preview.css +82 -0
  56. simple_resume/shell/assets/static/css/print.css +99 -0
  57. simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
  58. simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
  59. simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
  60. simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
  61. simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
  62. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
  63. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
  64. simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
  65. simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
  66. simple_resume/shell/assets/static/schema.json +236 -0
  67. simple_resume/shell/assets/static/themes/README.md +208 -0
  68. simple_resume/shell/assets/static/themes/bold.yaml +64 -0
  69. simple_resume/shell/assets/static/themes/classic.yaml +64 -0
  70. simple_resume/shell/assets/static/themes/executive.yaml +64 -0
  71. simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
  72. simple_resume/shell/assets/static/themes/modern.yaml +64 -0
  73. simple_resume/shell/assets/templates/html/cover.html +129 -0
  74. simple_resume/shell/assets/templates/html/demo.html +13 -0
  75. simple_resume/shell/assets/templates/html/resume_base.html +453 -0
  76. simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
  77. simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
  78. simple_resume/shell/cli/__init__.py +35 -0
  79. simple_resume/shell/cli/main.py +975 -0
  80. simple_resume/shell/cli/palette.py +75 -0
  81. simple_resume/shell/cli/random_palette_demo.py +407 -0
  82. simple_resume/shell/config.py +96 -0
  83. simple_resume/shell/effect_executor.py +211 -0
  84. simple_resume/shell/file_opener.py +308 -0
  85. simple_resume/shell/generate/__init__.py +37 -0
  86. simple_resume/shell/generate/core.py +650 -0
  87. simple_resume/shell/generate/lazy.py +284 -0
  88. simple_resume/shell/io_utils.py +199 -0
  89. simple_resume/shell/palettes/__init__.py +1 -0
  90. simple_resume/shell/palettes/fetch.py +63 -0
  91. simple_resume/shell/palettes/loader.py +321 -0
  92. simple_resume/shell/palettes/remote.py +179 -0
  93. simple_resume/shell/pdf_executor.py +52 -0
  94. simple_resume/shell/py.typed +0 -0
  95. simple_resume/shell/render/__init__.py +1 -0
  96. simple_resume/shell/render/latex.py +308 -0
  97. simple_resume/shell/render/operations.py +240 -0
  98. simple_resume/shell/resume_extensions.py +737 -0
  99. simple_resume/shell/runtime/__init__.py +7 -0
  100. simple_resume/shell/runtime/content.py +190 -0
  101. simple_resume/shell/runtime/generate.py +497 -0
  102. simple_resume/shell/runtime/lazy.py +138 -0
  103. simple_resume/shell/runtime/lazy_import.py +173 -0
  104. simple_resume/shell/service_locator.py +80 -0
  105. simple_resume/shell/services.py +256 -0
  106. simple_resume/shell/session/__init__.py +6 -0
  107. simple_resume/shell/session/config.py +35 -0
  108. simple_resume/shell/session/manage.py +386 -0
  109. simple_resume/shell/strategies.py +181 -0
  110. simple_resume/shell/themes/__init__.py +35 -0
  111. simple_resume/shell/themes/loader.py +230 -0
  112. simple_resume-0.1.9.dist-info/METADATA +201 -0
  113. simple_resume-0.1.9.dist-info/RECORD +116 -0
  114. simple_resume-0.1.9.dist-info/WHEEL +4 -0
  115. simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
  116. simple_resume-0.1.9.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """Render resumes as LaTeX documents (shell layer with I/O operations)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+
8
+ # Bandit: subprocess usage is confined to vetted TeX commands.
9
+ import subprocess # nosec B404
10
+ from collections.abc import Iterable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
15
+
16
+ from simple_resume.core.latex import (
17
+ LatexRenderResult,
18
+ build_latex_context_pure,
19
+ fontawesome_support_block,
20
+ )
21
+ from simple_resume.core.paths import Paths
22
+ from simple_resume.shell import config
23
+ from simple_resume.shell.runtime.content import get_content
24
+
25
+
26
+ class LatexCompilationError(RuntimeError):
27
+ """Raise when LaTeX compilation fails."""
28
+
29
+ def __init__(self, message: str, *, log: str | None = None) -> None:
30
+ """Initialize the exception.
31
+
32
+ Args:
33
+ message: The error message.
34
+ log: The compilation log.
35
+
36
+ """
37
+ super().__init__(message)
38
+ self.log = log
39
+
40
+
41
+ def _jinja_environment(template_root: Path) -> Environment:
42
+ """Return a Jinja2 environment for LaTeX templates.
43
+
44
+ This is an I/O operation that loads templates from the file system.
45
+
46
+ Args:
47
+ template_root: Path to template directory.
48
+
49
+ Returns:
50
+ Configured Jinja2 environment.
51
+
52
+ """
53
+ loader = FileSystemLoader(str(template_root))
54
+ env = Environment(loader=loader, autoescape=select_autoescape(("html", "xml")))
55
+ return env
56
+
57
+
58
+ def build_latex_context(
59
+ data: dict[str, Any],
60
+ *,
61
+ static_dir: Path | None = None,
62
+ ) -> dict[str, Any]:
63
+ """Prepare the LaTeX template context from raw resume data (with I/O).
64
+
65
+ This shell function wraps the pure core function and adds file system
66
+ operations for FontAwesome font detection.
67
+
68
+ Args:
69
+ data: Raw resume data dictionary.
70
+ static_dir: Path to static assets directory for font detection.
71
+
72
+ Returns:
73
+ Dictionary of context variables for LaTeX template rendering.
74
+
75
+ """
76
+ # Get pure context from core
77
+ context = build_latex_context_pure(data)
78
+
79
+ # Add fontawesome_block with file system check (I/O operation)
80
+ fontawesome_dir: str | None = None
81
+ if static_dir is not None:
82
+ candidate = Path(static_dir) / "fonts" / "fontawesome"
83
+ if candidate.is_dir(): # I/O: file system check
84
+ fontawesome_dir = f"{candidate.resolve().as_posix()}/"
85
+
86
+ context["fontawesome_block"] = fontawesome_support_block(fontawesome_dir)
87
+
88
+ return context
89
+
90
+
91
+ def render_resume_latex_from_data(
92
+ data: dict[str, Any],
93
+ *,
94
+ paths: Paths | None = None,
95
+ template_name: str = "latex/basic.tex",
96
+ ) -> LatexRenderResult:
97
+ """Render a LaTeX template with the prepared context.
98
+
99
+ This function performs I/O operations: template loading and rendering.
100
+
101
+ Args:
102
+ data: Resume data dictionary.
103
+ paths: Path configuration (resolved if not provided).
104
+ template_name: Template file name to render.
105
+
106
+ Returns:
107
+ LatexRenderResult with rendered tex and context.
108
+
109
+ """
110
+ resolved_paths = paths or config.resolve_paths()
111
+ context = build_latex_context(data, static_dir=resolved_paths.static)
112
+
113
+ # I/O operations: load and render template
114
+ env = _jinja_environment(resolved_paths.templates)
115
+ template = env.get_template(template_name)
116
+ tex = template.render(**context)
117
+
118
+ return LatexRenderResult(tex=tex, context=context)
119
+
120
+
121
+ def render_resume_latex(
122
+ name: str,
123
+ *,
124
+ paths: Paths | None = None,
125
+ template_name: str = "latex/basic.tex",
126
+ ) -> LatexRenderResult:
127
+ """Read resume data and render it to a LaTeX string.
128
+
129
+ This function performs I/O operations: reading resume data from file system.
130
+
131
+ Args:
132
+ name: Resume name to load.
133
+ paths: Path configuration (resolved if not provided).
134
+ template_name: Template file name to render.
135
+
136
+ Returns:
137
+ LatexRenderResult with rendered tex and context.
138
+
139
+ """
140
+ resolved_paths = paths or config.resolve_paths()
141
+
142
+ # I/O operation: read resume data
143
+ data = get_content(name, paths=resolved_paths, transform_markdown=False)
144
+
145
+ return render_resume_latex_from_data(
146
+ data, paths=resolved_paths, template_name=template_name
147
+ )
148
+
149
+
150
+ def compile_tex_to_pdf(
151
+ tex_path: Path,
152
+ *,
153
+ engines: Iterable[str] = ("xelatex", "pdflatex"),
154
+ ) -> Path:
155
+ """Compile a `.tex` file to PDF using an available LaTeX engine.
156
+
157
+ This function performs I/O operations: subprocess execution and file system access.
158
+
159
+ Args:
160
+ tex_path: Path to .tex file to compile.
161
+ engines: LaTeX engines to try (in order).
162
+
163
+ Returns:
164
+ Path to generated PDF file.
165
+
166
+ Raises:
167
+ LatexCompilationError: If compilation fails.
168
+
169
+ """
170
+ # I/O: Check which LaTeX engine is available
171
+ available_engine = None
172
+ for engine in engines:
173
+ if shutil.which(engine):
174
+ available_engine = engine
175
+ break
176
+
177
+ if available_engine is None:
178
+ raise LatexCompilationError(
179
+ "No LaTeX engine found. Install xelatex or pdflatex to render PDFs."
180
+ )
181
+
182
+ tex_argument = str(tex_path) if tex_path.is_absolute() else tex_path.name
183
+
184
+ command = [
185
+ available_engine,
186
+ "-interaction=nonstopmode",
187
+ "-halt-on-error",
188
+ "-output-directory",
189
+ str(tex_path.parent.resolve()),
190
+ tex_argument,
191
+ ]
192
+
193
+ # I/O: Execute subprocess
194
+ # Bandit: command arguments are constructed from vetted engine names and paths.
195
+ result = subprocess.run( # noqa: S603 # nosec B603
196
+ command,
197
+ cwd=str(tex_path.parent),
198
+ capture_output=True,
199
+ check=False,
200
+ )
201
+
202
+ if result.returncode != 0:
203
+ log = (result.stdout or b"") + b"\n" + (result.stderr or b"")
204
+ message = f"LaTeX compilation failed with exit code {result.returncode}"
205
+ raise LatexCompilationError(
206
+ message,
207
+ log=log.decode("utf-8", errors="ignore"),
208
+ )
209
+
210
+ pdf_path = tex_path.with_suffix(".pdf")
211
+ return pdf_path
212
+
213
+
214
+ def compile_tex_to_html(
215
+ tex_path: Path,
216
+ *,
217
+ tools: Iterable[str] = ("pandoc", "htlatex"),
218
+ ) -> Path:
219
+ """Compile a `.tex` file to HTML using an available tool.
220
+
221
+ This function performs I/O operations: subprocess execution and file system access.
222
+
223
+ Args:
224
+ tex_path: Path to .tex file to compile.
225
+ tools: Conversion tools to try (in order).
226
+
227
+ Returns:
228
+ Path to generated HTML file.
229
+
230
+ Raises:
231
+ LatexCompilationError: If compilation fails.
232
+
233
+ """
234
+ html_path = tex_path.with_suffix(".html")
235
+ tex_argument = str(tex_path) if tex_path.is_absolute() else tex_path.name
236
+
237
+ last_error: LatexCompilationError | None = None
238
+
239
+ # I/O: Try each tool
240
+ for tool in tools:
241
+ if shutil.which(tool) is None:
242
+ continue
243
+
244
+ if tool == "pandoc":
245
+ command = [
246
+ tool,
247
+ tex_argument,
248
+ "-f",
249
+ "latex",
250
+ "-t",
251
+ "html5",
252
+ "-s",
253
+ "-o",
254
+ str(html_path),
255
+ ]
256
+ elif tool == "htlatex":
257
+ command = [
258
+ tool,
259
+ tex_argument,
260
+ "html",
261
+ ]
262
+ else:
263
+ continue
264
+
265
+ # I/O: Execute subprocess
266
+ # Bandit: conversion command uses whitelisted tool names and the
267
+ # rendered tex path.
268
+ result = subprocess.run( # noqa: S603 # nosec B603
269
+ command,
270
+ cwd=str(tex_path.parent),
271
+ capture_output=True,
272
+ check=False,
273
+ )
274
+
275
+ if result.returncode == 0:
276
+ # I/O: Check/rename generated file
277
+ if tool == "htlatex":
278
+ generated = tex_path.with_suffix(".html")
279
+ if generated.exists():
280
+ generated.rename(html_path)
281
+ if not html_path.exists():
282
+ html_path.write_text("", encoding="utf-8")
283
+ return html_path
284
+
285
+ log = (result.stdout or b"") + b"\n" + (result.stderr or b"")
286
+ last_error = LatexCompilationError(
287
+ f"LaTeX to HTML conversion via {tool} "
288
+ f"failed with exit code {result.returncode}",
289
+ log=log.decode("utf-8", errors="ignore"),
290
+ )
291
+ continue
292
+
293
+ if last_error is not None:
294
+ raise last_error
295
+ raise LatexCompilationError(
296
+ "No LaTeX-to-HTML tool found. Install pandoc or htlatex to render HTML output."
297
+ )
298
+
299
+
300
+ __all__ = [
301
+ "LatexCompilationError",
302
+ "LatexRenderResult",
303
+ "build_latex_context",
304
+ "compile_tex_to_html",
305
+ "compile_tex_to_pdf",
306
+ "render_resume_latex",
307
+ "render_resume_latex_from_data",
308
+ ]
@@ -0,0 +1,240 @@
1
+ """Shell layer for rendering operations with external dependencies.
2
+
3
+ This module contains all the external dependencies and rendering logic
4
+ that should be isolated from the pure core functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess # nosec B404
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ # Import internal modules that will receive injected dependencies
15
+ from simple_resume.core.effects import CopyFile, MakeDirectory
16
+ from simple_resume.core.generate import html as _html_generation
17
+ from simple_resume.core.generate import pdf as _pdf_generation
18
+ from simple_resume.core.generate.html import create_html_generator_factory
19
+ from simple_resume.core.generate.pdf import PdfGenerationConfig
20
+ from simple_resume.core.models import RenderPlan
21
+ from simple_resume.core.protocols import EffectExecutor as EffectExecutorProtocol
22
+ from simple_resume.core.protocols import TemplateLocator
23
+ from simple_resume.core.render import get_template_environment
24
+ from simple_resume.core.result import GenerationMetadata, GenerationResult
25
+ from simple_resume.shell.config import ASSETS_ROOT, TEMPLATE_LOC
26
+ from simple_resume.shell.effect_executor import EffectExecutor
27
+
28
+
29
+ def create_backend_injector(module: ModuleType, **overrides: Any) -> Any:
30
+ """Create a context manager for temporarily overriding module attributes.
31
+
32
+ This is a factory function that returns a context manager, keeping the
33
+ core module pure while allowing dependency injection in the shell layer.
34
+ """
35
+
36
+ class _BackendInjector:
37
+ def __init__(self, module: ModuleType, **overrides: Any) -> None:
38
+ self.module = module
39
+ self.overrides = overrides
40
+ self.originals: dict[str, Any] = {}
41
+
42
+ def __enter__(self) -> None:
43
+ for name, value in self.overrides.items():
44
+ self.originals[name] = getattr(self.module, name, None)
45
+ setattr(self.module, name, value)
46
+
47
+ def __exit__(
48
+ self,
49
+ exc_type: type[BaseException] | None,
50
+ exc_val: BaseException | None,
51
+ exc_tb: object,
52
+ ) -> None:
53
+ for name, original in self.originals.items():
54
+ setattr(self.module, name, original)
55
+
56
+ return _BackendInjector(module, **overrides)
57
+
58
+
59
+ def generate_pdf_with_weasyprint(
60
+ render_plan: RenderPlan,
61
+ output_path: Path,
62
+ resume_name: str,
63
+ filename: str | None = None,
64
+ effect_executor: EffectExecutorProtocol | None = None,
65
+ ) -> tuple[GenerationResult, int | None]:
66
+ """Delegate to the HTML-to-PDF backend with patchable dependencies."""
67
+
68
+ class _TemplateLocator(TemplateLocator):
69
+ def get_template_location(self) -> Path:
70
+ return TEMPLATE_LOC
71
+
72
+ locator = _TemplateLocator()
73
+ executor = effect_executor or EffectExecutor()
74
+
75
+ backend_injector = create_backend_injector(
76
+ _pdf_generation,
77
+ get_template_environment=get_template_environment,
78
+ )
79
+
80
+ with backend_injector:
81
+ config = PdfGenerationConfig(
82
+ resume_name=resume_name,
83
+ filename=filename,
84
+ template_locator=locator,
85
+ effect_executor=executor,
86
+ )
87
+ return _pdf_generation.generate_pdf_with_weasyprint(
88
+ render_plan,
89
+ output_path,
90
+ config,
91
+ )
92
+
93
+
94
+ def _get_asset_copy_effects(output_dir: Path) -> list[CopyFile | MakeDirectory]:
95
+ """Generate effects to copy CSS and font files to output directory.
96
+
97
+ This ensures HTML files work standalone without needing a base tag
98
+ or server. Assets are copied to output_dir/static/css/ and output_dir/static/fonts/.
99
+
100
+ Args:
101
+ output_dir: The directory where HTML output is being written
102
+
103
+ Returns:
104
+ List of effects to create directories and copy files
105
+
106
+ """
107
+ effects: list[CopyFile | MakeDirectory] = []
108
+ static_src = ASSETS_ROOT / "static"
109
+
110
+ # CSS files
111
+ css_src = static_src / "css"
112
+ css_dest = output_dir / "static" / "css"
113
+ effects.append(MakeDirectory(path=css_dest, parents=True))
114
+
115
+ for css_file in css_src.glob("*.css"):
116
+ effects.append(CopyFile(source=css_file, destination=css_dest / css_file.name))
117
+
118
+ # Font files
119
+ fonts_src = static_src / "fonts"
120
+ fonts_dest = output_dir / "static" / "fonts"
121
+ if fonts_src.exists():
122
+ effects.append(MakeDirectory(path=fonts_dest, parents=True))
123
+ for font_file in fonts_src.glob("*"):
124
+ if font_file.is_file():
125
+ effects.append(
126
+ CopyFile(source=font_file, destination=fonts_dest / font_file.name)
127
+ )
128
+
129
+ return effects
130
+
131
+
132
+ def generate_html_with_jinja(
133
+ render_plan: RenderPlan,
134
+ output_path: Path,
135
+ filename: str | None = None,
136
+ effect_executor: EffectExecutorProtocol | None = None,
137
+ ) -> GenerationResult:
138
+ """Render HTML via Jinja with injectable template environment."""
139
+
140
+ class _TemplateLocator(TemplateLocator):
141
+ def get_template_location(self) -> Path:
142
+ return TEMPLATE_LOC
143
+
144
+ locator = _TemplateLocator()
145
+
146
+ # Create HTML generator factory with explicit locator
147
+ html_factory = create_html_generator_factory(default_template_locator=locator)
148
+ prepare_html_func = html_factory.create_prepare_html_function()
149
+
150
+ backend_injector = create_backend_injector(
151
+ _html_generation,
152
+ get_template_environment=get_template_environment,
153
+ )
154
+
155
+ with backend_injector:
156
+ html_content, effects, metadata = prepare_html_func(
157
+ render_plan=render_plan,
158
+ output_path=output_path,
159
+ resume_name=filename or "resume",
160
+ filename=filename,
161
+ template_locator=locator,
162
+ )
163
+
164
+ # Add effects to copy static assets (CSS, fonts) to output directory
165
+ output_dir = output_path.parent
166
+ asset_effects = _get_asset_copy_effects(output_dir)
167
+ all_effects = list(effects) + asset_effects
168
+
169
+ # Execute the effects to actually create the files
170
+ executor = effect_executor or EffectExecutor()
171
+ executor.execute_many(all_effects)
172
+
173
+ # Create and return GenerationResult
174
+ return GenerationResult(
175
+ output_path=output_path,
176
+ format_type="html",
177
+ metadata=metadata,
178
+ )
179
+
180
+
181
+ def open_file_in_browser(
182
+ file_path: Path,
183
+ browser: str | None = None,
184
+ ) -> None:
185
+ """Open a file in the default or specified browser.
186
+
187
+ Args:
188
+ file_path: Path to the file to open.
189
+ browser: Optional browser command.
190
+
191
+ Returns:
192
+ None
193
+
194
+ """
195
+ if browser:
196
+ # Use specified browser command for opening the file
197
+ subprocess.Popen( # noqa: S603 # nosec B603
198
+ [browser, str(file_path)],
199
+ stdout=subprocess.DEVNULL,
200
+ stderr=subprocess.DEVNULL,
201
+ )
202
+ else:
203
+ # Use default system opener
204
+ subprocess.run( # noqa: S603 # nosec B603
205
+ ["xdg-open", str(file_path)]
206
+ if Path("/usr/bin/xdg-open").exists()
207
+ else ["open", str(file_path)]
208
+ if Path("/usr/bin/open").exists()
209
+ else ["start", str(file_path)],
210
+ check=False,
211
+ )
212
+
213
+
214
+ def create_generation_result(
215
+ output_path: Path,
216
+ format_type: str,
217
+ generation_time: float,
218
+ **metadata_kwargs: Any,
219
+ ) -> GenerationResult:
220
+ """Create a GenerationResult with metadata."""
221
+ # Explicitly construct metadata with proper types to satisfy type checkers
222
+ metadata = GenerationMetadata(
223
+ format_type=format_type,
224
+ template_name=str(metadata_kwargs.get("template_name", "unknown")),
225
+ generation_time=generation_time,
226
+ file_size=int(metadata_kwargs.get("file_size", 0)),
227
+ resume_name=str(metadata_kwargs.get("resume_name", "resume")),
228
+ palette_info=metadata_kwargs.get("palette_info"),
229
+ page_count=metadata_kwargs.get("page_count"),
230
+ )
231
+ return GenerationResult(output_path, format_type, metadata)
232
+
233
+
234
+ __all__ = [
235
+ "create_backend_injector",
236
+ "generate_pdf_with_weasyprint",
237
+ "generate_html_with_jinja",
238
+ "open_file_in_browser",
239
+ "create_generation_result",
240
+ ]