├── src └── simple_resume │ ├── py.typed │ ├── core │ ├── py.typed │ ├── paths.py │ ├── generate │ │ ├── __init__.py │ │ ├── exceptions.py │ │ └── plan.py │ ├── constants │ │ ├── files.py │ │ ├── layout.py │ │ └── colors.py │ ├── palettes │ │ ├── exceptions.py │ │ ├── __init__.py │ │ ├── data │ │ │ └── default_palettes.json │ │ ├── fetch_types.py │ │ ├── registry.py │ │ ├── common.py │ │ └── resolution.py │ ├── render │ │ ├── __init__.py │ │ └── manage.py │ ├── __init__.py │ ├── latex │ │ ├── __init__.py │ │ ├── escaping.py │ │ ├── context.py │ │ ├── types.py │ │ ├── formatting.py │ │ └── fonts.py │ ├── file_operations.py │ ├── hydration.py │ ├── skills.py │ ├── models.py │ ├── protocols.py │ └── effects.py │ ├── shell │ ├── py.typed │ ├── render │ │ └── __init__.py │ ├── palettes │ │ ├── __init__.py │ │ └── fetch.py │ ├── __init__.py │ ├── assets │ │ ├── static │ │ │ ├── fonts │ │ │ │ ├── AvenirLTStd-Book.otf │ │ │ │ ├── AvenirLTStd-Light.otf │ │ │ │ ├── AvenirLTStd-Medium.otf │ │ │ │ ├── AvenirLTStd-Roman.otf │ │ │ │ ├── AvenirLTStd-Oblique.otf │ │ │ │ └── fontawesome │ │ │ │ │ ├── Font Awesome 6 Free-Solid-900.otf │ │ │ │ │ └── Font Awesome 6 Brands-Regular-400.otf │ │ │ └── images │ │ │ │ ├── default_profile_1.jpg │ │ │ │ └── default_profile_2.png │ │ └── templates │ │ │ ├── demo.html │ │ │ └── html │ │ │ └── cover.html │ ├── runtime │ │ ├── __init__.py │ │ └── lazy.py │ ├── session │ │ ├── __init__.py │ │ └── config.py │ ├── generate │ │ └── __init__.py │ ├── cli │ │ ├── __init__.py │ │ └── palette.py │ ├── pdf_executor.py │ ├── service_locator.py │ └── config.py │ └── __init__.py ├── tests ├── __init__.py ├── unit │ ├── core │ │ ├── constants │ │ │ ├── __init__.py │ │ │ └── test_layout.py │ │ ├── latex │ │ │ └── __init__.py │ │ ├── render │ │ │ └── __init__.py │ │ ├── generate │ │ │ ├── __init__.py │ │ │ └── test_plan.py │ │ ├── test_markdown.py │ │ ├── test_config.py │ │ ├── test_hydration.py │ │ ├── test_palette_generators.py │ │ ├── test_constants.py │ │ ├── test_skills.py │ │ ├── test_palette_resolution.py │ │ └── test_file_operations.py │ ├── shell │ │ └── cli │ │ │ └── __init__.py │ ├── test_bdd_helper.py │ ├── test_api_surface.py │ ├── test_api.py │ ├── test_color_service.py │ ├── test_random_palette_demo.py │ ├── test_cli_generate_e2e.py │ └── test_palette_registry.py ├── architecture │ ├── __init__.py │ ├── conftest.py │ └── test_core_forbidden_imports.py ├── integration │ └── test_cli_editable_install.py └── bdd.py ├── .bandit ├── assets ├── preview.jpg └── preview.png ├── .markdownlint.jsonc ├── create_sample.sh ├── .markdown-link-check.json ├── .github ├── workflows │ ├── wiki-sync.yml │ ├── lint.yml │ ├── typecheck.yml │ ├── test.yml │ ├── release.yml │ ├── code-quality.yml │ └── pre-commit.yml └── FUNDING.yml ├── LICENSE ├── pytest.ini ├── wiki ├── Path-Handling-Guide.md ├── Markdown-Guide.md ├── Contributing.md ├── Workflows.md ├── Color-Schemes.md ├── Getting-Started.md └── Development-Guide.md ├── .gitignore ├── PACKAGING.md ├── sample └── input │ ├── sample_dark_sidebar.yaml │ ├── sample_contrast_demo.yaml │ └── sample_2.yaml ├── PLAN.md └── .pre-commit-config.yaml /src/simple_resume/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/simple_resume/core/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/simple_resume/shell/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for Resume project.""" 2 | -------------------------------------------------------------------------------- /tests/unit/core/constants/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for core constants.""" 2 | -------------------------------------------------------------------------------- /tests/unit/core/latex/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for core.latex modules.""" 2 | -------------------------------------------------------------------------------- /tests/unit/shell/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for shell CLI module.""" 2 | -------------------------------------------------------------------------------- /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude_dirs = .venv,.uv-cache,.git,__pycache__ 3 | skips = B101 4 | -------------------------------------------------------------------------------- /assets/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/assets/preview.jpg -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/assets/preview.png -------------------------------------------------------------------------------- /tests/unit/core/render/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for core rendering functionality.""" 2 | -------------------------------------------------------------------------------- /src/simple_resume/shell/render/__init__.py: -------------------------------------------------------------------------------- 1 | """Rendering functionality for the shell layer.""" 2 | -------------------------------------------------------------------------------- /tests/unit/core/generate/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for core generation functionality.""" 2 | -------------------------------------------------------------------------------- /src/simple_resume/shell/palettes/__init__.py: -------------------------------------------------------------------------------- 1 | """Palette-related functionality for the shell layer.""" 2 | -------------------------------------------------------------------------------- /src/simple_resume/shell/__init__.py: -------------------------------------------------------------------------------- 1 | """Shell layer for resume generation - orchestrates I/O and external dependencies.""" 2 | 3 | __all__: list[str] = [] 4 | -------------------------------------------------------------------------------- /tests/architecture/__init__.py: -------------------------------------------------------------------------------- 1 | """Architectural tests for simple-resume. 2 | 3 | These tests enforce the functional core, imperative shell pattern. 4 | """ 5 | -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/images/default_profile_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/images/default_profile_1.jpg -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/images/default_profile_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/images/default_profile_2.png -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/runtime/__init__.py: -------------------------------------------------------------------------------- 1 | """Runtime package namespace.""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.shell.runtime import lazy 6 | 7 | __all__ = ["lazy"] 8 | -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/athola/simple-resume/HEAD/src/simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf -------------------------------------------------------------------------------- /.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": false, 4 | "MD024": { 5 | "siblings_only": true 6 | }, 7 | "MD030": false, 8 | "MD033": { 9 | "allowed_elements": ["div", "span", "pre", "code"] 10 | }, 11 | "MD041": false 12 | } 13 | -------------------------------------------------------------------------------- /create_sample.sh: -------------------------------------------------------------------------------- 1 | "C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe" \ 2 | --javascript-delay 2000 \ 3 | --dpi 300 \ 4 | -T 0 \ 5 | -B 0 \ 6 | -L 0 \ 7 | -R 0 \ 8 | --disable-smart-shrinking \ 9 | --print-media-type \ 10 | localhost:5000/print.html \ 11 | assets/sample.pdf 12 | -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/templates/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ resume_name or "Resume" }} 5 | 6 | 7 | {% if content %} 8 | {{ content | safe }} 9 | {% else %} 10 |

Hello

11 | {% endif %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/simple_resume/shell/session/__init__.py: -------------------------------------------------------------------------------- 1 | """Session management for simple-resume operations.""" 2 | 3 | from simple_resume.shell.session.config import SessionConfig 4 | from simple_resume.shell.session.manage import ResumeSession, create_session 5 | 6 | __all__ = ["SessionConfig", "ResumeSession", "create_session"] 7 | -------------------------------------------------------------------------------- /.markdown-link-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | { 4 | "pattern": "^http://localhost" 5 | }, 6 | { 7 | "pattern": "^http://127.0.0.1" 8 | } 9 | ], 10 | "timeout": "5s", 11 | "retryOn429": true, 12 | "retryCount": 3, 13 | "fallbackRetryDelay": "30s", 14 | "aliveStatusCodes": [200, 206] 15 | } 16 | -------------------------------------------------------------------------------- /src/simple_resume/core/paths.py: -------------------------------------------------------------------------------- 1 | """Core filesystem path dataclasses used across the project.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Paths: 11 | """Resolved filesystem locations for resume data and assets.""" 12 | 13 | data: Path 14 | input: Path 15 | output: Path 16 | content: Path 17 | templates: Path 18 | static: Path 19 | 20 | 21 | __all__ = ["Paths"] 22 | -------------------------------------------------------------------------------- /src/simple_resume/core/generate/__init__.py: -------------------------------------------------------------------------------- 1 | """Core generation functionality for resumes. 2 | 3 | This module provides pure functions for generating different output formats 4 | from resume data without any I/O side effects. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from simple_resume.core.generate.html import create_html_generator_factory 10 | from simple_resume.core.generate.pdf import ( 11 | prepare_pdf_with_latex, 12 | prepare_pdf_with_weasyprint, 13 | ) 14 | from simple_resume.core.generate.plan import build_generation_plan 15 | 16 | __all__ = [ 17 | "build_generation_plan", 18 | "create_html_generator_factory", 19 | "prepare_pdf_with_latex", 20 | "prepare_pdf_with_weasyprint", 21 | ] 22 | -------------------------------------------------------------------------------- /tests/architecture/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest tweaks for architecture-only runs.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Sequence 6 | 7 | 8 | def _only_architecture_selected(args: Sequence[str]) -> bool: 9 | """Return True if pytest was invoked only on architecture tests.""" 10 | return bool(args) and all(arg.startswith("tests/architecture") for arg in args) 11 | 12 | 13 | def pytest_configure(config) -> None: # type: ignore[override] 14 | """Relax coverage threshold when running architecture suite alone.""" 15 | if _only_architecture_selected(getattr(config, "args", ())): 16 | if hasattr(config.option, "cov_fail_under"): 17 | config.option.cov_fail_under = 0 18 | -------------------------------------------------------------------------------- /.github/workflows/wiki-sync.yml: -------------------------------------------------------------------------------- 1 | name: Wiki Sync 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'wiki/**' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | sync-wiki: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' }} 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v5 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Sync repository wiki pages 22 | uses: Andrew-Chen-Wang/github-wiki-action@v5.0.3 23 | continue-on-error: true 24 | with: 25 | token: ${{ secrets.WIKI_TOKEN }} 26 | path: wiki 27 | repository: ${{ github.repository }} 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main, development ] 8 | 9 | jobs: 10 | ruff: 11 | name: Ruff Linting 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v2 24 | with: 25 | version: "latest" 26 | 27 | - name: Install dependencies 28 | run: uv sync --extra utils --group dev 29 | 30 | - name: Run Ruff linting 31 | run: uv run ruff check src/ tests/ 32 | 33 | - name: Run Ruff formatting check 34 | run: uv run ruff format --check src/ tests/ 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [athola] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: athola 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /src/simple_resume/core/constants/files.py: -------------------------------------------------------------------------------- 1 | """Filesystem and file-format constants for simple-resume.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Final 6 | 7 | # Supported file extensions 8 | SUPPORTED_YAML_EXTENSIONS: Final[set[str]] = {".yaml", ".yml"} 9 | SUPPORTED_YAML_EXTENSIONS_STR: Final[str] = "yaml" # For CLI usage 10 | 11 | # Default template paths 12 | DEFAULT_LATEX_TEMPLATE: Final[str] = "latex/basic.tex" 13 | 14 | # Font scaling constants 15 | FONTAWESOME_DEFAULT_SCALE: Final[float] = 0.72 16 | 17 | # Byte conversion constants 18 | BYTES_PER_KB: Final[int] = 1024 19 | BYTES_PER_MB: Final[int] = 1024 * 1024 20 | 21 | __all__ = [ 22 | "SUPPORTED_YAML_EXTENSIONS", 23 | "SUPPORTED_YAML_EXTENSIONS_STR", 24 | "DEFAULT_LATEX_TEMPLATE", 25 | "FONTAWESOME_DEFAULT_SCALE", 26 | "BYTES_PER_KB", 27 | "BYTES_PER_MB", 28 | ] 29 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Exception types used by the palette subsystem.""" 3 | 4 | from __future__ import annotations 5 | 6 | 7 | class PaletteError(RuntimeError): 8 | """Base class for palette-related failures.""" 9 | 10 | 11 | class PaletteLookupError(PaletteError): 12 | """Raised when a named palette cannot be located.""" 13 | 14 | 15 | class PaletteGenerationError(PaletteError): 16 | """Raised when a generator cannot produce the requested swatches.""" 17 | 18 | 19 | class PaletteRemoteDisabled(PaletteError): 20 | """Raised when remote palette access is disabled by configuration.""" 21 | 22 | 23 | class PaletteRemoteError(PaletteError): 24 | """Raised when a remote palette provider returns an error.""" 25 | 26 | 27 | __all__ = [ 28 | "PaletteError", 29 | "PaletteGenerationError", 30 | "PaletteLookupError", 31 | "PaletteRemoteDisabled", 32 | "PaletteRemoteError", 33 | ] 34 | -------------------------------------------------------------------------------- /src/simple_resume/shell/generate/__init__.py: -------------------------------------------------------------------------------- 1 | """High-level resume generation module in shell layer. 2 | 3 | This module provides a clean, organized interface for generating resumes 4 | in various formats. It offers both standard (eager) and lazy-loading 5 | implementations to optimize for different use cases. 6 | 7 | .. versionadded:: 0.1.0 8 | 9 | """ 10 | 11 | # Direct imports from core generation modules 12 | from simple_resume.core.generate.html import ( 13 | HtmlGeneratorFactory, 14 | create_html_generator_factory, 15 | ) 16 | from simple_resume.core.generate.pdf import ( 17 | PdfGeneratorFactory, 18 | ) 19 | 20 | # Re-export lazy loading versions for backward compatibility 21 | from simple_resume.shell.generate.lazy import ( 22 | generate, 23 | generate_all, 24 | generate_html, 25 | generate_pdf, 26 | generate_resume, 27 | preview, 28 | ) 29 | 30 | __all__ = [ 31 | "generate", 32 | "generate_all", 33 | "generate_html", 34 | "generate_pdf", 35 | "generate_resume", 36 | "preview", 37 | ] 38 | -------------------------------------------------------------------------------- /src/simple_resume/shell/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Command-line interface for simple-resume.""" 2 | 3 | from __future__ import annotations 4 | 5 | # Import the module itself to maintain test functionality 6 | from simple_resume.shell.cli import main as main_module 7 | 8 | # Import the main functions directly from the module 9 | from simple_resume.shell.cli.main import ( 10 | _build_config_overrides, 11 | _handle_unexpected_error, 12 | _run_session_generation, 13 | create_parser, 14 | handle_generate_command, 15 | handle_session_command, 16 | handle_validate_command, 17 | ) 18 | 19 | # Import main as a different name to avoid conflict 20 | from simple_resume.shell.cli.main import main as main_entry 21 | 22 | # Make the main module available for import flexibility 23 | main = main_module 24 | 25 | __all__ = [ 26 | "_build_config_overrides", 27 | "_handle_unexpected_error", 28 | "_run_session_generation", 29 | "create_parser", 30 | "handle_generate_command", 31 | "handle_session_command", 32 | "handle_validate_command", 33 | "main", 34 | "main_entry", 35 | ] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 athola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Palette discovery utilities and registries (pure core).""" 3 | 4 | from __future__ import annotations 5 | 6 | from simple_resume.core.palettes.exceptions import ( 7 | PaletteError, 8 | PaletteGenerationError, 9 | PaletteLookupError, 10 | PaletteRemoteDisabled, 11 | PaletteRemoteError, 12 | ) 13 | from simple_resume.core.palettes.fetch_types import ( 14 | PaletteFetchRequest, 15 | PaletteResolution, 16 | ) 17 | from simple_resume.core.palettes.generators import generate_hcl_palette 18 | from simple_resume.core.palettes.registry import ( 19 | Palette, 20 | PaletteRegistry, 21 | build_palette_registry, 22 | ) 23 | from simple_resume.core.palettes.resolution import resolve_palette_config 24 | 25 | __all__ = [ 26 | "Palette", 27 | "PaletteRegistry", 28 | "build_palette_registry", 29 | "generate_hcl_palette", 30 | "PaletteError", 31 | "PaletteLookupError", 32 | "PaletteGenerationError", 33 | "PaletteRemoteDisabled", 34 | "PaletteRemoteError", 35 | "PaletteFetchRequest", 36 | "PaletteResolution", 37 | "resolve_palette_config", 38 | ] 39 | -------------------------------------------------------------------------------- /tests/unit/core/test_markdown.py: -------------------------------------------------------------------------------- 1 | """Tests for core/markdown.py - markdown processing functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.core.markdown import derive_bold_color 6 | 7 | 8 | class TestDeriveBoldColor: 9 | """Tests for derive_bold_color function.""" 10 | 11 | def test_darkens_valid_hex_color(self) -> None: 12 | """Test that valid hex color is darkened.""" 13 | result = derive_bold_color("#F6F6F6") 14 | assert result.startswith("#") 15 | assert result != "#F6F6F6" # Should be darker 16 | 17 | def test_returns_default_for_none(self) -> None: 18 | """Test that None returns default bold color.""" 19 | result = derive_bold_color(None) 20 | assert result.startswith("#") 21 | 22 | def test_returns_default_for_invalid_color(self) -> None: 23 | """Test that invalid color returns default.""" 24 | result = derive_bold_color("not-a-color") 25 | assert result.startswith("#") 26 | 27 | def test_returns_default_for_non_string(self) -> None: 28 | """Test that non-string input returns default.""" 29 | result = derive_bold_color(12345) # type: ignore[arg-type] 30 | assert result.startswith("#") 31 | -------------------------------------------------------------------------------- /src/simple_resume/core/render/__init__.py: -------------------------------------------------------------------------------- 1 | """Core rendering functionality for resumes. 2 | 3 | This module provides pure functions for template rendering and coordination 4 | between different rendering backends without any I/O side effects. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from simple_resume.core.plan import ( 10 | build_render_plan, 11 | normalize_with_palette_fallback, 12 | prepare_render_data, 13 | transform_for_mode, 14 | validate_resume_config, 15 | validate_resume_config_or_raise, 16 | ) 17 | from simple_resume.core.render.manage import ( 18 | get_template_environment, 19 | prepare_html_generation_request, 20 | prepare_pdf_generation_request, 21 | validate_render_plan, 22 | ) 23 | from simple_resume.core.render.plan import ( 24 | RenderPlanConfig, 25 | ) 26 | 27 | __all__ = [ 28 | "get_template_environment", 29 | "prepare_html_generation_request", 30 | "prepare_pdf_generation_request", 31 | "validate_render_plan", 32 | "build_render_plan", 33 | "normalize_with_palette_fallback", 34 | "prepare_render_data", 35 | "RenderPlanConfig", 36 | "transform_for_mode", 37 | "validate_resume_config", 38 | "validate_resume_config_or_raise", 39 | ] 40 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.yml: -------------------------------------------------------------------------------- 1 | name: Type Checking 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main, development ] 8 | 9 | jobs: 10 | mypy: 11 | name: MyPy Type Checking 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v2 24 | with: 25 | version: "latest" 26 | 27 | - name: Install dependencies 28 | run: uv sync --extra utils --group dev 29 | 30 | - name: Run MyPy 31 | run: uv run mypy src/simple_resume/ --strict 32 | 33 | ty: 34 | name: Ty Type Checking 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - name: Set up Python 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: '3.10' 44 | 45 | - name: Install uv 46 | uses: astral-sh/setup-uv@v2 47 | with: 48 | version: "latest" 49 | 50 | - name: Install dependencies 51 | run: uv sync --extra utils --group dev 52 | 53 | - name: Run Ty 54 | run: uv run ty check src/simple_resume/ 55 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/data/default_palettes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "default", 4 | "colors": [ 5 | "#0395DE", 6 | "#F6F6F6", 7 | "#DFDFDF", 8 | "#616161", 9 | "#757575" 10 | ], 11 | "source": "default", 12 | "metadata": { 13 | "description": "Professional blue default palette" 14 | } 15 | }, 16 | { 17 | "name": "modern_teal", 18 | "colors": [ 19 | "#0891B2", 20 | "#F0FDFA", 21 | "#CCFBF1", 22 | "#134E4A", 23 | "#0E7490" 24 | ], 25 | "source": "default", 26 | "metadata": { 27 | "description": "Default teal palette" 28 | } 29 | }, 30 | { 31 | "name": "ocean", 32 | "colors": [ 33 | "#005B96", 34 | "#E6F7FF", 35 | "#A7C6ED", 36 | "#013A63", 37 | "#0A2463" 38 | ], 39 | "source": "default", 40 | "metadata": { 41 | "description": "Classic ocean blues used by legacy templates" 42 | } 43 | }, 44 | { 45 | "name": "ocean_blue", 46 | "colors": [ 47 | "#0275D8", 48 | "#F0F8FF", 49 | "#CFE2FF", 50 | "#023E8A", 51 | "#03045E" 52 | ], 53 | "source": "default", 54 | "metadata": { 55 | "description": "Ocean-inspired palette for CLI demos" 56 | } 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /src/simple_resume/shell/session/config.py: -------------------------------------------------------------------------------- 1 | """Session configuration for simple-resume operations.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass, field 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from simple_resume.core.constants import OutputFormat 10 | from simple_resume.core.exceptions import ConfigurationError 11 | from simple_resume.core.paths import Paths 12 | 13 | 14 | @dataclass 15 | class SessionConfig: 16 | """Configuration for a `ResumeSession`.""" 17 | 18 | paths: Paths | None = None 19 | default_template: str | None = None 20 | default_palette: str | None = None 21 | default_format: OutputFormat | str = OutputFormat.PDF 22 | auto_open: bool = False 23 | preview_mode: bool = False 24 | output_dir: Path | None = None 25 | # Additional session-wide settings 26 | session_metadata: dict[str, Any] = field(default_factory=dict) 27 | 28 | def __post_init__(self) -> None: 29 | """Normalize enum-backed fields.""" 30 | try: 31 | self.default_format = OutputFormat.normalize(self.default_format) 32 | except (ValueError, TypeError) as exc: 33 | raise ConfigurationError( 34 | f"Invalid default format: {self.default_format}" 35 | ) from exc 36 | -------------------------------------------------------------------------------- /tests/unit/core/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from simple_resume.core.config import finalize_config, prepare_config 4 | 5 | 6 | def test_prepare_config_coerces_numeric_fields() -> None: 7 | config = { 8 | "page_width": "210", 9 | "page_height": "297", 10 | "sidebar_width": "50", 11 | } 12 | 13 | sidebar_locked = prepare_config(config, filename="resume.yaml") 14 | 15 | assert sidebar_locked is False 16 | width = config["page_width"] 17 | height = config["page_height"] 18 | sidebar = config["sidebar_width"] 19 | assert isinstance(width, int) and width == 210 20 | assert isinstance(height, int) and height == 297 21 | assert isinstance(sidebar, int) and sidebar == 50 22 | 23 | 24 | def test_finalize_config_populates_default_colors() -> None: 25 | config = { 26 | "theme_color": "#111111", 27 | "sidebar_color": "#FFFFFF", 28 | "frame_color": "#222222", 29 | } 30 | 31 | finalize_config(config, filename="resume.yaml", sidebar_text_locked=False) 32 | 33 | assert config["color_scheme"] == "default" 34 | assert config["sidebar_text_color"] in {"#000000", "#333333"} 35 | assert config["heading_icon_color"] 36 | assert config["sidebar_bold_color"] 37 | assert config["bold_color"] 38 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # Pytest configuration for Simple-Resume project following TDD principles 3 | 4 | # Test discovery 5 | testpaths = tests 6 | python_files = test_*.py *_test.py 7 | python_classes = Test* 8 | python_functions = test_* 9 | 10 | # Output configuration 11 | addopts = 12 | --strict-markers 13 | --strict-config 14 | --verbose 15 | --tb=short 16 | --cov=src/simple_resume 17 | --cov-report=term-missing 18 | --cov-report=html:htmlcov 19 | --cov-report=xml 20 | --cov-fail-under=85 21 | --durations=10 22 | 23 | # Markers for test categorization (TDD methodology) 24 | markers = 25 | unit: Unit tests for individual components (fast, isolated) 26 | integration: Integration tests for component interaction 27 | business: Business logic and user story tests 28 | slow: Tests that take longer to run (>1s) 29 | network: Tests that require network access 30 | tdd: Test-driven development tests (red-green-refactor) 31 | regression: Regression tests for previously fixed issues 32 | smoke: Quick smoke tests to verify basic functionality 33 | 34 | # Minimum version requirements 35 | minversion = 8.0 36 | 37 | # Filtering options 38 | filterwarnings = 39 | ignore::DeprecationWarning 40 | ignore::PendingDeprecationWarning 41 | 42 | # Test session configuration 43 | console_output_style = progress 44 | log_cli = false 45 | log_cli_level = INFO 46 | -------------------------------------------------------------------------------- /src/simple_resume/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core resume data transformations - pure functions without side effects.""" 2 | 3 | from simple_resume.core.file_operations import ( 4 | find_yaml_files, 5 | get_resume_name_from_path, 6 | iterate_yaml_files, 7 | ) 8 | from simple_resume.core.models import ( 9 | RenderMode, 10 | RenderPlan, 11 | ResumeConfig, 12 | ValidationResult, 13 | ) 14 | from simple_resume.core.plan import ( 15 | build_render_plan, 16 | normalize_with_palette_fallback, 17 | prepare_render_data, 18 | transform_for_mode, 19 | validate_resume_config, 20 | validate_resume_config_or_raise, 21 | ) 22 | from simple_resume.core.render import ( 23 | prepare_html_generation_request, 24 | prepare_pdf_generation_request, 25 | validate_render_plan, 26 | ) 27 | from simple_resume.core.resume import Resume 28 | 29 | __all__ = [ 30 | "Resume", 31 | "ResumeConfig", 32 | "RenderPlan", 33 | "ValidationResult", 34 | "RenderMode", 35 | "find_yaml_files", 36 | "get_resume_name_from_path", 37 | "iterate_yaml_files", 38 | "build_render_plan", 39 | "normalize_with_palette_fallback", 40 | "prepare_render_data", 41 | "transform_for_mode", 42 | "validate_resume_config", 43 | "validate_resume_config_or_raise", 44 | "prepare_html_generation_request", 45 | "prepare_pdf_generation_request", 46 | "validate_render_plan", 47 | ] 48 | -------------------------------------------------------------------------------- /tests/unit/test_bdd_helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from tests.bdd import scenario 6 | 7 | 8 | class TestScenarioHelper: 9 | def test_summary_includes_all_sections(self) -> None: 10 | story = scenario("Render palette list") 11 | story.given("an installed palette registry") 12 | story.when("the CLI lists palettes") 13 | story.then("the palette names are printed") 14 | story.background(data_dir="fixtures/palettes", palette_count=42) 15 | story.note("Covers CLI happy path") 16 | 17 | summary = story.summary() 18 | 19 | assert "Scenario: Render palette list" in summary 20 | assert "Given:" in summary and "When:" in summary and "Then:" in summary 21 | assert "Notes:" in summary and "Context:" in summary 22 | assert "palette_count: 42" in summary 23 | 24 | def test_expect_raises_with_contextual_summary(self) -> None: 25 | story = scenario("Handle invalid palette") 26 | story.given("an unknown palette name") 27 | story.when("the registry lookup runs") 28 | 29 | with pytest.raises(AssertionError) as excinfo: 30 | story.expect(False, "Palette lookup should fail") 31 | 32 | message = str(excinfo.value) 33 | assert "Palette lookup should fail" in message 34 | assert "Scenario: Handle invalid palette" in message 35 | assert "Given:" in message and "When:" in message 36 | -------------------------------------------------------------------------------- /tests/unit/test_api_surface.py: -------------------------------------------------------------------------------- 1 | """API surface contract tests.""" 2 | 3 | from __future__ import annotations 4 | 5 | import simple_resume 6 | from tests.bdd import Scenario 7 | 8 | EXPECTED_PUBLIC_SYMBOLS = { 9 | "__version__", 10 | # Core models (data only) 11 | "ResumeConfig", 12 | "RenderPlan", 13 | # Exceptions 14 | "SimpleResumeError", 15 | "ValidationError", 16 | "ConfigurationError", 17 | "TemplateError", 18 | "GenerationError", 19 | "PaletteError", 20 | "FileSystemError", 21 | "SessionError", 22 | # Results & sessions 23 | "GenerationResult", 24 | "GenerationMetadata", 25 | "BatchGenerationResult", 26 | "ResumeSession", 27 | "SessionConfig", 28 | "create_session", 29 | # Generation primitives 30 | "GenerationConfig", 31 | "generate_pdf", 32 | "generate_html", 33 | "generate_all", 34 | "generate_resume", 35 | # Shell layer I/O functions 36 | "to_pdf", 37 | "to_html", 38 | "resume_generate", 39 | # Convenience helpers 40 | "generate", 41 | "preview", 42 | } 43 | 44 | 45 | def test_public_api_surface_matches_reference(story: Scenario) -> None: 46 | story.given("the curated __all__ list defines the stable API surface") 47 | 48 | story.when("reading simple_resume.__all__") 49 | exported = set(simple_resume.__all__) 50 | 51 | story.then("the exported symbols match the reference list exactly") 52 | assert exported == EXPECTED_PUBLIC_SYMBOLS 53 | for symbol in exported: 54 | assert hasattr(simple_resume, symbol) 55 | -------------------------------------------------------------------------------- /src/simple_resume/shell/pdf_executor.py: -------------------------------------------------------------------------------- 1 | """PDF execution for the shell layer. 2 | 3 | This module provides functions to execute PDF generation 4 | effects created by the core layer. 5 | It implements the "imperative shell" pattern, 6 | performing actual I/O operations. 7 | """ 8 | 9 | from pathlib import Path 10 | 11 | from simple_resume.core.effects import Effect 12 | from simple_resume.core.result import GenerationMetadata, GenerationResult 13 | from simple_resume.shell.effect_executor import EffectExecutor 14 | 15 | 16 | def execute_pdf_generation( 17 | pdf_content: bytes, 18 | effects: list[Effect], 19 | output_path: Path, 20 | metadata: GenerationMetadata, 21 | ) -> GenerationResult: 22 | """Execute PDF generation by running effects and returning result. 23 | 24 | This function performs I/O operations by executing the provided effects. 25 | It uses the EffectExecutor to run all effects in sequence. 26 | 27 | Args: 28 | pdf_content: PDF content as bytes (for reference/validation) 29 | effects: List of effects to execute (MakeDirectory, WriteFile, etc.) 30 | output_path: Target PDF file path 31 | metadata: Generation metadata to include in result 32 | 33 | Returns: 34 | GenerationResult with output path and metadata 35 | 36 | Raises: 37 | Various I/O exceptions from effect execution 38 | 39 | """ 40 | # Create executor and run all effects 41 | executor = EffectExecutor() 42 | executor.execute_many(effects) 43 | 44 | # Return result 45 | return GenerationResult( 46 | output_path=output_path, 47 | format_type="pdf", 48 | metadata=metadata, 49 | ) 50 | 51 | 52 | __all__ = ["execute_pdf_generation"] 53 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/fetch_types.py: -------------------------------------------------------------------------------- 1 | """Palette request and response types for pure core operations. 2 | 3 | These types allow core functions to describe what network operations 4 | are needed without actually performing them, keeping the core pure. 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from typing import Any 9 | 10 | 11 | @dataclass(frozen=True) 12 | class PaletteFetchRequest: 13 | """Request to fetch palette from remote source. 14 | 15 | This describes a network operation that should be executed by the shell layer. 16 | The core layer creates these requests but never executes them directly. 17 | """ 18 | 19 | source: str # e.g., "colourlovers" 20 | keywords: list[str] | None = None 21 | num_results: int = 1 22 | order_by: str = "score" 23 | 24 | 25 | @dataclass(frozen=True) 26 | class PaletteResolution: 27 | """Result of palette resolution - either colors or fetch request. 28 | 29 | This represents the result of pure palette resolution logic. 30 | It either contains resolved colors (for local sources) or a 31 | fetch request (for remote sources) that the shell should execute. 32 | """ 33 | 34 | colors: list[str] | None = None 35 | metadata: dict[str, Any] | None = None 36 | fetch_request: PaletteFetchRequest | None = None 37 | 38 | @property 39 | def needs_fetch(self) -> bool: 40 | """Check if this resolution requires network fetching.""" 41 | return self.fetch_request is not None 42 | 43 | @property 44 | def has_colors(self) -> bool: 45 | """Check if this resolution already contains colors.""" 46 | return self.colors is not None and len(self.colors) > 0 47 | 48 | 49 | __all__ = [ 50 | "PaletteFetchRequest", 51 | "PaletteResolution", 52 | ] 53 | -------------------------------------------------------------------------------- /src/simple_resume/core/constants/layout.py: -------------------------------------------------------------------------------- 1 | """Layout and measurement constants for simple-resume.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Final 6 | 7 | # Default page dimensions in millimeters 8 | DEFAULT_PAGE_WIDTH_MM: Final[int] = 190 9 | DEFAULT_PAGE_HEIGHT_MM: Final[int] = 270 10 | 11 | # Default sidebar width in millimeters 12 | DEFAULT_SIDEBAR_WIDTH_MM: Final[int] = 60 13 | 14 | # Default padding values in points/millimeters 15 | DEFAULT_PADDING: Final[int] = 12 16 | DEFAULT_SIDEBAR_PADDING_ADJUSTMENT: Final[int] = -2 17 | DEFAULT_SIDEBAR_PADDING: Final[int] = 12 18 | 19 | # Frame padding values 20 | DEFAULT_FRAME_PADDING: Final[int] = 10 21 | 22 | # Cover letter specific padding 23 | DEFAULT_COVER_PADDING_TOP: Final[int] = 10 24 | DEFAULT_COVER_PADDING_BOTTOM: Final[int] = 20 25 | DEFAULT_COVER_PADDING_HORIZONTAL: Final[int] = 25 26 | 27 | # Validation constraints 28 | MIN_PAGE_WIDTH_MM: Final[int] = 100 29 | MAX_PAGE_WIDTH_MM: Final[int] = 300 30 | MIN_PAGE_HEIGHT_MM: Final[int] = 150 31 | MAX_PAGE_HEIGHT_MM: Final[int] = 400 32 | 33 | MIN_SIDEBAR_WIDTH_MM: Final[int] = 30 34 | MAX_SIDEBAR_WIDTH_MM: Final[int] = 100 35 | 36 | MIN_PADDING: Final[int] = 0 37 | MAX_PADDING: Final[int] = 50 38 | 39 | __all__ = [ 40 | "DEFAULT_PAGE_WIDTH_MM", 41 | "DEFAULT_PAGE_HEIGHT_MM", 42 | "DEFAULT_SIDEBAR_WIDTH_MM", 43 | "DEFAULT_PADDING", 44 | "DEFAULT_SIDEBAR_PADDING_ADJUSTMENT", 45 | "DEFAULT_SIDEBAR_PADDING", 46 | "DEFAULT_FRAME_PADDING", 47 | "DEFAULT_COVER_PADDING_TOP", 48 | "DEFAULT_COVER_PADDING_BOTTOM", 49 | "DEFAULT_COVER_PADDING_HORIZONTAL", 50 | "MIN_PAGE_WIDTH_MM", 51 | "MAX_PAGE_WIDTH_MM", 52 | "MIN_PAGE_HEIGHT_MM", 53 | "MAX_PAGE_HEIGHT_MM", 54 | "MIN_SIDEBAR_WIDTH_MM", 55 | "MAX_SIDEBAR_WIDTH_MM", 56 | "MIN_PADDING", 57 | "MAX_PADDING", 58 | ] 59 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/__init__.py: -------------------------------------------------------------------------------- 1 | """Core LaTeX functionality (pure, deterministic, no side effects). 2 | 3 | This package contains pure business logic for LaTeX document generation. 4 | All functions are deterministic and have no side effects (no file I/O, 5 | no network access, no randomness). 6 | 7 | The shell layer (simple_resume.shell.render.latex) handles all I/O operations 8 | including template loading, file system access, and LaTeX compilation. 9 | """ 10 | 11 | from simple_resume.core.latex.context import build_latex_context_pure 12 | from simple_resume.core.latex.conversion import ( 13 | collect_blocks, 14 | convert_inline, 15 | normalize_iterable, 16 | ) 17 | from simple_resume.core.latex.escaping import escape_latex, escape_url 18 | from simple_resume.core.latex.fonts import fontawesome_support_block 19 | from simple_resume.core.latex.formatting import format_date, linkify 20 | from simple_resume.core.latex.sections import ( 21 | build_contact_lines, 22 | prepare_sections, 23 | prepare_skill_sections, 24 | ) 25 | from simple_resume.core.latex.types import ( 26 | Block, 27 | LatexEntry, 28 | LatexRenderResult, 29 | LatexSection, 30 | ListBlock, 31 | ParagraphBlock, 32 | ) 33 | 34 | __all__ = [ 35 | # Types 36 | "Block", 37 | "LatexEntry", 38 | "LatexRenderResult", 39 | "LatexSection", 40 | "ListBlock", 41 | "ParagraphBlock", 42 | # Escaping 43 | "escape_latex", 44 | "escape_url", 45 | # Conversion 46 | "collect_blocks", 47 | "convert_inline", 48 | "normalize_iterable", 49 | # Formatting 50 | "format_date", 51 | "linkify", 52 | # Sections 53 | "build_contact_lines", 54 | "prepare_sections", 55 | "prepare_skill_sections", 56 | # Context 57 | "build_latex_context_pure", 58 | # Fonts 59 | "fontawesome_support_block", 60 | ] 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main, development ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v2 23 | with: 24 | version: "latest" 25 | 26 | - name: Install dependencies 27 | run: uv sync --extra utils --group dev 28 | 29 | - name: Verify sdist contains sources and templates 30 | run: | 31 | set -euo pipefail 32 | rm -f dist/simple_resume-*.tar.gz 33 | UV_CACHE_DIR=.uv-cache uv build --sdist >/dev/null 34 | TARBALL=$(ls -t dist/simple_resume-*.tar.gz | head -n1) 35 | tar -tf "$TARBALL" > /tmp/sdist_files.txt 36 | grep -q "simple_resume-.*/simple_resume/shell/cli/main.py" /tmp/sdist_files.txt 37 | grep -q "simple_resume-.*/simple_resume/shell/assets/templates/html/resume_no_bars.html" /tmp/sdist_files.txt 38 | echo "sdist contains required source files and templates" 39 | 40 | - name: Run tests 41 | run: uv run pytest 42 | 43 | architecture: 44 | runs-on: ubuntu-latest 45 | name: Architecture Tests 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Set up Python 51 | uses: actions/setup-python@v4 52 | with: 53 | python-version: '3.10' 54 | 55 | - name: Install uv 56 | uses: astral-sh/setup-uv@v2 57 | with: 58 | version: "latest" 59 | 60 | - name: Install dependencies 61 | run: uv sync --extra utils --group dev 62 | 63 | - name: Run architectural tests 64 | run: uv run pytest tests/architecture -v --tb=short --cov-fail-under=0 65 | -------------------------------------------------------------------------------- /src/simple_resume/core/file_operations.py: -------------------------------------------------------------------------------- 1 | """Core file operations for resume management. 2 | 3 | Pure functions for file discovery and path operations without external dependencies. 4 | """ 5 | 6 | from collections.abc import Generator 7 | from pathlib import Path 8 | 9 | 10 | def find_yaml_files(input_dir: Path, pattern: str = "*") -> list[Path]: 11 | """Find YAML files matching the given pattern. 12 | 13 | Args: 14 | input_dir: Directory to search for YAML files. 15 | pattern: Glob pattern for matching files. 16 | 17 | Returns: 18 | List of matching YAML file paths. 19 | 20 | """ 21 | if not input_dir.exists(): 22 | return [] 23 | 24 | yaml_files = [] 25 | 26 | # Find files matching pattern with .yaml/.yml extension 27 | for file_path in input_dir.glob(f"{pattern}.yaml"): 28 | if file_path.is_file(): 29 | yaml_files.append(file_path) 30 | for file_path in input_dir.glob(f"{pattern}.yml"): 31 | if file_path.is_file(): 32 | yaml_files.append(file_path) 33 | 34 | return sorted(yaml_files) 35 | 36 | 37 | def iterate_yaml_files( 38 | input_dir: Path, pattern: str = "*" 39 | ) -> Generator[Path, None, None]: 40 | """Iterate over YAML files matching the given pattern. 41 | 42 | Args: 43 | input_dir: Directory to search for YAML files. 44 | pattern: Glob pattern for matching files. 45 | 46 | Yields: 47 | YAML file paths. 48 | 49 | """ 50 | yield from find_yaml_files(input_dir, pattern) 51 | 52 | 53 | def get_resume_name_from_path(file_path: Path) -> str: 54 | """Extract resume name from file path. 55 | 56 | Args: 57 | file_path: Path to YAML file. 58 | 59 | Returns: 60 | Resume name (filename without extension). 61 | 62 | """ 63 | return file_path.stem 64 | 65 | 66 | __all__ = [ 67 | "find_yaml_files", 68 | "iterate_yaml_files", 69 | "get_resume_name_from_path", 70 | ] 71 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/escaping.py: -------------------------------------------------------------------------------- 1 | """LaTeX escaping functions (pure, no side effects).""" 2 | 3 | from __future__ import annotations 4 | 5 | LATEX_SPECIAL_CHARS = { 6 | "\\": r"\textbackslash{}", 7 | "&": r"\&", 8 | "%": r"\%", 9 | "$": r"\$", 10 | "#": r"\#", 11 | "_": r"\_", 12 | "{": r"\{", 13 | "}": r"\}", 14 | "~": r"\textasciitilde{}", 15 | "^": r"\textasciicircum{}", 16 | } 17 | 18 | 19 | def escape_latex(text: str) -> str: 20 | r"""Escape LaTeX special characters. 21 | 22 | This is a pure function that transforms a string by escaping characters 23 | that have special meaning in LaTeX. 24 | 25 | Args: 26 | text: The input string to escape. 27 | 28 | Returns: 29 | The escaped string safe for use in LaTeX documents. 30 | 31 | Examples: 32 | >>> escape_latex("Price: $50 & up") 33 | 'Price: \\$50 \\& up' 34 | >>> escape_latex("file_name") 35 | 'file\\_name' 36 | 37 | """ 38 | return "".join(LATEX_SPECIAL_CHARS.get(char, char) for char in text) 39 | 40 | 41 | def escape_url(url: str) -> str: 42 | r"""Escape characters in URLs that break LaTeX hyperlinks. 43 | 44 | This is a pure function that escapes only the subset of characters 45 | that cause issues in LaTeX URLs (fewer than general LaTeX escaping). 46 | 47 | Args: 48 | url: The URL to escape. 49 | 50 | Returns: 51 | The escaped URL safe for use in LaTeX \\href commands. 52 | 53 | Examples: 54 | >>> escape_url("https://example.com?q=test&foo=bar") 55 | 'https://example.com?q=test\\&foo=bar' 56 | >>> escape_url("https://example.com/page#section") 57 | 'https://example.com/page\\#section' 58 | 59 | """ 60 | replacements = {"%": r"\%", "#": r"\#", "&": r"\&", "_": r"\_"} 61 | return "".join(replacements.get(char, char) for char in url) 62 | 63 | 64 | __all__ = [ 65 | "LATEX_SPECIAL_CHARS", 66 | "escape_latex", 67 | "escape_url", 68 | ] 69 | -------------------------------------------------------------------------------- /wiki/Path-Handling-Guide.md: -------------------------------------------------------------------------------- 1 | # Path Handling 2 | 3 | `simple-resume` adopts a **path-first principle** for consistent path handling. 4 | 5 | 1. Accept `str | Path` at API boundaries for flexibility. 6 | 2. Normalize to `Path` objects immediately after receiving input. 7 | 3. Use `Path` objects internally throughout the codebase. 8 | 4. Convert to `str` only when required by an external API. 9 | 10 | ### When to Convert to a String 11 | 12 | Convert a `Path` object to a string only when an external library requires it. 13 | 14 | - **External libraries**: Some external libraries, such as WeasyPrint, require string paths (e.g., `write_pdf(str(output_path))`). 15 | - **Exception messages**: A `Path` object automatically converts to a string when used in an exception message. 16 | - **Error messages**: Use a `Path` object directly in an f-string when building an error message for display. 17 | 18 | ### Examples 19 | 20 | ```python 21 | # Good: Accept both `str` and `Path` objects, and normalize early. 22 | def process_file(file_path: str | Path) -> None: 23 | path = Path(file_path) # Normalize immediately. 24 | path.read_text() # Use Path methods. 25 | 26 | 27 | # Good: Pass `Path` objects directly. 28 | output_path = paths.output / "resume.pdf" 29 | resume.to_pdf(output_path) # No conversion is needed. 30 | 31 | # Good: Convert to a string only when an external API requires it. 32 | html_doc.write_pdf(str(output_path)) # WeasyPrint requires a string. 33 | 34 | # Bad: Unnecessary conversions. 35 | output_path = str(paths.output) + "/resume.pdf" # Use the / operator instead. 36 | raise Error(f"Failed: {str(output_path)}") # `Path` objects work in f-strings. 37 | ``` 38 | 39 | ### The `Paths` Dataclass 40 | 41 | The `Paths` dataclass stores all paths as `Path` objects. It is immutable (`frozen=True`), type-safe, and supports all `pathlib` operations. 42 | 43 | ```python 44 | paths = resolve_paths(data_dir="resume_private") 45 | output_file = paths.output / "resume.pdf" # Path operations 46 | ``` 47 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/context.py: -------------------------------------------------------------------------------- 1 | """LaTeX context building functions (pure version without I/O).""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from simple_resume.core.latex.conversion import collect_blocks, convert_inline 8 | from simple_resume.core.latex.sections import ( 9 | build_contact_lines, 10 | prepare_sections, 11 | prepare_skill_sections, 12 | ) 13 | 14 | 15 | def build_latex_context_pure(data: dict[str, Any]) -> dict[str, Any]: 16 | """Prepare the LaTeX template context from raw resume data (pure version). 17 | 18 | This is the pure, core version of context building that does NOT perform 19 | any file system operations. The fontawesome_block is set to None and must 20 | be added by the shell layer which has access to the file system. 21 | 22 | Args: 23 | data: Raw resume data dictionary. 24 | 25 | Returns: 26 | Dictionary of context variables for LaTeX template rendering. 27 | Note: fontawesome_block will be None and must be set by shell layer. 28 | 29 | Examples: 30 | >>> data = {"full_name": "John Doe", "job_title": "Engineer"} 31 | >>> context = build_latex_context_pure(data) 32 | >>> context["full_name"] 33 | 'John Doe' 34 | >>> context["headline"] 35 | 'Engineer' 36 | 37 | """ 38 | full_name = convert_inline(str(data.get("full_name", ""))) 39 | headline = data.get("job_title") 40 | rendered_headline = convert_inline(str(headline)) if headline else None 41 | summary_blocks = collect_blocks(data.get("description")) 42 | 43 | return { 44 | "full_name": full_name, 45 | "headline": rendered_headline, 46 | "contact_lines": build_contact_lines(data), 47 | "summary_blocks": summary_blocks, 48 | "skill_sections": prepare_skill_sections(data), 49 | "sections": prepare_sections(data), 50 | "fontawesome_block": None, # Must be set by shell layer 51 | } 52 | 53 | 54 | __all__ = [ 55 | "build_latex_context_pure", 56 | ] 57 | -------------------------------------------------------------------------------- /tests/unit/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for the core color utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.core import colors 6 | from tests.bdd import Scenario 7 | 8 | 9 | class TestCoreColors: 10 | """Test the core color utilities module.""" 11 | 12 | def test_colors_module_import(self, story: Scenario) -> None: 13 | """Test that core.colors can be imported.""" 14 | story.given("the colors module is part of the public API") 15 | story.when("importing the module") 16 | assert colors is not None 17 | 18 | def test_colors_all_exports_available(self, story: Scenario) -> None: 19 | """Test that all exported symbols are available.""" 20 | story.given("__all__ defines expected color helpers") 21 | story.when("inspecting the module attributes") 22 | # Verify all exports from __all__ are available 23 | assert hasattr(colors, "calculate_luminance") 24 | assert hasattr(colors, "calculate_contrast_ratio") 25 | assert hasattr(colors, "get_contrasting_text_color") 26 | assert hasattr(colors, "is_valid_color") 27 | assert hasattr(colors, "hex_to_rgb") 28 | assert hasattr(colors, "darken_color") 29 | 30 | def test_colors_functions_work(self, story: Scenario) -> None: 31 | """Test that imported functions work correctly.""" 32 | story.given("the color helper functions are invoked with sample values") 33 | story.when("validating luminance and contrast helpers") 34 | # Test is_valid_color 35 | assert colors.is_valid_color("#FF0000") is True 36 | assert colors.is_valid_color("invalid") is False 37 | 38 | # Test get_contrasting_text_color - returns a valid hex color 39 | result = colors.get_contrasting_text_color("#000000") 40 | assert colors.is_valid_color(result) is True 41 | 42 | # Test calculate_luminance 43 | luminance = colors.calculate_luminance("#808080") 44 | assert isinstance(luminance, float) 45 | assert 0 <= luminance <= 1 46 | -------------------------------------------------------------------------------- /wiki/Markdown-Guide.md: -------------------------------------------------------------------------------- 1 | # Markdown Guide 2 | 3 | This guide explains how to use Markdown to format the content of your resume. 4 | 5 | ## Supported Features 6 | 7 | The following Markdown features are supported: 8 | 9 | - **Bold** 10 | - *Italic* 11 | - Links 12 | - Headers 13 | - Fenced code blocks 14 | - Tables 15 | - Bulleted lists 16 | 17 | ## Formatting the Projects Section 18 | 19 | The `Projects` section is for showcasing personal or open-source work. 20 | 21 | ### Example 22 | 23 | ```yaml 24 | Projects: 25 | - 26 | # The start and end dates of the project. 27 | start: "" 28 | end: 2024 29 | # The title of the project. 30 | title: "My Awesome Project" 31 | # A link to the project's repository or website. 32 | title_link: "https://github.com/username/repo-name" 33 | # The name of the company or organization. 34 | company: "Personal Project" 35 | # A link to the company's website. 36 | company_link: "https://github.com/username" 37 | # A description of the project. 38 | description: | 39 | Reduced latency by 75% by implementing a caching layer. 40 | 41 | - Developed a Python script to process and aggregate data. 42 | - Deployed the application using Docker and Kubernetes. 43 | - **Tech Stack:** Python, Docker, Kubernetes 44 | ``` 45 | 46 | ### Recommendations 47 | 48 | - Quantify your results (e.g., "Reduced latency by 75%"). 49 | - Include links to code repositories or live demos. 50 | - Highlight the technologies you used with a "Tech Stack" line. 51 | 52 | ## General Formatting Tips 53 | 54 | - Use code blocks with language identifiers (e.g., `python`, `javascript`) to enable syntax highlighting in the HTML version of your resume. 55 | - Quantify your accomplishments with numbers (e.g., "Reduced latency by 45%", "Increased revenue by $200K"). 56 | - Begin bullet points with action verbs. 57 | 58 | ## Implementation Details 59 | 60 | This project uses the 'markdown' Python library to render Markdown. We enable the 'fenced_code', 'tables', and 'codehilite' extensions. 61 | -------------------------------------------------------------------------------- /src/simple_resume/shell/palettes/fetch.py: -------------------------------------------------------------------------------- 1 | """Shell layer palette fetching with network I/O. 2 | 3 | This module executes the network operations described by PaletteFetchRequest 4 | objects created by the pure core logic. This isolates all network I/O 5 | to the shell layer. 6 | """ 7 | 8 | from typing import Any 9 | 10 | from simple_resume.core.palettes import PaletteFetchRequest 11 | from simple_resume.core.palettes.exceptions import PaletteLookupError 12 | from simple_resume.shell.palettes.remote import ColourLoversClient 13 | 14 | 15 | def execute_palette_fetch( 16 | request: PaletteFetchRequest, 17 | ) -> tuple[list[str], dict[str, Any]]: 18 | """Shell operation - performs network I/O to fetch palette. 19 | 20 | This function executes the network operation described by the request 21 | and returns the actual color data. 22 | 23 | Args: 24 | request: Palette fetch request describing the network operation 25 | 26 | Returns: 27 | Tuple of (colors, metadata) from the remote source 28 | 29 | Raises: 30 | PaletteLookupError: If palette fetch fails or returns no results 31 | 32 | """ 33 | if request.source not in {"colourlovers", "remote"}: 34 | raise PaletteLookupError(f"Unsupported remote source: {request.source}") 35 | 36 | client = ColourLoversClient() 37 | # Convert list of keywords to comma-separated string for the API 38 | keywords_str = ",".join(request.keywords) if request.keywords else None 39 | palettes = client.fetch( 40 | keywords=keywords_str, 41 | num_results=request.num_results, 42 | order_by=request.order_by, 43 | ) 44 | 45 | if not palettes: 46 | raise PaletteLookupError(f"No palettes found for keywords: {request.keywords}") 47 | 48 | palette = palettes[0] 49 | colors = list(palette.swatches) 50 | 51 | metadata = { 52 | "source": request.source, 53 | "name": palette.name, 54 | "attribution": palette.metadata, 55 | "size": len(colors), 56 | } 57 | 58 | return colors, metadata 59 | 60 | 61 | __all__ = [ 62 | "execute_palette_fetch", 63 | ] 64 | -------------------------------------------------------------------------------- /tests/unit/test_color_service.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the color calculation service boundaries.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from simple_resume.core.colors import ColorCalculationService 8 | from simple_resume.core.constants.colors import ( 9 | DEFAULT_COLOR_SCHEME, 10 | ICON_CONTRAST_THRESHOLD, 11 | ) 12 | 13 | 14 | class TestColorCalculationService: 15 | def test_sidebar_text_color_respects_contrast(self) -> None: 16 | config = {"sidebar_color": "#000000"} 17 | assert ColorCalculationService.calculate_sidebar_text_color(config) == "#F5F5F5" 18 | 19 | def test_sidebar_text_color_falls_back_to_default(self) -> None: 20 | config = {"sidebar_color": "not-a-color"} 21 | assert ( 22 | ColorCalculationService.calculate_sidebar_text_color(config) 23 | == DEFAULT_COLOR_SCHEME["sidebar_text_color"] 24 | ) 25 | 26 | def test_heading_icon_color_prefers_theme_with_contrast(self) -> None: 27 | config = { 28 | "sidebar_color": "#F6F6F6", 29 | "theme_color": "#0000FF", 30 | } 31 | assert ( 32 | ColorCalculationService.calculate_heading_icon_color(config) 33 | == config["theme_color"] 34 | ) 35 | 36 | @pytest.mark.parametrize( 37 | "text_color,background", 38 | [ 39 | ("#000000", "#FFFFFF"), 40 | ("#FFFFFF", "#000000"), 41 | ], 42 | ) 43 | def test_ensure_color_contrast_preserves_valid_colors( 44 | self, text_color: str, background: str 45 | ) -> None: 46 | assert ( 47 | ColorCalculationService.ensure_color_contrast( 48 | background, 49 | text_color, 50 | contrast_threshold=ICON_CONTRAST_THRESHOLD, 51 | ) 52 | == text_color 53 | ) 54 | 55 | def test_ensure_color_contrast_generates_fallback(self) -> None: 56 | # Very low contrast user color should be replaced. 57 | result = ColorCalculationService.ensure_color_contrast("#FFFFFF", "#FFFFFE") 58 | assert result == "#333333" 59 | -------------------------------------------------------------------------------- /src/simple_resume/core/hydration.py: -------------------------------------------------------------------------------- 1 | """Provide pure helpers for transforming hydrated resume data.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copy 6 | from collections.abc import Mapping 7 | from typing import Any, Callable 8 | 9 | from simple_resume.core.skills import format_skill_groups 10 | 11 | NormalizeConfigFn = Callable[ 12 | [dict[str, Any], str], tuple[dict[str, Any], dict[str, Any] | None] 13 | ] 14 | RenderMarkdownFn = Callable[[dict[str, Any]], dict[str, Any]] 15 | 16 | 17 | def build_skill_group_payload(resume_data: Mapping[str, Any]) -> dict[str, Any]: 18 | """Return the computed skill group payload for sidebar sections.""" 19 | return { 20 | "expertise_groups": format_skill_groups(resume_data.get("expertise")), 21 | "programming_groups": format_skill_groups(resume_data.get("programming")), 22 | "keyskills_groups": format_skill_groups(resume_data.get("keyskills")), 23 | "certification_groups": format_skill_groups(resume_data.get("certification")), 24 | } 25 | 26 | 27 | def hydrate_resume_structure( 28 | source_yaml: dict[str, Any], 29 | *, 30 | filename: str = "", 31 | transform_markdown: bool = True, 32 | normalize_config_fn: NormalizeConfigFn, 33 | render_markdown_fn: RenderMarkdownFn, 34 | ) -> dict[str, Any]: 35 | """Return normalized resume data using injected pure helpers.""" 36 | processed_resume = copy.deepcopy(source_yaml) 37 | 38 | config = processed_resume.get("config") 39 | if isinstance(config, dict): 40 | normalized_config, palette_meta = normalize_config_fn(config, filename) 41 | processed_resume["config"] = normalized_config 42 | if palette_meta: 43 | meta = dict(processed_resume.get("meta", {})) 44 | meta["palette"] = palette_meta 45 | processed_resume["meta"] = meta 46 | 47 | if transform_markdown: 48 | processed_resume = render_markdown_fn(processed_resume) 49 | else: 50 | processed_resume.update(build_skill_group_payload(processed_resume)) 51 | 52 | return processed_resume 53 | 54 | 55 | __all__ = ["build_skill_group_payload", "hydrate_resume_structure"] 56 | -------------------------------------------------------------------------------- /src/simple_resume/core/skills.py: -------------------------------------------------------------------------------- 1 | """Provide utilities for skill data processing and formatting.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | 8 | def _coerce_items(raw_input: Any) -> list[str]: 9 | """Return a list of trimmed string items from arbitrary input.""" 10 | if raw_input is None: 11 | return [] 12 | if isinstance(raw_input, (list, tuple, set)): 13 | return [str(element).strip() for element in raw_input if str(element).strip()] 14 | return [str(raw_input).strip()] 15 | 16 | 17 | def format_skill_groups( 18 | skill_data: Any, 19 | ) -> list[dict[str, list[str] | str | None]]: 20 | """Normalize skill data into titled groups with string entries.""" 21 | groups: list[dict[str, list[str] | str | None]] = [] 22 | 23 | if skill_data is None: 24 | return groups 25 | 26 | def add_group(title: str | None, items: Any) -> None: 27 | normalized = [entry for entry in _coerce_items(items) if entry] 28 | if not normalized: 29 | return 30 | groups.append( 31 | { 32 | "title": str(title).strip() if title else None, 33 | "items": normalized, 34 | } 35 | ) 36 | 37 | if isinstance(skill_data, dict): 38 | for category_name, items in skill_data.items(): 39 | add_group(str(category_name), items) 40 | return groups 41 | 42 | if isinstance(skill_data, (list, tuple, set)): 43 | # Check if all entries are simple strings (not dicts) 44 | all_simple = all(not isinstance(entry, dict) for entry in skill_data) 45 | 46 | if all_simple: 47 | # Create a single group with all items 48 | add_group(None, list(skill_data)) 49 | else: 50 | # Mixed content: process each entry separately 51 | for entry in skill_data: 52 | if isinstance(entry, dict): 53 | for category_name, items in entry.items(): 54 | add_group(str(category_name), items) 55 | else: 56 | add_group(None, entry) 57 | return groups 58 | 59 | add_group(None, skill_data) 60 | return groups 61 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/types.py: -------------------------------------------------------------------------------- 1 | """LaTeX document data types (pure, immutable).""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import Any, ClassVar, Literal, TypedDict 8 | 9 | 10 | class ParagraphBlock(TypedDict): 11 | """Define a paragraph text block.""" 12 | 13 | kind: Literal["paragraph"] 14 | text: str 15 | 16 | 17 | class ListBlock(TypedDict): 18 | """Define a bullet or enumerated list block.""" 19 | 20 | kind: Literal["itemize", "enumerate"] 21 | items: list[str] 22 | 23 | 24 | Block = ParagraphBlock | ListBlock 25 | 26 | 27 | @dataclass(frozen=True) 28 | class LatexEntry: 29 | """Define a single entry in a resume section.""" 30 | 31 | title: str 32 | subtitle: str | None 33 | date_range: str | None 34 | blocks: list[Block] 35 | 36 | 37 | @dataclass(frozen=True) 38 | class LatexSection: 39 | """Define a top-level resume section.""" 40 | 41 | title: str 42 | entries: list[LatexEntry] 43 | 44 | 45 | @dataclass(frozen=True) 46 | class LatexRenderResult: 47 | """Define the result of a LaTeX render operation.""" 48 | 49 | tex: str 50 | context: dict[str, Any] 51 | 52 | 53 | @dataclass(frozen=True) 54 | class LatexGenerationContext: 55 | """Context object for LaTeX PDF generation, grouping related parameters.""" 56 | 57 | last_context: ClassVar[LatexGenerationContext | None] = None 58 | resume_data: dict[str, Any] | None 59 | processed_data: dict[str, Any] 60 | output_path: Path 61 | base_path: Path | str | None = None 62 | filename: str | None = None 63 | paths: Any = None 64 | metadata: Any = None 65 | 66 | def __post_init__(self) -> None: 67 | """Cache the most recent context for fallback use.""" 68 | type(self).last_context = self 69 | 70 | @property 71 | def raw_data(self) -> dict[str, Any] | None: 72 | """Backward-compatible accessor used by some tests.""" 73 | return self.resume_data 74 | 75 | 76 | __all__ = [ 77 | "Block", 78 | "LatexEntry", 79 | "LatexGenerationContext", 80 | "LatexRenderResult", 81 | "LatexSection", 82 | "ListBlock", 83 | "ParagraphBlock", 84 | ] 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # --------------------- COMMON EXCLUSIONS ------------------------------------- 2 | # Editor settings 3 | sublime.sublime-project 4 | sublime.sublime-workspace 5 | .idea 6 | 7 | # Python bytecode and caches 8 | *.py[cod] 9 | *.pyc 10 | __pycache__/ 11 | */__pycache__ 12 | .pytest_cache/ 13 | .mypy_cache/ 14 | .ruff_cache/ 15 | .pre-commit-cache/ 16 | .npm-cache/ 17 | 18 | # Jupyter checkpoints 19 | /.ipynb_checkpoints 20 | */.ipynb_checkpoints 21 | 22 | # Coverage and lint reports 23 | .coverage 24 | */.coverage 25 | cover 26 | */cover 27 | coverage.xml 28 | */coverage.xml 29 | coverage.json 30 | */coverage.json 31 | htmlcov/ 32 | */htmlcov/ 33 | nosetests.xml 34 | */nosetests.xml 35 | pylint.xml 36 | */pylint.xml 37 | .coverage* 38 | 39 | # Temporary files from build processes 40 | *.tmp 41 | *.temp 42 | 43 | # Packaging / build output 44 | build/ 45 | dist/ 46 | *.egg-info 47 | poetry.lock 48 | *.exe 49 | 50 | # --------------------- PERSONAL EXCLUSIONS ----------------------------------- 51 | /data 52 | */data 53 | 54 | # --------------------- PROJECT EXCLUSIONS ------------------------------------ 55 | # Local data that should stay private 56 | resume_private/ 57 | **/*/private_headshot.jpg 58 | 59 | # Generated samples 60 | sample/output/ 61 | resume_private/output/ 62 | 63 | # Generated output files (but keep source templates) 64 | *.pdf 65 | *.html 66 | *.tex 67 | !src/simple_resume/templates/html/*.html 68 | !src/simple_resume/templates/latex/*.tex 69 | !src/simple_resume/shell/assets/templates/html/*.html 70 | !src/simple_resume/shell/assets/templates/demo.html 71 | 72 | # Preview images (but keep main preview.jpg/png for README) 73 | *preview_new* 74 | *preview_temp* 75 | test_preview-*.png 76 | 77 | # Playground notebooks and temporary files 78 | */test.ipynb 79 | */temp.md 80 | */*/temp.md 81 | */temp.ipynb 82 | utilities/**.ipynb 83 | 84 | # Temporary scripts 85 | test_*.py 86 | !tests/**/test_*.py 87 | !tests/unit/test_*.py 88 | !tests/integration/test_*.py 89 | 90 | # Static images (except defaults) 91 | src/static/images/* 92 | !src/static/images/default_* 93 | 94 | # Miscellaneous 95 | .DS_Store 96 | *.swp 97 | commit_msg.txt 98 | bandit-report.json 99 | *.log 100 | output.json 101 | -------------------------------------------------------------------------------- /tests/unit/test_random_palette_demo.py: -------------------------------------------------------------------------------- 1 | """Tests for the random palette demo CLI helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | 7 | from oyaml import safe_load 8 | 9 | from simple_resume.shell.cli import random_palette_demo as demo 10 | from tests.bdd import Scenario 11 | 12 | 13 | def test_generate_random_yaml_produces_realistic_fields( 14 | story: Scenario, tmp_path: Path 15 | ) -> None: 16 | story.given("a template YAML with placeholder values") 17 | story.when("generating random palette demo YAML with a fixed seed") 18 | template = tmp_path / "template.yaml" 19 | template.write_text( 20 | """ 21 | full_name: Placeholder 22 | job_title: Placeholder 23 | body: 24 | Experience: 25 | - company: ExampleCorp 26 | description: "-" 27 | title: Engineer 28 | Projects: 29 | - title: Demo 30 | description: "-" 31 | """, 32 | encoding="utf-8", 33 | ) 34 | 35 | output = tmp_path / "out.yaml" 36 | demo.generate_random_yaml(output_path=output, template_path=template, seed=42) 37 | 38 | data = safe_load(output.read_text(encoding="utf-8")) 39 | assert data["full_name"] != "Placeholder" 40 | assert "@" in data["email"] 41 | assert data["config"]["color_scheme"] 42 | assert output.exists() 43 | 44 | 45 | def test_main_writes_output_with_seed( 46 | story: Scenario, tmp_path: Path, monkeypatch 47 | ) -> None: 48 | story.given("CLI entry point is invoked with output and seed arguments") 49 | story.when("random palette demo main executes") 50 | output = tmp_path / "cli.yaml" 51 | template = tmp_path / "template.yaml" 52 | template.write_text("full_name: Base\nbody: {Experience: []}\n", encoding="utf-8") 53 | 54 | monkeypatch.setenv("PYTHONWARNINGS", "ignore") # silence oyaml warnings if any 55 | monkeypatch.setattr( 56 | "sys.argv", 57 | [ 58 | "random_palette_demo", 59 | "--output", 60 | str(output), 61 | "--template", 62 | str(template), 63 | "--seed", 64 | "123", 65 | ], 66 | ) 67 | 68 | demo.main() 69 | 70 | data = safe_load(output.read_text(encoding="utf-8")) 71 | assert data["full_name"] != "Base" 72 | assert "color_scheme" in data.get("config", {}) 73 | -------------------------------------------------------------------------------- /tests/architecture/test_core_forbidden_imports.py: -------------------------------------------------------------------------------- 1 | """Guardrail test to keep core import purity metric current. 2 | 3 | Scans all core modules for forbidden imports (I/O libs and shell modules). 4 | Fails if any violations are found. Mirrors the manual snippet documented in 5 | wiki/Architecture-Guide.md to ensure CI and docs stay in sync. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import ast 11 | from pathlib import Path 12 | 13 | import pytest 14 | 15 | PACKAGE_ROOT = Path(__file__).parent.parent.parent / "src" / "simple_resume" 16 | CORE_DIR = PACKAGE_ROOT / "core" 17 | 18 | # Keep list in sync with Architecture-Guide status note 19 | FORBIDDEN = ( 20 | "weasyprint", 21 | "yaml", 22 | "requests", 23 | "urllib", 24 | "subprocess", 25 | "simple_resume.shell", 26 | ) 27 | 28 | 29 | def _scan_forbidden_imports(file_path: Path) -> set[str]: 30 | """Return matching forbidden import prefixes in the given file.""" 31 | tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path)) 32 | hits: set[str] = set() 33 | 34 | for node in ast.walk(tree): 35 | if isinstance(node, ast.Import): 36 | targets = [alias.name for alias in node.names] 37 | elif isinstance(node, ast.ImportFrom): 38 | targets = [node.module or ""] 39 | else: 40 | continue 41 | 42 | for target in targets: 43 | for forbidden in FORBIDDEN: 44 | if target == forbidden or target.startswith(forbidden + "."): 45 | hits.add(forbidden) 46 | return hits 47 | 48 | 49 | def test_core_has_no_forbidden_imports() -> None: 50 | """All core modules must avoid shell and I/O imports.""" 51 | assert CORE_DIR.exists(), "core directory must exist" 52 | py_files = list(CORE_DIR.rglob("*.py")) 53 | assert py_files, "core directory must contain Python files" 54 | 55 | violations = { 56 | path.relative_to(Path(".")).as_posix(): sorted(_scan_forbidden_imports(path)) 57 | for path in py_files 58 | if _scan_forbidden_imports(path) 59 | } 60 | 61 | if violations: 62 | details = "\n".join( 63 | f"- {path} -> {', '.join(items)}" 64 | for path, items in sorted(violations.items()) 65 | ) 66 | pytest.fail(f"Forbidden imports detected in core:\n{details}") 67 | -------------------------------------------------------------------------------- /src/simple_resume/shell/cli/palette.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Command line helpers for palette discovery.""" 3 | 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import json 8 | import sys 9 | 10 | from simple_resume.shell.palettes.loader import ( 11 | build_palettable_snapshot, 12 | get_palette_registry, 13 | ) 14 | 15 | 16 | def cmd_snapshot(args: argparse.Namespace) -> int: 17 | """Export current palettable registry snapshot to JSON.""" 18 | snapshot = build_palettable_snapshot() 19 | output = json.dumps(snapshot, indent=2) 20 | if args.output: 21 | args.output.write(output) 22 | args.output.write("\n") 23 | else: 24 | print(output) 25 | return 0 26 | 27 | 28 | def cmd_list(_: argparse.Namespace) -> int: 29 | """List all available palette names with preview colors.""" 30 | registry = get_palette_registry() 31 | for palette in registry.list(): 32 | print(f"{palette.name}: {', '.join(palette.swatches[:6])}") 33 | return 0 34 | 35 | 36 | def build_parser() -> argparse.ArgumentParser: 37 | """Build argument parser for palette CLI.""" 38 | parser = argparse.ArgumentParser(description="Palette utilities") 39 | subparsers = parser.add_subparsers(dest="command", required=True) 40 | 41 | snap = subparsers.add_parser("snapshot", help="Build palettable snapshot") 42 | snap.add_argument( 43 | "-o", 44 | "--output", 45 | type=argparse.FileType("w", encoding="utf-8"), 46 | help="Output file (defaults to stdout)", 47 | ) 48 | snap.set_defaults(func=cmd_snapshot) 49 | 50 | list_cmd = subparsers.add_parser("list", help="List available palettes") 51 | list_cmd.set_defaults(func=cmd_list) 52 | 53 | return parser 54 | 55 | 56 | def main(argv: list[str] | None = None) -> int: 57 | """Run the palette CLI with given arguments.""" 58 | parser = build_parser() 59 | args = parser.parse_args(argv) 60 | result = args.func(args) 61 | return int(result) if result is not None else 0 62 | 63 | 64 | def snapshot() -> None: 65 | """Entry point for palette-snapshot command.""" 66 | sys.exit(main(["snapshot"])) 67 | 68 | 69 | def palette_list() -> None: 70 | """Entry point for palette-list command.""" 71 | sys.exit(main(["list"])) 72 | 73 | 74 | if __name__ == "__main__": 75 | sys.exit(main()) 76 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/registry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Provide a palette registry that aggregates multiple providers.""" 3 | 4 | from __future__ import annotations 5 | 6 | import json 7 | from typing import Callable 8 | 9 | from simple_resume.core.palettes.common import Palette 10 | 11 | 12 | class PaletteRegistry: 13 | """Define an in-memory registry of named palettes.""" 14 | 15 | def __init__(self) -> None: 16 | """Initialize an empty palette registry.""" 17 | self._palettes: dict[str, Palette] = {} 18 | 19 | def register(self, palette: Palette) -> None: 20 | """Register or overwrite a palette.""" 21 | key = palette.name.lower() 22 | self._palettes[key] = palette 23 | 24 | def get(self, name: str) -> Palette: 25 | """Return a palette by name.""" 26 | key = name.lower() 27 | try: 28 | return self._palettes[key] 29 | except KeyError as exc: 30 | raise KeyError(f"Palette not found: {name}") from exc 31 | 32 | def list(self) -> list[Palette]: 33 | """Return all registered palettes sorted by name.""" 34 | return [self._palettes[key] for key in sorted(self._palettes)] 35 | 36 | def to_json(self) -> str: 37 | """Serialize the registry to JSON.""" 38 | return json.dumps([palette.to_dict() for palette in self.list()], indent=2) 39 | 40 | 41 | _CACHE_ENV = "SIMPLE_RESUME_PALETTE_CACHE" 42 | 43 | 44 | def build_palette_registry( 45 | *, 46 | default_loader: Callable[[], list[Palette]] | None = None, 47 | palettable_loader: Callable[[], list[Palette]] | None = None, 48 | ) -> PaletteRegistry: 49 | """Build a palette registry with custom loader functions. 50 | 51 | Args: 52 | default_loader: Function to load default palettes 53 | palettable_loader: Function to load palettable palettes 54 | 55 | Returns: 56 | PaletteRegistry populated with palettes from the specified loaders 57 | 58 | """ 59 | registry = PaletteRegistry() 60 | 61 | if default_loader: 62 | for palette in default_loader(): 63 | registry.register(palette) 64 | 65 | if palettable_loader: 66 | for palette in palettable_loader(): 67 | registry.register(palette) 68 | 69 | return registry 70 | 71 | 72 | __all__ = [ 73 | "Palette", 74 | "PaletteRegistry", 75 | "build_palette_registry", 76 | ] 77 | -------------------------------------------------------------------------------- /tests/unit/core/test_hydration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from simple_resume.core.hydration import ( 6 | build_skill_group_payload, 7 | hydrate_resume_structure, 8 | ) 9 | from tests.bdd import Scenario 10 | 11 | 12 | def _dummy_normalize( 13 | config: dict[str, Any], filename: str 14 | ) -> tuple[dict[str, Any], dict[str, Any] | None]: 15 | normalized = dict(config) 16 | normalized["normalized"] = True 17 | return normalized, {"source": "direct", "file": filename or "unknown"} 18 | 19 | 20 | def _dummy_render(data: dict[str, Any]) -> dict[str, Any]: 21 | rendered = dict(data) 22 | rendered["description"] = "HTML" 23 | rendered.update(build_skill_group_payload(rendered)) 24 | return rendered 25 | 26 | 27 | def test_build_skill_group_payload_formats_groups() -> None: 28 | payload = build_skill_group_payload( 29 | { 30 | "expertise": ["Python"], 31 | "programming": ["Rust"], 32 | "keyskills": ["Testing"], 33 | "certification": ["AWS"], 34 | } 35 | ) 36 | assert payload["expertise_groups"][0]["items"] == ["Python"] 37 | assert payload["programming_groups"][0]["items"] == ["Rust"] 38 | assert payload["keyskills_groups"][0]["items"] == ["Testing"] 39 | assert payload["certification_groups"][0]["items"] == ["AWS"] 40 | 41 | 42 | def test_hydrate_resume_structure_injects_palette_meta(story: Scenario) -> None: 43 | story.given("raw resume data with config and markdown fields") 44 | source = { 45 | "config": {"theme_color": "#000000"}, 46 | "description": "Intro", 47 | "expertise": ["Python"], 48 | "programming": ["Rust"], 49 | "keyskills": ["Testing"], 50 | "certification": ["AWS"], 51 | } 52 | 53 | story.when("hydrate_resume_structure runs with dummy helpers") 54 | hydrated = hydrate_resume_structure( 55 | source, 56 | filename="resume.yaml", 57 | transform_markdown=True, 58 | normalize_config_fn=_dummy_normalize, 59 | render_markdown_fn=_dummy_render, 60 | ) 61 | 62 | story.then("config is normalized and palette metadata is attached") 63 | assert hydrated["config"]["normalized"] is True 64 | assert hydrated["meta"]["palette"]["file"] == "resume.yaml" 65 | assert hydrated["description"] == "HTML" 66 | assert hydrated["expertise_groups"][0]["items"] == ["Python"] 67 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Define common types and utilities for palette modules.""" 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | from dataclasses import dataclass, field 8 | from enum import Enum 9 | from pathlib import Path 10 | 11 | _CACHE_ENV = "SIMPLE_RESUME_PALETTE_CACHE_DIR" 12 | 13 | 14 | @dataclass(frozen=True) 15 | class Palette: 16 | """Define palette metadata and resolved swatches.""" 17 | 18 | name: str 19 | swatches: tuple[str, ...] 20 | source: str 21 | metadata: dict[str, object] = field(default_factory=dict) 22 | 23 | def to_dict(self) -> dict[str, object]: 24 | """Serialize palette to a JSON-friendly structure.""" 25 | return { 26 | "name": self.name, 27 | "swatches": list(self.swatches), 28 | "source": self.source, 29 | "metadata": dict(self.metadata), 30 | } 31 | 32 | 33 | class PaletteSource(str, Enum): 34 | """Define supported palette sources for resume configuration.""" 35 | 36 | REGISTRY = "registry" 37 | GENERATOR = "generator" 38 | REMOTE = "remote" 39 | 40 | @classmethod 41 | def normalize( 42 | cls, value: str | PaletteSource | None, *, param_name: str | None = None 43 | ) -> PaletteSource: 44 | """Convert arbitrary input into a `PaletteSource` enum member.""" 45 | if value is None: 46 | return cls.REGISTRY 47 | if isinstance(value, cls): 48 | return value 49 | if not isinstance(value, str): 50 | raise TypeError( 51 | f"Palette source must be string or PaletteSource, got {type(value)}" 52 | ) 53 | 54 | normalized = value.strip().lower() 55 | try: 56 | return cls(normalized) 57 | except ValueError as exc: 58 | label = f"{param_name} " if param_name else "" 59 | supported_sources = ", ".join( 60 | sorted(member.value for member in cls.__members__.values()) 61 | ) 62 | raise ValueError( 63 | f"Unsupported {label}source: {value}. Supported sources: " 64 | f"{supported_sources}" 65 | ) from exc 66 | 67 | 68 | def get_cache_dir() -> Path: 69 | """Return palette cache directory.""" 70 | custom = os.environ.get(_CACHE_ENV) 71 | if custom: 72 | return Path(custom).expanduser() 73 | return Path.home() / ".cache" / "simple-resume" / "palettes" 74 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | create-release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v7 26 | 27 | - name: Build package 28 | run: uv build 29 | 30 | - name: Extract tag version 31 | id: tag 32 | run: | 33 | TAG=${GITHUB_REF#refs/tags/} 34 | VERSION=${TAG#v} 35 | echo "tag=$TAG" >> "$GITHUB_OUTPUT" 36 | echo "version=$VERSION" >> "$GITHUB_OUTPUT" 37 | 38 | - name: Generate changelog 39 | id: changelog 40 | run: | 41 | # Get the previous tag 42 | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 43 | 44 | if [ -z "$PREV_TAG" ]; then 45 | echo "First release - including all commits" 46 | CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) 47 | else 48 | echo "Changes since $PREV_TAG" 49 | CHANGELOG=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) 50 | fi 51 | 52 | # Save to output file 53 | echo "## Changes" > /tmp/changelog.md 54 | echo "" >> /tmp/changelog.md 55 | echo "$CHANGELOG" >> /tmp/changelog.md 56 | echo "" >> /tmp/changelog.md 57 | echo "## Installation" >> /tmp/changelog.md 58 | echo "" >> /tmp/changelog.md 59 | echo '```bash' >> /tmp/changelog.md 60 | echo "pip install simple-resume==${{ steps.tag.outputs.version }}" >> /tmp/changelog.md 61 | echo '```' >> /tmp/changelog.md 62 | 63 | - name: Create GitHub Release 64 | uses: softprops/action-gh-release@v2 65 | with: 66 | tag_name: ${{ steps.tag.outputs.tag }} 67 | name: Release ${{ steps.tag.outputs.tag }} 68 | body_path: /tmp/changelog.md 69 | draft: false 70 | prerelease: ${{ contains(steps.tag.outputs.version, 'rc') || contains(steps.tag.outputs.version, 'beta') || contains(steps.tag.outputs.version, 'alpha') }} 71 | files: | 72 | dist/*.tar.gz 73 | dist/*.whl 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | -------------------------------------------------------------------------------- /src/simple_resume/core/generate/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception types used by the generation subsystem.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from simple_resume.core.exceptions import SimpleResumeError 8 | 9 | 10 | class GenerationError(SimpleResumeError): 11 | """Raise when PDF/HTML generation fails.""" 12 | 13 | def __init__(self, message: str, **metadata: Any) -> None: 14 | """Initialize the exception with optional metadata. 15 | 16 | Args: 17 | message: The error message. 18 | **metadata: Optional metadata such as ``output_path``, ``format_type``, 19 | ``resume_name``, ``context`` and ``filename``. 20 | 21 | """ 22 | output_path = metadata.pop("output_path", None) 23 | format_type = metadata.pop("format_type", None) 24 | resume_name = metadata.pop("resume_name", None) 25 | context = metadata.pop("context", None) 26 | filename = metadata.pop("filename", None) 27 | 28 | if filename is None and resume_name is not None: 29 | filename = resume_name 30 | 31 | super().__init__(message, context=context, filename=filename, **metadata) 32 | self.output_path = str(output_path) if output_path is not None else None 33 | self.format_type = format_type 34 | self.resume_name = resume_name 35 | 36 | def __str__(self) -> str: 37 | """Return a formatted error message.""" 38 | base_msg = super().__str__() 39 | if self.format_type: 40 | base_msg = f"{base_msg} (format={self.format_type})" 41 | return base_msg 42 | 43 | 44 | class TemplateError(SimpleResumeError): 45 | """Raise when template processing fails.""" 46 | 47 | def __init__(self, message: str, **metadata: Any) -> None: 48 | """Initialize the exception with optional metadata. 49 | 50 | Args: 51 | message: The error message. 52 | **metadata: Optional metadata such as ``template_name``, 53 | ``template_path``, ``context`` and ``filename``. 54 | 55 | """ 56 | template_name = metadata.pop("template_name", None) 57 | template_path = metadata.pop("template_path", None) 58 | context = metadata.pop("context", None) 59 | filename = metadata.pop("filename", None) 60 | 61 | super().__init__(message, context=context, filename=filename, **metadata) 62 | self.template_name = template_name 63 | self.template_path = template_path 64 | 65 | 66 | __all__ = [ 67 | "GenerationError", 68 | "TemplateError", 69 | ] 70 | -------------------------------------------------------------------------------- /PACKAGING.md: -------------------------------------------------------------------------------- 1 | # Packaging for PyPI 2 | 3 | This guide outlines the process for preparing, building, and publishing `simple-resume` releases to PyPI. 4 | 5 | ## Prerequisites 6 | 7 | - Python 3.9+ 8 | - `uv` (installed globally) 9 | - A PyPI account with access to the project. 10 | - A clean git working directory. 11 | 12 | ## Versioning 13 | 14 | This project uses [Semantic Versioning](https://semver.org/). Before creating a release, update the version in `pyproject.toml` and `CHANGELOG.md`. 15 | 16 | ## Release Checklist 17 | 18 | 1. **Freeze features**: Merge the release branch into `main`. 19 | 2. **Update artifacts**: Update `CHANGELOG.md`, `README.md`, and `assets/preview.jpg` (if the UI has changed). 20 | 3. **Bump the version**: Manually update the version in `pyproject.toml`. 21 | 4. **Regenerate the lockfile**: If dependencies have changed, run the following command: 22 | 23 | ```bash 24 | uv lock --upgrade 25 | ``` 26 | 27 | 5. **Run quality checks**: 28 | 29 | ```bash 30 | make validate 31 | ``` 32 | 33 | 6. **Commit and tag**: 34 | 35 | ```bash 36 | git commit -am "chore: release vX.Y.Z" 37 | git tag vX.Y.Z 38 | git push origin main --tags 39 | ``` 40 | 41 | ## Building and Verifying the Package 42 | 43 | 1. Build the `sdist` and `wheel`: 44 | 45 | ```bash 46 | uv build 47 | ``` 48 | 49 | 2. Check the built packages for errors: 50 | 51 | ```bash 52 | uv run twine check dist/* 53 | ``` 54 | 55 | 3. Install the wheel in a new virtual environment to test it: 56 | 57 | ```bash 58 | uv venv --python 3.9 --name packaging-smoke 59 | uv run --venv packaging-smoke pip install dist/simple-resume-X.Y.Z-py3-none-any.whl 60 | uv run --venv packaging-smoke simple-resume --help 61 | ``` 62 | 63 | ## Publishing the Package 64 | 65 | ### Trusted Publishing (Preferred Method) 66 | 67 | The GitHub Actions workflow is configured to automatically build and publish a release to PyPI when a new tag is pushed to the `main` branch. 68 | 69 | ### Manual Publishing 70 | 71 | To publish the package manually, use `uv`. 72 | 73 | ```bash 74 | # Publish to PyPI 75 | uv publish --repository pypi 76 | 77 | # Alternatively, publish to TestPyPI first for verification 78 | uv run twine upload --repository testpypi dist/* 79 | pip install --index-url https://test.pypi.org/simple/ simple-resume==X.Y.Z 80 | ``` 81 | 82 | ## Post-Release Tasks 83 | 84 | - Create a new release on GitHub for the new tag. 85 | - Close the current milestone and create a new one for the next release. 86 | - Verify that the package metadata is correct on PyPI. 87 | -------------------------------------------------------------------------------- /wiki/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | This guide explains how to contribute to the `simple-resume` project. We welcome bug reports, feature suggestions, and code improvements. 4 | 5 | ## Reporting Bugs and Suggesting Features 6 | 7 | Bugs and feature suggestions should be reported by opening a GitHub issue. 8 | 9 | - **[Report a bug](https://github.com/athola/simple-resume/issues)**: Provide a detailed description of the bug and steps to reproduce it. 10 | - **[Suggest a feature](https://github.com/athola/simple-resume/issues/new?template=feature_request.md)**: Describe your idea and explain why it would be a good addition to the project. 11 | 12 | ## Contributing Code 13 | 14 | To contribute code, please follow these steps: 15 | 16 | 1. Fork the repository and clone it to your local machine. 17 | 2. Create a new branch for your changes. 18 | 3. Set up your development environment by following the [Development Guide](Development-Guide.md). 19 | 4. Make your changes, and add tests and documentation as needed. 20 | 5. Run all code quality checks to ensure the changes adhere to the project's style and pass all tests. 21 | 22 | ```bash 23 | make check-all 24 | make validate 25 | ``` 26 | 27 | 6. Push your changes to your fork and open a pull request against the `main` branch. 28 | 29 | ### Commit Signing Requirement 30 | 31 | All commits must be GPG-signed so GitHub can mark them as **Verified**. Configure 32 | your signing key before opening a pull request: 33 | 34 | ```bash 35 | # Export or create a key, then tell git which one to use 36 | git config user.signingkey 37 | 38 | # Sign every commit in this repo by default 39 | git config commit.gpgsign true 40 | 41 | # Optional: ensure the right GPG program is used (gpg vs gpg2) 42 | git config gpg.program gpg 43 | ``` 44 | 45 | Make sure the corresponding public key is uploaded to your GitHub account under 46 | **Settings → SSH and GPG keys**. If you use a hardware token or SSH signing, 47 | follow GitHub's [official guide](https://docs.github.com/authentication/managing-commit-signature-verification) 48 | to register the signer. Commits without a trusted signature will be blocked from 49 | merging. 50 | 51 | ## Development Guidelines 52 | 53 | - **Code Style**: This project uses `ruff` for linting and formatting. Run `make format` before committing your changes, and maintain consistency with the existing code style. 54 | - **Tests**: New features must be accompanied by tests. Bug fixes must include a test that demonstrates the bug and its resolution. 55 | - **Documentation**: All new or changed features must be documented. 56 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/formatting.py: -------------------------------------------------------------------------------- 1 | """LaTeX formatting functions for dates and links (pure, no side effects).""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.core.latex.conversion import convert_inline 6 | from simple_resume.core.latex.escaping import escape_url 7 | 8 | 9 | def format_date(start: str | None, end: str | None) -> str | None: 10 | """Format start and end dates for LaTeX rendering. 11 | 12 | This is a pure function that formats date ranges according to resume 13 | conventions. 14 | 15 | Args: 16 | start: Start date string (may be None or empty). 17 | end: End date string (may be None or empty). 18 | 19 | Returns: 20 | Formatted date range string, or None if both inputs are empty. 21 | 22 | Examples: 23 | >>> format_date("2020", "2023") 24 | '2020 -- 2023' 25 | >>> format_date("2023", "2023") 26 | '2023' 27 | >>> format_date("2020", "Present") 28 | '2020 -- Present' 29 | >>> format_date(None, None) 30 | None 31 | 32 | """ 33 | start_clean = start.strip() if isinstance(start, str) else "" 34 | end_clean = end.strip() if isinstance(end, str) else "" 35 | 36 | if start_clean and end_clean: 37 | if start_clean == end_clean: 38 | return convert_inline(start_clean) 39 | return convert_inline(f"{start_clean} -- {end_clean}") 40 | if end_clean: 41 | return convert_inline(end_clean) 42 | if start_clean: 43 | return convert_inline(start_clean) 44 | return None 45 | 46 | 47 | def linkify(text: str | None, link: str | None) -> str | None: 48 | r"""Convert text to a hyperlink if a URL is provided. 49 | 50 | This is a pure function that creates LaTeX \\href commands when 51 | a link is provided, or returns the text as-is if no link. 52 | 53 | Args: 54 | text: The text to display (may be None). 55 | link: The URL to link to (may be None or empty). 56 | 57 | Returns: 58 | LaTeX hyperlink command if link is provided, plain text otherwise, 59 | or None if text is empty. 60 | 61 | Examples: 62 | >>> linkify("Company", "https://example.com") 63 | '\\href{https://example.com}{Company}' 64 | >>> linkify("Company", None) 65 | 'Company' 66 | >>> linkify(None, "https://example.com") 67 | None 68 | 69 | """ 70 | if not text: 71 | return None 72 | rendered = convert_inline(text) 73 | if link: 74 | return rf"\href{{{escape_url(link)}}}{{{rendered}}}" 75 | return rendered 76 | 77 | 78 | __all__ = [ 79 | "format_date", 80 | "linkify", 81 | ] 82 | -------------------------------------------------------------------------------- /tests/unit/test_cli_generate_e2e.py: -------------------------------------------------------------------------------- 1 | """End-to-end-ish CLI generate path tests (patched to avoid I/O).""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from simple_resume.core.constants import OutputFormat 11 | from simple_resume.shell.cli import main as cli_main 12 | from tests.bdd import Scenario 13 | 14 | 15 | def test_cli_main_generate_invokes_execute( 16 | story: Scenario, monkeypatch: pytest.MonkeyPatch, tmp_path: Path 17 | ) -> None: 18 | story.given("CLI is invoked with generate command and PDF format") 19 | story.when("building the generation plan and executing it") 20 | called = {} 21 | 22 | monkeypatch.setattr(cli_main, "register_default_services", lambda: None) 23 | 24 | def fake_build(options): 25 | called["options"] = options 26 | return ["plan"] 27 | 28 | def fake_exec(commands): 29 | called["commands"] = commands 30 | return 0 31 | 32 | monkeypatch.setattr(cli_main, "build_generation_plan", fake_build) 33 | monkeypatch.setattr(cli_main, "_execute_generation_plan", fake_exec) 34 | 35 | argv = [ 36 | "simple-resume", 37 | "generate", 38 | "demo", 39 | "--format", 40 | "pdf", 41 | "--data-dir", 42 | str(tmp_path), 43 | ] 44 | monkeypatch.setattr(sys, "argv", argv) 45 | 46 | exit_code = cli_main.main() 47 | 48 | assert exit_code == 0 49 | assert called["commands"] == ["plan"] 50 | assert called["options"].name == "demo" 51 | assert OutputFormat.PDF in called["options"].formats 52 | 53 | 54 | def test_cli_generate_multiple_formats( 55 | story: Scenario, monkeypatch: pytest.MonkeyPatch, tmp_path: Path 56 | ) -> None: 57 | story.given("CLI is invoked with multiple formats flag") 58 | story.when("parsing arguments and executing generation plan") 59 | captured = {} 60 | monkeypatch.setattr(cli_main, "register_default_services", lambda: None) 61 | 62 | def fake_build(options): 63 | captured["formats"] = options.formats 64 | return ["plan"] 65 | 66 | monkeypatch.setattr(cli_main, "build_generation_plan", fake_build) 67 | monkeypatch.setattr(cli_main, "_execute_generation_plan", lambda cmds: 0) 68 | 69 | argv = [ 70 | "simple-resume", 71 | "generate", 72 | "demo", 73 | "--formats", 74 | "pdf", 75 | "html", 76 | "--data-dir", 77 | str(tmp_path), 78 | ] 79 | monkeypatch.setattr(sys, "argv", argv) 80 | 81 | exit_code = cli_main.main() 82 | 83 | assert exit_code == 0 84 | assert set(captured["formats"]) == {OutputFormat.PDF, OutputFormat.HTML} 85 | -------------------------------------------------------------------------------- /tests/unit/core/test_palette_generators.py: -------------------------------------------------------------------------------- 1 | """Tests for core/palettes/generators.py - palette generation functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from simple_resume.core.palettes.generators import generate_hcl_palette 8 | 9 | 10 | class TestGenerateHclPalette: 11 | """Tests for generate_hcl_palette function.""" 12 | 13 | def test_generates_single_color(self) -> None: 14 | """Test generating a single color palette.""" 15 | colors = generate_hcl_palette(1, seed=42) 16 | assert len(colors) == 1 17 | assert all(c.startswith("#") for c in colors) 18 | 19 | def test_generates_multiple_colors(self) -> None: 20 | """Test generating multiple colors.""" 21 | colors = generate_hcl_palette(5, seed=42) 22 | assert len(colors) == 5 23 | assert all(c.startswith("#") for c in colors) 24 | 25 | def test_deterministic_with_seed(self) -> None: 26 | """Test that same seed produces same colors.""" 27 | colors1 = generate_hcl_palette(3, seed=123) 28 | colors2 = generate_hcl_palette(3, seed=123) 29 | assert colors1 == colors2 30 | 31 | def test_different_seeds_different_colors(self) -> None: 32 | """Test that different seeds produce different colors.""" 33 | colors1 = generate_hcl_palette(3, seed=123) 34 | colors2 = generate_hcl_palette(3, seed=456) 35 | assert colors1 != colors2 36 | 37 | def test_custom_hue_range(self) -> None: 38 | """Test generating with custom hue range.""" 39 | colors = generate_hcl_palette(3, seed=42, hue_range=(0, 60)) 40 | assert len(colors) == 3 41 | 42 | def test_custom_luminance_range(self) -> None: 43 | """Test generating with custom luminance range.""" 44 | colors = generate_hcl_palette(3, seed=42, luminance_range=(0.5, 0.9)) 45 | assert len(colors) == 3 46 | 47 | def test_returns_valid_hex_colors(self) -> None: 48 | """Test that returned colors are valid hex format.""" 49 | colors = generate_hcl_palette(5, seed=42) 50 | for color in colors: 51 | assert color.startswith("#") 52 | assert len(color) == 7 # #RRGGBB format 53 | 54 | def test_raises_error_for_zero_size(self) -> None: 55 | """Test that size <= 0 raises ValueError.""" 56 | with pytest.raises(ValueError, match="size must be a positive integer"): 57 | generate_hcl_palette(0) 58 | 59 | def test_raises_error_for_negative_size(self) -> None: 60 | """Test that negative size raises ValueError.""" 61 | with pytest.raises(ValueError, match="size must be a positive integer"): 62 | generate_hcl_palette(-1) 63 | -------------------------------------------------------------------------------- /wiki/Workflows.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows 2 | 3 | This project uses GitHub Actions to automate code quality checks on every push and pull request to the `main` branch. This guide provides an overview of each workflow and explains how to run the same checks locally. 4 | 5 | ## CI/CD Workflows 6 | 7 | - **`test.yml`**: Runs the `pytest` test suite and performs static analysis with `mypy`, `ty`, and `ruff`. 8 | - **`lint.yml`**: Enforces consistent code style using `ruff`, `flake8`, and `pylint`. 9 | - **`typecheck.yml`**: Validates type hints with `mypy`, `ty`, `pyright`, and `pytype`. 10 | - **`code-quality.yml`**: Scans for security vulnerabilities and code complexity with `Bandit`, `Safety`, `Radon`, and `Xenon`. 11 | - **`pre-commit.yml`**: Validates the `.pre-commit-config.yaml` file. 12 | - **`publish-packages.yml`**: Publishes the package to PyPI when version changes are detected on the `main` branch. 13 | - **`release.yml`**: Creates GitHub releases automatically when version tags are pushed. 14 | 15 | ## Local Development 16 | 17 | ### Pre-commit Hooks 18 | 19 | Pre-commit hooks identify issues before committing code. They automatically run `ruff`, `mypy`, and several security checks. 20 | 21 | ```bash 22 | # Install the pre-commit hooks 23 | uv run pre-commit install 24 | 25 | # Run the hooks on all files 26 | uv run pre-commit run --all-files 27 | ``` 28 | 29 | ### Manual Execution 30 | 31 | The checks can also be run manually. 32 | 33 | ```bash 34 | # Linting and formatting 35 | uv run ruff check src/ tests/ 36 | uv run ruff format src/ tests/ 37 | 38 | # Type checking 39 | uv run mypy src/simple_resume/ --strict 40 | uv run ty check src/simple_resume/ 41 | 42 | # Testing 43 | uv run pytest 44 | ``` 45 | 46 | ## Release Workflow 47 | 48 | The `release.yml` workflow automates GitHub release creation when tags are pushed. 49 | 50 | ### Creating a Release 51 | 52 | ```bash 53 | # Tag the version (must start with 'v') 54 | git tag v0.1.2 55 | git push origin v0.1.2 56 | ``` 57 | 58 | The workflow performs the following actions: 59 | 60 | 1. Builds the package using `uv build` 61 | 2. Extracts version information from the tag 62 | 3. Generates a changelog from commits since the previous tag 63 | 4. Creates a GitHub release with: 64 | - Generated changelog 65 | - Installation instructions 66 | - Built distribution artifacts (`.tar.gz` and `.whl` files) 67 | - Prerelease flag for tags containing `alpha`, `beta`, or `rc` 68 | 69 | ### Package Publishing 70 | 71 | The `publish-packages.yml` workflow monitors the `main` branch for version changes in `pyproject.toml`. When a version change is detected: 72 | 73 | 1. Builds and verifies the package distributions 74 | 2. Publishes to PyPI (requires `PYPI_API_TOKEN` secret) 75 | 76 | ## Configuration 77 | 78 | All workflows use Python 3.9 and `uv`. Security and complexity scan reports become build artifacts in GitHub Actions. 79 | -------------------------------------------------------------------------------- /tests/unit/core/test_constants.py: -------------------------------------------------------------------------------- 1 | """Tests for core/constants/__init__.py - constant definitions and enums.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from simple_resume.core.constants import OutputFormat, RenderMode, TemplateType 8 | 9 | 10 | class TestOutputFormat: 11 | """Tests for OutputFormat enum.""" 12 | 13 | def test_normalize_from_string(self) -> None: 14 | """Test normalizing from string value.""" 15 | assert OutputFormat.normalize("pdf") is OutputFormat.PDF 16 | assert OutputFormat.normalize("html") is OutputFormat.HTML 17 | 18 | def test_normalize_from_enum(self) -> None: 19 | """Test normalizing from enum returns same enum.""" 20 | assert OutputFormat.normalize(OutputFormat.PDF) is OutputFormat.PDF 21 | assert OutputFormat.normalize(OutputFormat.HTML) is OutputFormat.HTML 22 | 23 | def test_normalize_case_insensitive(self) -> None: 24 | """Test normalize is case-insensitive.""" 25 | assert OutputFormat.normalize("PDF") is OutputFormat.PDF 26 | assert OutputFormat.normalize("Html") is OutputFormat.HTML 27 | assert OutputFormat.normalize(" pdf ") is OutputFormat.PDF 28 | 29 | def test_normalize_invalid_type_raises_type_error(self) -> None: 30 | """Test normalize raises TypeError for invalid input type.""" 31 | with pytest.raises(TypeError, match="Output format must be provided as string"): 32 | OutputFormat.normalize(123) # type: ignore[arg-type] 33 | 34 | def test_normalize_invalid_value_raises_value_error(self) -> None: 35 | """Test normalize raises ValueError for invalid format string.""" 36 | with pytest.raises(ValueError, match="Unsupported format"): 37 | OutputFormat.normalize("docx") 38 | 39 | 40 | class TestRenderMode: 41 | """Tests for RenderMode enum.""" 42 | 43 | def test_render_modes_available(self) -> None: 44 | """Test that RenderMode has expected values.""" 45 | assert RenderMode.HTML is not None 46 | assert RenderMode.LATEX is not None 47 | 48 | def test_render_mode_values(self) -> None: 49 | """Test RenderMode enum values.""" 50 | assert RenderMode.HTML.value == "html" 51 | assert RenderMode.LATEX.value == "latex" 52 | 53 | 54 | class TestTemplateType: 55 | """Tests for TemplateType enum.""" 56 | 57 | def test_is_valid_returns_true_for_valid_template(self) -> None: 58 | """Test is_valid returns True for valid template strings.""" 59 | assert TemplateType.is_valid("resume_no_bars") is True 60 | assert TemplateType.is_valid("resume_with_bars") is True 61 | 62 | def test_is_valid_returns_false_for_invalid_template(self) -> None: 63 | """Test is_valid returns False for invalid template strings.""" 64 | assert TemplateType.is_valid("invalid_template") is False 65 | assert TemplateType.is_valid("") is False 66 | -------------------------------------------------------------------------------- /src/simple_resume/core/constants/colors.py: -------------------------------------------------------------------------------- 1 | """Color-related constants for simple-resume.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Final 6 | 7 | DEFAULT_COLOR_SCHEME: Final[dict[str, str]] = { 8 | "theme_color": "#0395DE", 9 | "sidebar_color": "#F6F6F6", 10 | "sidebar_text_color": "#000000", 11 | "bar_background_color": "#DFDFDF", 12 | "date2_color": "#616161", 13 | "frame_color": "#757575", 14 | "heading_icon_color": "#0395DE", 15 | "bold_color": "#585858", 16 | } 17 | 18 | # WCAG relative luminance calculation constants 19 | WCAG_LINEARIZATION_THRESHOLD: Final[float] = 0.03928 20 | WCAG_LINEARIZATION_DIVISOR: Final[float] = 12.92 21 | WCAG_LINEARIZATION_EXPONENT: Final[float] = 2.4 22 | WCAG_LINEARIZATION_OFFSET: Final[float] = 0.055 23 | 24 | # Color manipulation constants 25 | BOLD_DARKEN_FACTOR: Final[float] = 0.75 26 | SIDEBAR_BOLD_DARKEN_FACTOR: Final[float] = 0.8 27 | 28 | # Luminance thresholds for color contrast calculations 29 | LUMINANCE_VERY_DARK: Final[float] = 0.15 30 | LUMINANCE_DARK: Final[float] = 0.5 31 | LUMINANCE_VERY_LIGHT: Final[float] = 0.8 32 | ICON_CONTRAST_THRESHOLD: Final[float] = 3.0 33 | 34 | # Color Format Constants 35 | HEX_COLOR_SHORT_LENGTH: Final[int] = 3 36 | HEX_COLOR_FULL_LENGTH: Final[int] = 6 37 | 38 | # UI Element Constants 39 | DEFAULT_BOLD_COLOR: Final[str] = "#585858" 40 | 41 | # Define color field ordering for palette application 42 | COLOR_FIELD_ORDER: Final[tuple[str, ...]] = ( 43 | "accent_color", 44 | "sidebar_color", 45 | "text_color", 46 | "emphasis_color", 47 | "link_color", 48 | ) 49 | 50 | # Direct color keys that can be specified in configuration 51 | DIRECT_COLOR_KEYS: Final[set[str]] = { 52 | "accent_color", 53 | "sidebar_color", 54 | "text_color", 55 | "emphasis_color", 56 | "link_color", 57 | "sidebar_text_color", 58 | } 59 | 60 | # Resume configuration color ordering (used by palette normalization) 61 | CONFIG_COLOR_FIELDS: Final[tuple[str, ...]] = ( 62 | "theme_color", 63 | "sidebar_color", 64 | "sidebar_text_color", 65 | "bar_background_color", 66 | "date2_color", 67 | "frame_color", 68 | "heading_icon_color", 69 | ) 70 | 71 | CONFIG_DIRECT_COLOR_KEYS: Final[tuple[str, ...]] = CONFIG_COLOR_FIELDS + ( 72 | "bold_color", 73 | "sidebar_bold_color", 74 | ) 75 | 76 | __all__ = [ 77 | "DEFAULT_COLOR_SCHEME", 78 | "WCAG_LINEARIZATION_THRESHOLD", 79 | "WCAG_LINEARIZATION_DIVISOR", 80 | "WCAG_LINEARIZATION_EXPONENT", 81 | "WCAG_LINEARIZATION_OFFSET", 82 | "BOLD_DARKEN_FACTOR", 83 | "SIDEBAR_BOLD_DARKEN_FACTOR", 84 | "LUMINANCE_VERY_DARK", 85 | "LUMINANCE_DARK", 86 | "LUMINANCE_VERY_LIGHT", 87 | "ICON_CONTRAST_THRESHOLD", 88 | "HEX_COLOR_SHORT_LENGTH", 89 | "HEX_COLOR_FULL_LENGTH", 90 | "DEFAULT_BOLD_COLOR", 91 | "COLOR_FIELD_ORDER", 92 | "DIRECT_COLOR_KEYS", 93 | "CONFIG_COLOR_FIELDS", 94 | "CONFIG_DIRECT_COLOR_KEYS", 95 | ] 96 | -------------------------------------------------------------------------------- /src/simple_resume/shell/service_locator.py: -------------------------------------------------------------------------------- 1 | """Service locator pattern for dependency injection. 2 | 3 | This module provides a clean way to inject shell-layer dependencies 4 | into core functions without using late-bound imports. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from functools import lru_cache 10 | from typing import Any, Callable, TypeVar 11 | 12 | T = TypeVar("T") 13 | 14 | 15 | class ServiceLocator: 16 | """Simple service locator for dependency injection.""" 17 | 18 | def __init__(self) -> None: 19 | """Initialize the service locator.""" 20 | self._services: dict[str, Any] = {} 21 | self._factories: dict[str, Callable[[], Any]] = {} 22 | 23 | def register(self, name: str, service: Any) -> None: 24 | """Register a service instance.""" 25 | self._services[name] = service 26 | 27 | def register_factory(self, name: str, factory: Callable[[], Any]) -> None: 28 | """Register a factory function for lazy service creation.""" 29 | self._factories[name] = factory 30 | 31 | def get(self, name: str, service_type: type[T]) -> T: 32 | """Get a service by name, ensuring it matches the expected type.""" 33 | if name in self._services: 34 | service = self._services[name] 35 | if not isinstance(service, service_type): 36 | raise TypeError(f"Service {name} is not of type {service_type}") 37 | return service 38 | 39 | if name in self._factories: 40 | service = self._factories[name]() 41 | if not isinstance(service, service_type): 42 | raise TypeError(f"Service {name} is not of type {service_type}") 43 | self._services[name] = service # Cache the instance 44 | return service 45 | 46 | raise ValueError(f"Service {name} not registered") 47 | 48 | def has(self, name: str) -> bool: 49 | """Check if a service is registered.""" 50 | return name in self._services or name in self._factories 51 | 52 | 53 | @lru_cache(maxsize=1) 54 | def get_service_locator() -> ServiceLocator: 55 | """Get the global service locator instance without module globals.""" 56 | return ServiceLocator() 57 | 58 | 59 | def register_service(name: str, service: Any) -> None: 60 | """Register a service with the global locator.""" 61 | get_service_locator().register(name, service) 62 | 63 | 64 | def register_service_factory(name: str, factory: Callable[[], Any]) -> None: 65 | """Register a service factory with the global locator.""" 66 | get_service_locator().register_factory(name, factory) 67 | 68 | 69 | def get_service(name: str, service_type: type[T]) -> T: 70 | """Get a service from the global locator.""" 71 | return get_service_locator().get(name, service_type) 72 | 73 | 74 | __all__ = [ 75 | "ServiceLocator", 76 | "get_service_locator", 77 | "register_service", 78 | "register_service_factory", 79 | "get_service", 80 | ] 81 | -------------------------------------------------------------------------------- /sample/input/sample_dark_sidebar.yaml: -------------------------------------------------------------------------------- 1 | template: resume_with_bars 2 | 3 | full_name: Casey Dark Mode 4 | job_title: Design Technologist 5 | 6 | address: 7 | - 101 Dark Way 8 | - Austin, TX 9 | 10 | phone: "(512) 555-2025" 11 | email: casey.dark@example.com 12 | web: https://caseydark.dev 13 | 14 | titles: 15 | contact: Contact 16 | skills: Skills 17 | expertise: Strengths 18 | certification: Certifications 19 | keyskills: Tools 20 | languages: Languages 21 | 22 | description: | 23 | Demonstration resume with a **dark sidebar** to showcase automatic text color 24 | adjustment. The sidebar text should automatically switch to light colors when 25 | the sidebar background is dark. 26 | 27 | skills: 28 | Creative Coding: 50 29 | Design Systems: 48 30 | Accessibility: 52 31 | Frontend Dev: 45 32 | Prototyping: 40 33 | 34 | expertise: 35 | - Creative coding 36 | - Design systems 37 | - Accessibility 38 | 39 | certification: 40 | - Accessibility Auditor 41 | - Frontend Specialist 42 | 43 | keyskills: 44 | - Python 45 | - Figma 46 | - WebGL 47 | - Storybook 48 | 49 | languages: 50 | English: 58 51 | Spanish: 30 52 | 53 | body: 54 | Experience: 55 | - 56 | start: 2022 57 | end: Present 58 | title: Design Technologist 59 | company: Spectrum Labs 60 | description: | 61 | - Partnered with design to ship a component library used across 12 product teams. 62 | - Automated theme generation using YAML palettes and codemods. 63 | - Led accessibility audits that removed 180+ contrast violations. 64 | Education: 65 | - 66 | start: 2014 67 | end: 2018 68 | title: B.S. Human-Centered Design 69 | company: University of Texas 70 | 71 | config: 72 | color_scheme: generator dark_dusk 73 | palette: 74 | source: generator 75 | type: hcl 76 | size: 6 77 | seed: 1957 78 | hue_range: [240, 280] # Blue-purple range for dark colors 79 | luminance_range: [0.08, 0.2] # Very dark to dark 80 | chroma: 0.25 # Moderate saturation 81 | 82 | padding: 12 83 | page_width: 210 84 | page_height: 297 85 | sidebar_width: 60 86 | sidebar_padding_top: 5 87 | profile_image_padding_bottom: 6 88 | pitch_padding_top: 4 89 | pitch_padding_bottom: 4 90 | pitch_padding_left: 4 91 | h2_padding_left: 4 92 | h3_padding_top: 5 93 | date_container_width: 13 94 | date_container_padding_left: 8 95 | description_container_padding_left: 3 96 | frame_padding: 10 97 | frame_color: "#757575" 98 | bold_color: "#585858" # Darkened frame for bold text 99 | 100 | # Section heading icon layout customization 101 | section_icon_circle_size: "7.8mm" 102 | section_icon_circle_x_offset: "-0.5mm" 103 | section_icon_design_size: "4mm" 104 | section_icon_design_x_offset: "-0.1mm" 105 | section_icon_design_y_offset: "-0.4mm" 106 | section_heading_text_margin: "-6mm" 107 | -------------------------------------------------------------------------------- /sample/input/sample_contrast_demo.yaml: -------------------------------------------------------------------------------- 1 | template: resume_with_bars 2 | 3 | full_name: Casey Contrast 4 | job_title: Color Contrast Specialist 5 | 6 | address: 7 | - 101 Spectrum Way 8 | - Austin, TX 9 | 10 | phone: "(512) 555-2025" 11 | email: casey.contrast@example.com 12 | web: https://caseycontrast.dev 13 | 14 | titles: 15 | contact: Contact 16 | skills: Skills 17 | expertise: Strengths 18 | certification: Certifications 19 | keyskills: Tools 20 | languages: Languages 21 | 22 | description: | 23 | Demo showcasing **automatic text contrast adjustment**. This configuration uses 24 | a dark purple sidebar, and all text in the sidebar automatically adjusts to 25 | light colors for optimal readability. 26 | 27 | skills: 28 | Color Accessibility: 55 29 | UI/UX Design: 48 30 | WCAG Compliance: 52 31 | Color Theory: 45 32 | A11y Testing: 40 33 | 34 | expertise: 35 | - Color accessibility 36 | - WCAG compliance 37 | - UI/UX design 38 | 39 | certification: 40 | - Certified Professional in Accessibility 41 | - WCAG Specialist 42 | 43 | keyskills: 44 | - Color theory 45 | - Contrast ratios 46 | - Palette design 47 | - A11y testing 48 | 49 | languages: 50 | English: 60 51 | Spanish: 28 52 | 53 | body: 54 | Experience: 55 | - 56 | start: 2022 57 | end: Present 58 | title: Accessibility Specialist 59 | company: Color Labs 60 | description: | 61 | - Implemented automatic contrast adjustment for dark themes. 62 | - Ensured WCAG AA compliance across all color combinations. 63 | - Developed responsive color systems for mobile and web. 64 | Education: 65 | - 66 | start: 2014 67 | end: 2018 68 | title: B.S. Color Science 69 | company: Design Institute 70 | 71 | config: 72 | color_scheme: generator deep_purple 73 | palette: 74 | source: generator 75 | type: hcl 76 | size: 6 77 | seed: 42 78 | hue_range: [260, 280] # Purple range 79 | luminance_range: [0.12, 0.25] # Dark to medium-dark 80 | chroma: 0.3 # Rich saturation 81 | 82 | padding: 12 83 | page_width: 210 84 | page_height: 297 85 | sidebar_width: 60 86 | sidebar_padding_top: 5 87 | profile_image_padding_bottom: 6 88 | pitch_padding_top: 4 89 | pitch_padding_bottom: 4 90 | pitch_padding_left: 4 91 | h2_padding_left: 4 92 | h3_padding_top: 5 93 | date_container_width: 13 94 | date_container_padding_left: 8 95 | description_container_padding_left: 3 96 | frame_padding: 10 97 | frame_color: "#757575" 98 | bold_color: "#585858" # Darkened frame for bold text 99 | 100 | # Section heading icon layout customization 101 | section_icon_circle_size: "7.8mm" 102 | section_icon_circle_x_offset: "-0.5mm" 103 | section_icon_design_size: "4mm" 104 | section_icon_design_x_offset: "-0.1mm" 105 | section_icon_design_y_offset: "-0.4mm" 106 | section_heading_text_margin: "-6mm" 107 | -------------------------------------------------------------------------------- /PLAN.md: -------------------------------------------------------------------------------- 1 | # Project Plan 2 | 3 | This document outlines the `simple-resume` development plan. 4 | 5 | ## Project Goals 6 | 7 | - Generate resumes from structured data formats (YAML, JSON). 8 | - Support custom templates and color schemes via YAML configuration. 9 | - Keep test coverage above 85%; document all public APIs. 10 | 11 | ## Phase 1: Core Functionality and Refinement (Completed) 12 | 13 | - [x] Initial YAML-based resume generation. 14 | - [x] HTML and PDF output formats. 15 | - [x] Basic template system. 16 | - [x] Initial command-line interface (CLI). 17 | - [x] Consolidated `README.md` with wiki links. 18 | - [x] `wiki/Contributing.md` 19 | - [x] `wiki/Usage-Guide.md` 20 | - [x] `wiki/Development-Guide.md` 21 | - [x] Enhanced external palette loading with direct color definitions. 22 | - [x] Optimized print-friendly color palettes for black/white contrast. 23 | - [x] Fixed external palette validation conflicts. 24 | 25 | ## Phase 2: Documentation and Usability (Completed) 26 | 27 | - [x] Expanded wiki with detailed guides. 28 | - [x] Published stability policy and API reference (`docs/reference.md`). 29 | - [x] **Major architectural refactor**: Migrated to functional core, imperative shell architecture. 30 | - [x] **Enhanced API surface**: Redesigned Resume class with pandas-like symmetric I/O patterns. 31 | - [x] **Improved session management**: Enhanced ResumeSession with caching and statistics. 32 | - [x] **Comprehensive sample files**: Added demo resumes showcasing all features. 33 | - [x] **Architecture documentation**: Added functional core-shell design docs. 34 | - [x] **Error handling improvements**: Enhanced exception hierarchy and validation. 35 | - [x] **LaTeX documentation**: Added comprehensive LaTeX support documentation with examples and compilation instructions. 36 | - [x] **Enhanced README**: Improved documentation structure with better LaTeX support coverage. 37 | - [ ] Improve error messages for user-friendliness. 38 | - [ ] Add "live preview" feature to web UI. 39 | 40 | ## Phase 3: Feature Expansion 41 | 42 | - [ ] Add at least three new resume templates. 43 | - [ ] Evaluate and potentially integrate new PDF rendering engine to improve quality/performance. 44 | - [ ] Add support for JSON Resume format to improve interoperability. 45 | - [ ] Add support for generating cover letters from Markdown. 46 | 47 | ## Tooling Notes 48 | 49 | The validation process runs `ruff`, `mypy`, `ty`, and `pytest`. Markdown linting/formatting tools (`blacken-docs`, `markdownlint`) no longer run on `README.md` and `wiki/` files, as these are frequently updated from an external source. These files no longer block the `make validate` command. 50 | 51 | To keep validation fast, README/wiki rewrites will land through doc-only branches anchored to a recorded baseline (`docs/doc-baseline.md`, refreshed via `scripts/doc_baseline.sh`). Mainline work should avoid touching those files unless the baseline is bumped first, preventing huge markdown diffs in day-to-day PRs. 52 | -------------------------------------------------------------------------------- /src/simple_resume/core/models.py: -------------------------------------------------------------------------------- 1 | """Core data models for resume rendering.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | from simple_resume.core.constants import OutputFormat, RenderMode 10 | from simple_resume.core.paths import Paths 11 | 12 | 13 | @dataclass(frozen=True) 14 | class ResumeConfig: 15 | """A normalized resume configuration with validated fields.""" 16 | 17 | page_width: int | None = None 18 | page_height: int | None = None 19 | sidebar_width: int | None = None 20 | output_mode: str = "markdown" 21 | template: str = "resume_no_bars" 22 | color_scheme: str = "default" 23 | 24 | # Color fields 25 | theme_color: str = "#0395DE" 26 | sidebar_color: str = "#F6F6F6" 27 | sidebar_text_color: str = "#000000" 28 | sidebar_bold_color: str = "#000000" 29 | bar_background_color: str = "#DFDFDF" 30 | date2_color: str = "#616161" 31 | frame_color: str = "#757575" 32 | heading_icon_color: str = "#0395DE" 33 | bold_color: str = "#585858" 34 | 35 | # Layout customization fields (section heading icons) 36 | section_icon_circle_size: str = "7.8mm" 37 | section_icon_circle_x_offset: str = "-0.5mm" 38 | section_icon_design_size: str = "4mm" 39 | section_icon_design_x_offset: str = "-0.1mm" 40 | section_icon_design_y_offset: str = "-0.4mm" 41 | section_heading_text_margin: str = "-6mm" 42 | 43 | 44 | @dataclass(frozen=True) 45 | class RenderPlan: 46 | """A pure data structure describing how to render a resume.""" 47 | 48 | name: str 49 | mode: RenderMode 50 | config: ResumeConfig 51 | template_name: str | None = None 52 | context: dict[str, Any] | None = None 53 | tex: str | None = None 54 | palette_metadata: dict[str, Any] | None = None 55 | base_path: Path | str = "" 56 | 57 | 58 | @dataclass(frozen=True) 59 | class ValidationResult: 60 | """The result of validating resume data.""" 61 | 62 | is_valid: bool 63 | errors: list[str] 64 | warnings: list[str] 65 | normalized_config: ResumeConfig | None = None 66 | palette_metadata: dict[str, Any] | None = None 67 | 68 | 69 | @dataclass(frozen=True) 70 | class GenerationConfig: 71 | """A complete configuration for generation operations.""" 72 | 73 | # Path configuration 74 | data_dir: str | Path | None = None 75 | output_dir: str | Path | None = None 76 | output_path: str | Path | None = None 77 | paths: Paths | None = None 78 | 79 | # Generation options 80 | template: str | None = None 81 | format: OutputFormat | str = OutputFormat.PDF 82 | open_after: bool = False 83 | preview: bool = False 84 | name: str | None = None 85 | pattern: str = "*" 86 | browser: str | None = None 87 | formats: list[OutputFormat | str] | None = None 88 | 89 | 90 | __all__ = [ 91 | "GenerationConfig", 92 | "RenderMode", 93 | "RenderPlan", 94 | "ResumeConfig", 95 | "ValidationResult", 96 | ] 97 | -------------------------------------------------------------------------------- /tests/unit/core/constants/test_layout.py: -------------------------------------------------------------------------------- 1 | """Tests for core/constants/layout.py - layout constants.""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.core.constants import layout 6 | 7 | 8 | class TestLayoutConstants: 9 | """Test layout constant values.""" 10 | 11 | def test_default_page_dimensions(self) -> None: 12 | """Test default page dimension constants.""" 13 | assert layout.DEFAULT_PAGE_WIDTH_MM == 190 14 | assert layout.DEFAULT_PAGE_HEIGHT_MM == 270 15 | 16 | def test_default_sidebar_width(self) -> None: 17 | """Test default sidebar width constant.""" 18 | assert layout.DEFAULT_SIDEBAR_WIDTH_MM == 60 19 | 20 | def test_default_padding_values(self) -> None: 21 | """Test default padding constants.""" 22 | assert layout.DEFAULT_PADDING == 12 23 | assert layout.DEFAULT_SIDEBAR_PADDING_ADJUSTMENT == -2 24 | assert layout.DEFAULT_SIDEBAR_PADDING == 12 25 | 26 | def test_frame_padding(self) -> None: 27 | """Test frame padding constant.""" 28 | assert layout.DEFAULT_FRAME_PADDING == 10 29 | 30 | def test_cover_letter_padding(self) -> None: 31 | """Test cover letter padding constants.""" 32 | assert layout.DEFAULT_COVER_PADDING_TOP == 10 33 | assert layout.DEFAULT_COVER_PADDING_BOTTOM == 20 34 | assert layout.DEFAULT_COVER_PADDING_HORIZONTAL == 25 35 | 36 | def test_validation_constraints_page_dimensions(self) -> None: 37 | """Test page dimension validation constraints.""" 38 | assert layout.MIN_PAGE_WIDTH_MM == 100 39 | assert layout.MAX_PAGE_WIDTH_MM == 300 40 | assert layout.MIN_PAGE_HEIGHT_MM == 150 41 | assert layout.MAX_PAGE_HEIGHT_MM == 400 42 | 43 | def test_validation_constraints_sidebar(self) -> None: 44 | """Test sidebar validation constraints.""" 45 | assert layout.MIN_SIDEBAR_WIDTH_MM == 30 46 | assert layout.MAX_SIDEBAR_WIDTH_MM == 100 47 | 48 | def test_validation_constraints_padding(self) -> None: 49 | """Test padding validation constraints.""" 50 | assert layout.MIN_PADDING == 0 51 | assert layout.MAX_PADDING == 50 52 | 53 | def test_all_exports(self) -> None: 54 | """Test that __all__ contains all expected constants.""" 55 | expected = [ 56 | "DEFAULT_PAGE_WIDTH_MM", 57 | "DEFAULT_PAGE_HEIGHT_MM", 58 | "DEFAULT_SIDEBAR_WIDTH_MM", 59 | "DEFAULT_PADDING", 60 | "DEFAULT_SIDEBAR_PADDING_ADJUSTMENT", 61 | "DEFAULT_SIDEBAR_PADDING", 62 | "DEFAULT_FRAME_PADDING", 63 | "DEFAULT_COVER_PADDING_TOP", 64 | "DEFAULT_COVER_PADDING_BOTTOM", 65 | "DEFAULT_COVER_PADDING_HORIZONTAL", 66 | "MIN_PAGE_WIDTH_MM", 67 | "MAX_PAGE_WIDTH_MM", 68 | "MIN_PAGE_HEIGHT_MM", 69 | "MAX_PAGE_HEIGHT_MM", 70 | "MIN_SIDEBAR_WIDTH_MM", 71 | "MAX_SIDEBAR_WIDTH_MM", 72 | "MIN_PADDING", 73 | "MAX_PADDING", 74 | ] 75 | assert layout.__all__ == expected 76 | -------------------------------------------------------------------------------- /tests/integration/test_cli_editable_install.py: -------------------------------------------------------------------------------- 1 | """Integration test covering editable installs and CLI execution.""" 2 | 3 | from __future__ import annotations 4 | 5 | import shutil 6 | import subprocess 7 | import sys 8 | import tempfile 9 | from collections.abc import Sequence 10 | from pathlib import Path 11 | 12 | import pytest 13 | 14 | from simple_resume.shell.config import PACKAGE_ROOT 15 | from tests.bdd import Scenario 16 | 17 | 18 | def _checked_call( 19 | command: Sequence[str], 20 | *, 21 | cwd: Path, 22 | suppress_output: bool = False, 23 | ) -> None: 24 | """Run a trusted subprocess command in tests.""" 25 | subprocess.run( # noqa: S603 26 | list(command), 27 | cwd=cwd, 28 | check=True, 29 | stdout=subprocess.DEVNULL if suppress_output else None, 30 | stderr=subprocess.DEVNULL if suppress_output else None, 31 | ) 32 | 33 | 34 | @pytest.mark.integration 35 | def test_generate_html_cli_after_editable_install( 36 | story: Scenario, tmp_path_factory: pytest.TempPathFactory 37 | ) -> None: 38 | story.given("a clean workspace that performs pip install -e ..") 39 | repo_root = PACKAGE_ROOT.parent.parent 40 | work_dir = Path(tempfile.mkdtemp(dir=repo_root)) 41 | try: 42 | try: 43 | _checked_call( 44 | [sys.executable, "-m", "pip", "--version"], 45 | cwd=work_dir, 46 | suppress_output=True, 47 | ) 48 | except subprocess.CalledProcessError: 49 | _checked_call( 50 | [sys.executable, "-m", "ensurepip", "--upgrade"], 51 | cwd=work_dir, 52 | ) 53 | 54 | _checked_call( 55 | [sys.executable, "-m", "pip", "install", "-e", ".."], 56 | cwd=work_dir, 57 | ) 58 | 59 | cli_path = Path(sys.executable).parent / "simple-resume" 60 | assert cli_path.exists(), ( 61 | "simple-resume entry point not found after installation" 62 | ) 63 | 64 | data_dir = work_dir / "data" 65 | input_dir = data_dir / "input" 66 | output_dir = data_dir / "output" 67 | input_dir.mkdir(parents=True) 68 | output_dir.mkdir(parents=True) 69 | 70 | sample_input = repo_root / "sample" / "input" / "sample_1.yaml" 71 | (input_dir / "sample_1.yaml").write_text( 72 | sample_input.read_text(encoding="utf-8"), encoding="utf-8" 73 | ) 74 | 75 | story.when("the simple-resume CLI generates HTML from the sample file") 76 | _checked_call( 77 | [ 78 | str(cli_path), 79 | "generate", 80 | "--format", 81 | "html", 82 | "--data-dir", 83 | str(data_dir), 84 | ], 85 | cwd=work_dir, 86 | ) 87 | 88 | story.then("the expected HTML artifact exists and is non-empty") 89 | generated_html = output_dir / "sample_1.html" 90 | assert generated_html.exists() 91 | assert generated_html.read_text(encoding="utf-8") 92 | finally: 93 | shutil.rmtree(work_dir, ignore_errors=True) 94 | -------------------------------------------------------------------------------- /src/simple_resume/core/latex/fonts.py: -------------------------------------------------------------------------------- 1 | """LaTeX font support functions (pure, no side effects).""" 2 | 3 | from __future__ import annotations 4 | 5 | import textwrap 6 | 7 | 8 | def fontawesome_support_block(fontawesome_dir: str | None) -> str: 9 | r"""Return a LaTeX block that defines the contact icons. 10 | 11 | This is a pure function that generates LaTeX code for FontAwesome 12 | icon support. It uses either fontspec (with font files) or fallback 13 | text-based icons. 14 | 15 | Args: 16 | fontawesome_dir: Path to fontawesome fonts directory, or None for fallback. 17 | 18 | Returns: 19 | LaTeX code block defining icon commands. 20 | 21 | Examples: 22 | >>> block = fontawesome_support_block(None) 23 | >>> r"\\IfFileExists{fontawesome.sty}" in block 24 | True 25 | >>> block = fontawesome_support_block("/fonts/") 26 | >>> r"\\usepackage{fontspec}" in block 27 | True 28 | 29 | """ 30 | fallback = textwrap.dedent( 31 | r""" 32 | \IfFileExists{fontawesome.sty}{% 33 | \usepackage{fontawesome}% 34 | \providecommand{\faLocation}{\faMapMarker}% 35 | }{ 36 | \newcommand{\faPhone}{\textbf{P}} 37 | \newcommand{\faEnvelope}{\textbf{@}} 38 | \newcommand{\faLinkedin}{\textbf{in}} 39 | \newcommand{\faGlobe}{\textbf{W}} 40 | \newcommand{\faGithub}{\textbf{GH}} 41 | \newcommand{\faLocation}{\textbf{A}} 42 | } 43 | """ 44 | ).strip() 45 | 46 | if not fontawesome_dir: 47 | return fallback 48 | 49 | fontspec_block = textwrap.dedent( 50 | rf""" 51 | \usepackage{{fontspec}} 52 | \newfontfamily\FAFreeSolid[ 53 | Path={fontawesome_dir}, 54 | Scale=0.72, 55 | ]{{Font Awesome 6 Free-Solid-900.otf}} 56 | \newfontfamily\FAFreeBrands[ 57 | Path={fontawesome_dir}, 58 | Scale=0.72, 59 | ]{{Font Awesome 6 Brands-Regular-400.otf}} 60 | \newcommand{{\faPhone}}{{% 61 | {{\FAFreeSolid\symbol{{"F095}}}}% 62 | }} 63 | \newcommand{{\faEnvelope}}{{% 64 | {{\FAFreeSolid\symbol{{"F0E0}}}}% 65 | }} 66 | \newcommand{{\faLinkedin}}{{% 67 | {{\FAFreeBrands\symbol{{"F08C}}}}% 68 | }} 69 | \newcommand{{\faGlobe}}{{% 70 | {{\FAFreeSolid\symbol{{"F0AC}}}}% 71 | }} 72 | \newcommand{{\faGithub}}{{% 73 | {{\FAFreeBrands\symbol{{"F09B}}}}% 74 | }} 75 | \newcommand{{\faLocation}}{{% 76 | {{\FAFreeSolid\symbol{{"F3C5}}}}% 77 | }} 78 | """ 79 | ).strip() 80 | 81 | lines: list[str] = [r"\usepackage{iftex}", r"\ifPDFTeX"] 82 | fallback_lines = fallback.splitlines() 83 | lines.extend(f" {line}" if line else "" for line in fallback_lines) 84 | lines.append(r"\else") 85 | fontspec_lines = fontspec_block.splitlines() 86 | lines.extend(f" {line}" if line else "" for line in fontspec_lines) 87 | lines.append(r"\fi") 88 | return "\n".join(lines) 89 | 90 | 91 | __all__ = [ 92 | "fontawesome_support_block", 93 | ] 94 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | - id: check-merge-conflict 10 | - id: check-case-conflict 11 | - id: check-docstring-first 12 | - id: debug-statements 13 | - id: requirements-txt-fixer 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.14.2 17 | hooks: 18 | - id: ruff 19 | args: [--fix, --exit-non-zero-on-fix, --select=PL] 20 | - id: ruff 21 | args: [--fix, --exit-non-zero-on-fix] 22 | - id: ruff-format 23 | 24 | - repo: https://github.com/pre-commit/mirrors-mypy 25 | rev: v1.18.2 26 | hooks: 27 | - id: mypy 28 | additional_dependencies: [types-markdown, flask, oyaml, weasyprint] 29 | args: [--strict, --config-file=pyproject.toml] 30 | files: ^src/ 31 | 32 | - repo: https://github.com/pycqa/bandit 33 | rev: 1.8.6 34 | hooks: 35 | - id: bandit 36 | args: [-r, src/] 37 | exclude: tests/ 38 | 39 | - repo: https://github.com/asottile/blacken-docs 40 | rev: 1.20.0 41 | hooks: 42 | - id: blacken-docs 43 | args: [--line-length=88] 44 | exclude: ^(tests/|wiki/|README\.md$) 45 | 46 | - repo: https://github.com/DavidAnson/markdownlint-cli2 47 | rev: v0.18.1 48 | hooks: 49 | - id: markdownlint-cli2 50 | args: [--config, ".markdownlint.jsonc"] 51 | exclude: ^(wiki/|README\.md$) 52 | 53 | - repo: https://github.com/tcort/markdown-link-check 54 | rev: v3.13.6 55 | hooks: 56 | - id: markdown-link-check 57 | 58 | - repo: local 59 | hooks: 60 | - id: ty 61 | name: ty 62 | entry: uv run ty check 63 | language: system 64 | files: ^src/ 65 | pass_filenames: true 66 | 67 | - id: pytest-check 68 | name: pytest-check 69 | entry: uv run pytest --collect-only -q 70 | language: system 71 | pass_filenames: false 72 | always_run: true 73 | 74 | - id: sdist-contents 75 | name: sdist-contains-templates-and-sources 76 | entry: bash -lc 'set -euo pipefail; mkdir -p dist; rm -f dist/simple_resume-*.tar.gz; UV_CACHE_DIR=.uv-cache uv build --sdist >/dev/null; TARBALL=$(ls -t dist/simple_resume-*.tar.gz | head -n1); tar -tf "$TARBALL" > /tmp/sdist_files.txt; grep -q "simple_resume-.*/simple_resume/shell/assets/templates/html/resume_no_bars.html" /tmp/sdist_files.txt; grep -q "simple_resume-.*/simple_resume/shell/cli/main.py" /tmp/sdist_files.txt; echo sdist contains required source and template files' 77 | language: system 78 | pass_filenames: false 79 | always_run: true 80 | 81 | - id: architecture-tests 82 | name: architecture-layer-separation 83 | entry: bash -c 'uv run pytest tests/architecture/test_layer_separation.py --quiet --tb=short || (echo "Architecture tests failing (expected during refactoring - see CORE_REFACTOR_PLAN.md)" && exit 0)' 84 | language: system 85 | pass_filenames: false 86 | always_run: true 87 | -------------------------------------------------------------------------------- /src/simple_resume/shell/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Provide configuration helpers and shared constants. 3 | 4 | See `wiki/Path-Handling-Guide.md` for path handling conventions. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import atexit 10 | import os 11 | import sys 12 | from contextlib import ExitStack 13 | from importlib import resources 14 | from pathlib import Path 15 | from typing import Union 16 | 17 | from simple_resume.core.paths import Paths 18 | 19 | # Keep an open handle to package resources so they're available even when the 20 | # distribution is zipped (e.g., installed from a wheel). 21 | _asset_stack = ExitStack() 22 | PACKAGE_ROOT = _asset_stack.enter_context( 23 | resources.as_file(resources.files("simple_resume")) 24 | ) 25 | ASSETS_ROOT = PACKAGE_ROOT / "shell" / "assets" 26 | atexit.register(_asset_stack.close) 27 | 28 | PATH_CONTENT = ASSETS_ROOT 29 | TEMPLATE_LOC = ASSETS_ROOT / "templates" 30 | STATIC_LOC = ASSETS_ROOT / "static" 31 | 32 | # Legacy string-based paths preserved for configuration. 33 | PATH_DATA = "resume_private" 34 | PATH_INPUT = f"{PATH_DATA}/input" 35 | PATH_OUTPUT = f"{PATH_DATA}/output" 36 | 37 | 38 | if sys.version_info >= (3, 10): 39 | PathLike = str | os.PathLike[str] 40 | else: 41 | PathLike = Union[str, os.PathLike[str]] 42 | 43 | 44 | def resolve_paths( 45 | data_dir: PathLike | None = None, 46 | *, 47 | content_dir: PathLike | None = None, 48 | templates_dir: PathLike | None = None, 49 | static_dir: PathLike | None = None, 50 | ) -> Paths: 51 | """Return the active data, input, and output paths. 52 | 53 | Args: 54 | data_dir: Optional directory containing `input/` and `output/` folders. 55 | If omitted, the `RESUME_DATA_DIR` environment variable is used. 56 | If neither is provided, the default is `./resume_private`. 57 | content_dir: Optional override for the package content directory. 58 | templates_dir: Optional override for the templates directory. 59 | static_dir: Optional override for the static assets directory. 60 | 61 | Returns: 62 | A `Paths` dataclass with resolved data, template, and static paths. 63 | 64 | """ 65 | base = data_dir or os.environ.get("RESUME_DATA_DIR") or PATH_DATA 66 | base_path = Path(base) 67 | 68 | if data_dir is None: 69 | data_path = Path(PATH_DATA) 70 | input_path = Path(PATH_INPUT) 71 | output_path = Path(PATH_OUTPUT) 72 | else: 73 | data_path = base_path 74 | input_path = base_path / "input" 75 | output_path = base_path / "output" 76 | 77 | content_path = Path(content_dir) if content_dir is not None else PATH_CONTENT 78 | templates_path = ( 79 | Path(templates_dir) if templates_dir is not None else content_path / "templates" 80 | ) 81 | static_path = ( 82 | Path(static_dir) if static_dir is not None else content_path / "static" 83 | ) 84 | 85 | return Paths( 86 | data=data_path, 87 | input=input_path, 88 | output=output_path, 89 | content=content_path, 90 | templates=templates_path, 91 | static=static_path, 92 | ) 93 | 94 | 95 | # Files 96 | FILE_DEFAULT = "sample_1" 97 | -------------------------------------------------------------------------------- /tests/unit/core/test_skills.py: -------------------------------------------------------------------------------- 1 | """Tests for core/skills.py - skill formatting functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from simple_resume.core.skills import format_skill_groups 6 | 7 | 8 | class TestFormatSkillGroups: 9 | """Tests for format_skill_groups function.""" 10 | 11 | def test_none_input_returns_empty_list(self) -> None: 12 | """Test that None input returns empty list.""" 13 | result = format_skill_groups(None) 14 | assert result == [] 15 | 16 | def test_simple_string_input(self) -> None: 17 | """Test single string input.""" 18 | result = format_skill_groups("Python") 19 | assert len(result) == 1 20 | assert result[0]["title"] is None 21 | items = result[0]["items"] 22 | assert isinstance(items, list) 23 | assert "Python" in items 24 | 25 | def test_list_of_strings(self) -> None: 26 | """Test list of string skills.""" 27 | result = format_skill_groups(["Python", "JavaScript", "Go"]) 28 | assert len(result) == 1 29 | assert result[0]["title"] is None 30 | assert result[0]["items"] == ["Python", "JavaScript", "Go"] 31 | 32 | def test_dict_with_categories(self) -> None: 33 | """Test dict with skill categories.""" 34 | skill_data = { 35 | "Programming": ["Python", "JavaScript"], 36 | "Databases": ["PostgreSQL", "Redis"], 37 | } 38 | result = format_skill_groups(skill_data) 39 | assert len(result) == 2 40 | 41 | def test_list_with_dict_entries(self) -> None: 42 | """Test list containing dict entries.""" 43 | skill_data = [ 44 | {"Programming": ["Python", "JavaScript"]}, 45 | {"Databases": ["PostgreSQL"]}, 46 | ] 47 | result = format_skill_groups(skill_data) 48 | assert len(result) == 2 49 | 50 | def test_list_with_mixed_content(self) -> None: 51 | """Test list with both dict and non-dict entries.""" 52 | skill_data = [ 53 | {"Programming": ["Python"]}, 54 | "Standalone Skill", # Non-dict entry 55 | ] 56 | result = format_skill_groups(skill_data) 57 | # Should have two groups: one for Programming, one for standalone 58 | assert len(result) == 2 59 | # One should have title None (the standalone skill) 60 | none_groups = [g for g in result if g["title"] is None] 61 | assert len(none_groups) == 1 62 | 63 | def test_empty_list_returns_empty_list(self) -> None: 64 | """Test empty list input returns empty list.""" 65 | result = format_skill_groups([]) 66 | assert result == [] 67 | 68 | def test_empty_dict_returns_empty_list(self) -> None: 69 | """Test empty dict input returns empty list.""" 70 | result = format_skill_groups({}) 71 | assert result == [] 72 | 73 | def test_dict_with_none_value(self) -> None: 74 | """Test dict with None value for items.""" 75 | skill_data = { 76 | "Programming": None, # None value should be coerced to empty list 77 | "Databases": ["PostgreSQL"], 78 | } 79 | result = format_skill_groups(skill_data) 80 | # Should only have one group (Databases), Programming is skipped 81 | assert len(result) == 1 82 | assert result[0]["title"] == "Databases" 83 | -------------------------------------------------------------------------------- /tests/unit/test_palette_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import pytest 6 | 7 | from simple_resume.core.palettes.registry import Palette, PaletteRegistry 8 | from simple_resume.core.palettes.sources import PalettableRecord 9 | from simple_resume.shell.palettes.loader import ( 10 | get_palette_registry, 11 | load_default_palettes, 12 | reset_palette_registry, 13 | ) 14 | from tests.bdd import Scenario 15 | 16 | 17 | def test_load_default_palettes_returns_palettes(story: Scenario) -> None: 18 | story.given("the built-in palette catalogue is requested") 19 | palettes = load_default_palettes() 20 | 21 | story.then("at least one palette with swatches is returned") 22 | assert palettes, "Expected at least one default palette" 23 | assert all(palette.swatches for palette in palettes) 24 | 25 | 26 | def test_palette_registry_register_and_get(story: Scenario) -> None: 27 | story.given("an empty registry and a palette to register") 28 | registry = PaletteRegistry() 29 | palette = Palette(name="Test", swatches=("#FFFFFF",), source="test") 30 | 31 | story.when("the palette is registered") 32 | registry.register(palette) 33 | 34 | story.then("the palette can be retrieved case-insensitively") 35 | assert registry.get("test") == palette 36 | with pytest.raises(KeyError): 37 | registry.get("missing") 38 | 39 | 40 | def test_global_registry_uses_palettable( 41 | story: Scenario, monkeypatch: pytest.MonkeyPatch 42 | ) -> None: 43 | record = PalettableRecord( 44 | name="Mock Palette", 45 | module="palettable.colorbrewer.sequential", 46 | attribute="Blues_3", 47 | category="sequential", 48 | palette_type="sequential", 49 | size=3, 50 | ) 51 | 52 | def fake_ensure() -> list[PalettableRecord]: 53 | return [record] 54 | 55 | def fake_load(_: PalettableRecord) -> Palette: 56 | return Palette( 57 | name="Mock Palette", swatches=("#000000", "#111111"), source="palettable" 58 | ) 59 | 60 | monkeypatch.setattr( 61 | "simple_resume.shell.palettes.loader.discover_palettable", 62 | fake_ensure, 63 | ) 64 | monkeypatch.setattr( 65 | "simple_resume.shell.palettes.loader.load_palettable_palette", fake_load 66 | ) 67 | reset_palette_registry() 68 | 69 | story.when("the global registry is first initialised") 70 | registry = get_palette_registry() 71 | 72 | story.then("palettable-backed palettes are registered and retrievable") 73 | palette = registry.get("mock palette") 74 | assert palette.source == "palettable" 75 | assert len(palette.swatches) == 2 76 | 77 | 78 | def test_palette_registry_to_json(story: Scenario) -> None: 79 | story.given("a registry with multiple palettes") 80 | registry = PaletteRegistry() 81 | palette1 = Palette(name="Test1", swatches=("#FF0000", "#00FF00"), source="test") 82 | palette2 = Palette(name="Test2", swatches=("#0000FF",), source="test") 83 | 84 | registry.register(palette1) 85 | registry.register(palette2) 86 | 87 | story.when("the registry is serialized to JSON") 88 | result = registry.to_json() 89 | 90 | story.then("a valid JSON string with all palettes is returned") 91 | parsed = json.loads(result) 92 | assert len(parsed) == 2 93 | assert parsed[0]["name"] == "Test1" 94 | assert parsed[0]["swatches"] == ["#FF0000", "#00FF00"] 95 | assert parsed[1]["name"] == "Test2" 96 | assert parsed[1]["swatches"] == ["#0000FF"] 97 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | push: 5 | branches: [ main, development ] 6 | pull_request: 7 | branches: [ main, development ] 8 | 9 | jobs: 10 | security: 11 | name: Security Analysis 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v2 24 | with: 25 | version: "latest" 26 | 27 | - name: Install dependencies 28 | run: uv sync --extra utils --group dev 29 | 30 | - name: Run Bandit (security linter) 31 | run: | 32 | uv run bandit -r src/simple_resume/ -f json -o bandit-report.json || true 33 | uv run bandit -r src/simple_resume/ 34 | 35 | - name: Run Safety (dependency security) 36 | run: | 37 | uv run safety scan --ignore 51457 --save-as json safety-report.json || true 38 | 39 | - name: Upload security reports 40 | uses: actions/upload-artifact@v4 41 | if: always() 42 | with: 43 | name: security-reports 44 | path: | 45 | bandit-report.json 46 | safety-report.json 47 | 48 | complexity: 49 | name: Code Complexity Analysis 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | 55 | - name: Set up Python 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: '3.10' 59 | 60 | - name: Install uv 61 | uses: astral-sh/setup-uv@v2 62 | with: 63 | version: "latest" 64 | 65 | - name: Install dependencies 66 | run: uv sync --extra utils --group dev 67 | 68 | - name: Install complexity tools 69 | run: uv pip install radon xenon 70 | 71 | - name: Run Radon (cyclomatic complexity) 72 | run: | 73 | uv run radon cc src/simple_resume/ --min B 74 | uv run radon mi src/simple_resume/ --min B 75 | uv run radon cc src/simple_resume/ --json > radon-report.json || true 76 | 77 | - name: Run Xenon (complexity monitoring) 78 | run: uv run xenon --max-absolute B --max-modules A --max-average A src/simple_resume/ || true 79 | 80 | - name: Upload complexity reports 81 | uses: actions/upload-artifact@v4 82 | if: always() 83 | with: 84 | name: complexity-reports 85 | path: radon-report.json 86 | 87 | documentation: 88 | name: Documentation Quality 89 | runs-on: ubuntu-latest 90 | 91 | steps: 92 | - uses: actions/checkout@v4 93 | 94 | - name: Set up Python 95 | uses: actions/setup-python@v4 96 | with: 97 | python-version: '3.10' 98 | 99 | - name: Install uv 100 | uses: astral-sh/setup-uv@v2 101 | with: 102 | version: "latest" 103 | 104 | - name: Install dependencies 105 | run: uv sync --extra utils --group dev 106 | 107 | - name: Check docstring coverage 108 | run: | 109 | uv run interrogate src/simple_resume/ --fail-under 80 --quiet || true 110 | 111 | - name: Check README links 112 | run: | 113 | npm install -g markdown-link-check 114 | markdown-link-check README.md || true 115 | 116 | - name: Validate Markdown files 117 | run: | 118 | find . -name "*.md" -not -path "./.git/*" -not -path "./.venv/*" -not -path "./htmlcov/*" -exec sh -c 'uv run python -c "import markdown; content=open(\"\\$1\").read(); markdown.markdown(content); print(\"\\$1 is valid markdown\")"' _ {} \; 119 | -------------------------------------------------------------------------------- /src/simple_resume/core/render/manage.py: -------------------------------------------------------------------------------- 1 | """Core rendering management without external dependencies. 2 | 3 | This module provides pure functions for template rendering setup and coordination 4 | between different rendering backends without any I/O side effects. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import Any 10 | 11 | from jinja2 import Environment, FileSystemLoader 12 | 13 | from simple_resume.core.models import RenderPlan, ValidationResult 14 | 15 | 16 | def get_template_environment(template_path: str) -> Environment: 17 | """Create and return a Jinja2 environment for template rendering. 18 | 19 | Args: 20 | template_path: Path to the templates directory 21 | 22 | Returns: 23 | Jinja2 Environment configured for rendering 24 | 25 | """ 26 | return Environment( 27 | loader=FileSystemLoader(template_path), 28 | autoescape=True, 29 | trim_blocks=True, 30 | lstrip_blocks=True, 31 | ) 32 | 33 | 34 | def prepare_html_generation_request( 35 | render_plan: RenderPlan, 36 | output_path: Any, 37 | **kwargs: Any, 38 | ) -> dict[str, Any]: 39 | """Prepare request data for HTML generation. 40 | 41 | Args: 42 | render_plan: The render plan to use. 43 | output_path: Output file path. 44 | **kwargs: Additional generation options. 45 | 46 | Returns: 47 | Dictionary with request data for shell layer. 48 | 49 | """ 50 | return { 51 | "render_plan": render_plan, 52 | "output_path": output_path, 53 | "filename": getattr(render_plan, "filename", None), 54 | **kwargs, 55 | } 56 | 57 | 58 | def prepare_pdf_generation_request( 59 | render_plan: RenderPlan, 60 | output_path: Any, 61 | open_after: bool = False, 62 | **kwargs: Any, 63 | ) -> dict[str, Any]: 64 | """Prepare request data for PDF generation. 65 | 66 | Args: 67 | render_plan: The render plan to use. 68 | output_path: Output file path. 69 | open_after: Whether to open the PDF after generation. 70 | **kwargs: Additional generation options. 71 | 72 | Returns: 73 | Dictionary with request data for shell layer. 74 | 75 | """ 76 | return { 77 | "render_plan": render_plan, 78 | "output_path": output_path, 79 | "open_after": open_after, 80 | "filename": getattr(render_plan, "filename", None), 81 | "resume_name": getattr(render_plan, "name", "resume"), 82 | **kwargs, 83 | } 84 | 85 | 86 | def validate_render_plan(render_plan: RenderPlan) -> ValidationResult: 87 | """Validate a render plan before generation. 88 | 89 | Args: 90 | render_plan: The render plan to validate. 91 | 92 | Returns: 93 | ValidationResult indicating if the plan is valid. 94 | 95 | """ 96 | errors = [] 97 | 98 | if render_plan.mode is None: 99 | errors.append("Render mode is required") 100 | 101 | if render_plan.config is None: 102 | errors.append("Render config is required") 103 | 104 | if ( 105 | render_plan.mode is not None 106 | and render_plan.mode.value == "html" 107 | and render_plan.template_name is None 108 | ): 109 | errors.append("HTML rendering requires a template name") 110 | 111 | return ValidationResult( 112 | is_valid=len(errors) == 0, 113 | errors=errors, 114 | warnings=[], 115 | normalized_config=None, 116 | palette_metadata=None, 117 | ) 118 | 119 | 120 | __all__ = [ 121 | "get_template_environment", 122 | "prepare_html_generation_request", 123 | "prepare_pdf_generation_request", 124 | "validate_render_plan", 125 | ] 126 | -------------------------------------------------------------------------------- /src/simple_resume/core/protocols.py: -------------------------------------------------------------------------------- 1 | """Protocol definitions for shell layer dependencies. 2 | 3 | These protocols define the interfaces that shell layer implementations 4 | must provide to the core layer, enabling dependency injection without 5 | late-bound imports. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from pathlib import Path 11 | from typing import Any, Protocol, runtime_checkable 12 | 13 | 14 | @runtime_checkable 15 | class TemplateLocator(Protocol): 16 | """Protocol for locating template directories.""" 17 | 18 | def get_template_location(self) -> Path: 19 | """Get the template directory path.""" 20 | ... 21 | 22 | 23 | @runtime_checkable 24 | class EffectExecutor(Protocol): 25 | """Protocol for executing effects.""" 26 | 27 | def execute(self, effect: Any) -> Any: 28 | """Execute a single effect and return its result (type varies).""" 29 | ... 30 | 31 | def execute_many(self, effects: list[Any]) -> None: 32 | """Execute multiple effects.""" 33 | ... 34 | 35 | 36 | @runtime_checkable 37 | class ContentLoader(Protocol): 38 | """Protocol for loading resume content.""" 39 | 40 | def load( 41 | self, 42 | name: str, 43 | paths: Any, 44 | transform_markdown: bool, 45 | ) -> tuple[dict[str, Any], dict[str, Any]]: 46 | """Load content from a YAML file.""" 47 | ... 48 | 49 | 50 | @runtime_checkable 51 | class PdfGenerationStrategy(Protocol): 52 | """Protocol for PDF generation strategies.""" 53 | 54 | def generate( 55 | self, 56 | render_plan: Any, 57 | output_path: Path, 58 | resume_name: str, 59 | filename: str | None = None, 60 | ) -> tuple[Any, int | None]: 61 | """Generate a PDF file.""" 62 | ... 63 | 64 | 65 | @runtime_checkable 66 | class HtmlGenerator(Protocol): 67 | """Protocol for HTML generation.""" 68 | 69 | def generate( 70 | self, 71 | render_plan: Any, 72 | output_path: Path, 73 | filename: str | None = None, 74 | ) -> Any: 75 | """Generate HTML content.""" 76 | ... 77 | 78 | 79 | @runtime_checkable 80 | class FileOpenerService(Protocol): 81 | """Protocol for opening files.""" 82 | 83 | def open_file(self, path: Path, format_type: str | None = None) -> bool: 84 | """Open a file with the system default application.""" 85 | ... 86 | 87 | 88 | @runtime_checkable 89 | class PaletteLoader(Protocol): 90 | """Protocol for loading color palettes.""" 91 | 92 | def load_palette_from_file(self, path: str | Path) -> dict[str, Any]: 93 | """Load a palette from a file.""" 94 | ... 95 | 96 | 97 | @runtime_checkable 98 | class PathResolver(Protocol): 99 | """Protocol for resolving file paths.""" 100 | 101 | def candidate_yaml_path(self, name: str) -> Path: 102 | """Get candidate YAML path for a name.""" 103 | ... 104 | 105 | def resolve_paths_for_read( 106 | self, 107 | paths: Any, 108 | overrides: dict[str, Any], 109 | candidate_path: Path, 110 | ) -> Any: 111 | """Resolve paths for reading operations.""" 112 | ... 113 | 114 | 115 | @runtime_checkable 116 | class LaTeXRenderer(Protocol): 117 | """Protocol for LaTeX rendering.""" 118 | 119 | def get_latex_functions(self) -> tuple[Any, Any, Any]: 120 | """Get LaTeX compilation functions.""" 121 | ... 122 | 123 | 124 | __all__ = [ 125 | "TemplateLocator", 126 | "EffectExecutor", 127 | "ContentLoader", 128 | "PdfGenerationStrategy", 129 | "HtmlGenerator", 130 | "FileOpenerService", 131 | "PaletteLoader", 132 | "PathResolver", 133 | "LaTeXRenderer", 134 | ] 135 | -------------------------------------------------------------------------------- /tests/unit/core/test_palette_resolution.py: -------------------------------------------------------------------------------- 1 | """Tests for core/palettes/resolution.py - pure palette resolution logic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from unittest import mock 6 | 7 | import pytest 8 | 9 | from simple_resume.core.palettes.common import Palette, PaletteSource 10 | from simple_resume.core.palettes.exceptions import ( 11 | PaletteError, 12 | PaletteLookupError, 13 | ) 14 | from simple_resume.core.palettes.registry import PaletteRegistry 15 | from simple_resume.core.palettes.resolution import resolve_palette_config 16 | 17 | 18 | class TestResolvePaletteConfig: 19 | """Tests for resolve_palette_config function.""" 20 | 21 | def test_invalid_source_raises_palette_error(self) -> None: 22 | """Test that invalid source type raises PaletteError.""" 23 | registry = PaletteRegistry() 24 | block = {"source": "invalid_source_type"} 25 | 26 | with pytest.raises(PaletteError, match="Unsupported palette source"): 27 | resolve_palette_config(block, registry=registry) 28 | 29 | def test_registry_without_name_raises_error(self) -> None: 30 | """Test that registry source without name raises error.""" 31 | registry = PaletteRegistry() 32 | block = {"source": "registry"} # Missing 'name' 33 | 34 | with pytest.raises(PaletteLookupError, match="requires 'name'"): 35 | resolve_palette_config(block, registry=registry) 36 | 37 | def test_registry_with_name_returns_colors(self) -> None: 38 | """Test registry source with valid name returns colors.""" 39 | registry = PaletteRegistry() 40 | # Register a test palette 41 | test_palette = Palette( 42 | name="test_palette", 43 | swatches=("#FF0000", "#00FF00", "#0000FF"), 44 | source="test", 45 | metadata={}, 46 | ) 47 | registry.register(test_palette) 48 | 49 | block = {"source": "registry", "name": "test_palette"} 50 | result = resolve_palette_config(block, registry=registry) 51 | 52 | assert result.colors is not None 53 | assert len(result.colors) == 3 54 | assert result.colors[0] == "#FF0000" 55 | 56 | def test_generator_source_returns_colors(self) -> None: 57 | """Test generator source returns generated colors.""" 58 | registry = PaletteRegistry() 59 | block = { 60 | "source": "generator", 61 | "size": 3, 62 | "seed": 42, 63 | } 64 | 65 | result = resolve_palette_config(block, registry=registry) 66 | 67 | assert result.colors is not None 68 | assert len(result.colors) == 3 69 | assert result.fetch_request is None 70 | 71 | def test_remote_source_returns_fetch_request(self) -> None: 72 | """Test remote source returns fetch request.""" 73 | registry = PaletteRegistry() 74 | block = { 75 | "source": "remote", 76 | "keywords": "ocean blue", 77 | "num_results": 3, 78 | } 79 | 80 | result = resolve_palette_config(block, registry=registry) 81 | 82 | assert result.fetch_request is not None 83 | assert result.fetch_request.keywords == "ocean blue" 84 | assert result.fetch_request.num_results == 3 85 | assert result.colors is None 86 | 87 | def test_unsupported_source_value_raises_error(self) -> None: 88 | """Test unsupported source value in enum raises error.""" 89 | registry = PaletteRegistry() 90 | 91 | # Mock PaletteSource.normalize to return an unexpected value 92 | with mock.patch.object( 93 | PaletteSource, "normalize", return_value=mock.MagicMock(value="unknown") 94 | ): 95 | block = {"source": "something"} 96 | 97 | with pytest.raises(PaletteError, match="Unsupported palette source"): 98 | resolve_palette_config(block, registry=registry) 99 | -------------------------------------------------------------------------------- /src/simple_resume/core/effects.py: -------------------------------------------------------------------------------- 1 | """Effect types for the functional core. 2 | 3 | Effects represent side effects (I/O operations) that should be 4 | executed by the shell layer. Core functions return effects instead 5 | of performing I/O directly, enabling pure testing. 6 | 7 | This implements the "Effect System" pattern where: 8 | - Core functions are pure and return descriptions of side effects (Effects) 9 | - Shell layer executes these effects, performing actual I/O 10 | 11 | All effects are immutable (frozen dataclasses) and hashable. 12 | """ 13 | 14 | from abc import ABC, abstractmethod 15 | from dataclasses import dataclass 16 | from pathlib import Path 17 | 18 | 19 | class Effect(ABC): 20 | """Base class for all side effects. 21 | 22 | Effects describe I/O operations without performing them. 23 | They are created by core logic and executed by the shell layer. 24 | """ 25 | 26 | @abstractmethod 27 | def describe(self) -> str: 28 | """Return human-readable description of this effect.""" 29 | pass 30 | 31 | 32 | @dataclass(frozen=True) 33 | class WriteFile(Effect): 34 | """Effect: Write content to a file. 35 | 36 | Attributes: 37 | path: Target file path 38 | content: Content to write (string or bytes) 39 | encoding: Text encoding (used only for string content) 40 | 41 | """ 42 | 43 | path: Path 44 | content: str | bytes 45 | encoding: str = "utf-8" 46 | 47 | def describe(self) -> str: 48 | """Return human-readable description.""" 49 | return f"Write file: {self.path}" 50 | 51 | 52 | @dataclass(frozen=True) 53 | class MakeDirectory(Effect): 54 | """Effect: Create a directory. 55 | 56 | Attributes: 57 | path: Directory path to create 58 | parents: If True, create parent directories as needed 59 | 60 | """ 61 | 62 | path: Path 63 | parents: bool = True 64 | 65 | def describe(self) -> str: 66 | """Return human-readable description.""" 67 | return f"Create directory: {self.path}" 68 | 69 | 70 | @dataclass(frozen=True) 71 | class DeleteFile(Effect): 72 | """Effect: Delete a file. 73 | 74 | Attributes: 75 | path: File path to delete 76 | 77 | """ 78 | 79 | path: Path 80 | 81 | def describe(self) -> str: 82 | """Return human-readable description.""" 83 | return f"Delete file: {self.path}" 84 | 85 | 86 | @dataclass(frozen=True) 87 | class OpenBrowser(Effect): 88 | """Effect: Open a URL in the default web browser. 89 | 90 | Attributes: 91 | url: URL to open (can be http://, https://, or file://) 92 | 93 | """ 94 | 95 | url: str 96 | 97 | def describe(self) -> str: 98 | """Return human-readable description.""" 99 | return f"Open browser: {self.url}" 100 | 101 | 102 | @dataclass(frozen=True) 103 | class RunCommand(Effect): 104 | """Effect: Execute a shell command. 105 | 106 | Attributes: 107 | command: Command to run as a list of arguments 108 | cwd: Working directory for command execution (None for current dir) 109 | 110 | """ 111 | 112 | command: list[str] 113 | cwd: Path | None = None 114 | 115 | def describe(self) -> str: 116 | """Return human-readable description.""" 117 | command_str = " ".join(self.command) 118 | return f"Run command: {command_str}" 119 | 120 | 121 | @dataclass(frozen=True) 122 | class RenderPdf(Effect): 123 | """Effect: Render HTML+CSS to PDF at the target path. 124 | 125 | The shell layer is responsible for providing the rendering engine 126 | (e.g., WeasyPrint). 127 | """ 128 | 129 | html: str 130 | css: str 131 | output_path: Path 132 | base_url: str | None = None 133 | 134 | def describe(self) -> str: 135 | """Return human-readable description.""" 136 | return f"Render PDF to: {self.output_path}" 137 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit Checks 2 | 3 | on: 4 | push: 5 | branches: [ main, development ] 6 | pull_request: 7 | branches: [ main, development ] 8 | 9 | jobs: 10 | pre-commit: 11 | name: Pre-commit Hook Validation 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v2 24 | with: 25 | version: "latest" 26 | 27 | - name: Install dependencies 28 | run: uv sync --extra utils --group dev 29 | 30 | - name: Install pre-commit 31 | run: | 32 | pip install pre-commit 33 | pre-commit install 34 | 35 | - name: Cache pre-commit 36 | uses: actions/cache@v3 37 | with: 38 | path: ~/.cache/pre-commit 39 | key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} 40 | 41 | - name: Run pre-commit on all files 42 | run: pre-commit run --all-files --show-diff-on-failure 43 | 44 | local-dev-setup: 45 | name: Local Development Setup Validation 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v4 53 | with: 54 | python-version: '3.10' 55 | 56 | - name: Install uv 57 | uses: astral-sh/setup-uv@v2 58 | with: 59 | version: "latest" 60 | 61 | - name: Test make commands 62 | run: | 63 | # Install dependencies 64 | uv sync --extra utils --group dev 65 | 66 | # Test make commands (if Makefile exists) 67 | if [ -f "Makefile" ]; then 68 | echo "Testing Makefile commands..." 69 | make help || echo "No help target found" 70 | 71 | # Test lint command 72 | if make -n lint 2>/dev/null; then 73 | echo "make lint command exists" 74 | make lint 75 | else 76 | echo "make lint command not found" 77 | fi 78 | 79 | # Test typecheck command 80 | if make -n typecheck 2>/dev/null; then 81 | echo "make typecheck command exists" 82 | make typecheck 83 | else 84 | echo "make typecheck command not found" 85 | fi 86 | 87 | # Test test command 88 | if make -n test 2>/dev/null; then 89 | echo "make test command exists" 90 | else 91 | echo "make test command not found" 92 | fi 93 | else 94 | echo "No Makefile found, testing direct uv commands..." 95 | 96 | # Test direct uv commands 97 | uv run ruff check src/ 98 | echo "uv run ruff check works" 99 | 100 | uv run mypy src/simple_resume/ 101 | echo "uv run mypy works" 102 | 103 | uv run ty check src/simple_resume/ 104 | echo "uv run ty works" 105 | 106 | uv run pytest 107 | echo "uv run pytest works" 108 | fi 109 | 110 | - name: Validate development environment 111 | run: | 112 | echo "Validating development environment setup..." 113 | 114 | # Check if all required tools are available 115 | uv run ruff --version 116 | uv run mypy --version 117 | uv run ty --version 118 | uv run pytest --version 119 | 120 | echo "All development tools are available" 121 | 122 | # Test imports work correctly 123 | uv run python -c "import simple_resume; print('simple_resume imports correctly')" 124 | uv run python -c "from simple_resume import generate; print('generate function imports correctly')" 125 | uv run python -c "from simple_resume.shell.runtime.content import get_content; print('get_content imports correctly')" 126 | -------------------------------------------------------------------------------- /tests/bdd.py: -------------------------------------------------------------------------------- 1 | """Lightweight helpers for writing Given/When/Then style tests. 2 | 3 | These helpers are intentionally minimal: they record the narrative for a 4 | scenario and expose convenience methods so each test can document the 5 | context, trigger, and expected outcome. They do not alter pytest's control 6 | flow, but they keep the structure consistent and self-documenting. 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from dataclasses import dataclass, field 12 | from typing import Any 13 | 14 | 15 | @dataclass 16 | class Scenario: 17 | """BDD-style scenario recorder used inside tests.""" 18 | 19 | name: str 20 | givens: list[str] = field(default_factory=list) 21 | whens: list[str] = field(default_factory=list) 22 | thens: list[str] = field(default_factory=list) 23 | context: dict[str, Any] = field(default_factory=dict) 24 | notes: list[str] = field(default_factory=list) 25 | 26 | def given(self, clause: str) -> None: 27 | self.givens.append(clause) 28 | 29 | def when(self, clause: str) -> None: 30 | self.whens.append(clause) 31 | 32 | def then(self, clause: str) -> None: 33 | self.thens.append(clause) 34 | 35 | def background(self, **context: Any) -> None: 36 | """Store reusable context variables for the scenario.""" 37 | 38 | self.context.update(context) 39 | 40 | def note(self, clause: str) -> None: 41 | """Capture additional observational notes.""" 42 | 43 | self.notes.append(clause) 44 | 45 | def expect(self, condition: bool, message: str = "Expectation failed") -> None: 46 | """Assert ``condition`` and raise with a scenario summary if it is false.""" 47 | 48 | if not condition: 49 | raise AssertionError(f"{message}\n\n{self.summary()}") 50 | 51 | def fail(self, message: str) -> None: 52 | """Unconditionally fail the scenario with a formatted summary.""" 53 | 54 | raise AssertionError(f"{message}\n\n{self.summary()}") 55 | 56 | def summary(self) -> str: 57 | """Render the scenario narrative as a formatted string.""" 58 | 59 | lines = [f"Scenario: {self.name}"] 60 | for title, items in ( 61 | ("Given", self.givens), 62 | ("When", self.whens), 63 | ("Then", self.thens), 64 | ("Notes", self.notes), 65 | ): 66 | if items: 67 | lines.append(f"{title}:") 68 | lines.extend([f" - {item}" for item in items]) 69 | if self.context: 70 | lines.append("Context:") 71 | for key, value in self.context.items(): 72 | lines.append(f" - {key}: {value!r}") 73 | return "\n".join(lines) 74 | 75 | def __str__(self) -> str: # pragma: no cover - trivial wrapper 76 | """Return the formatted summary for convenient printing.""" 77 | return self.summary() 78 | 79 | def case( 80 | self, 81 | description: str | None = None, 82 | *, 83 | given: str | list[str] | None = None, 84 | when: str | list[str] | None = None, 85 | then: str | list[str] | None = None, 86 | ) -> None: 87 | """Register a scenario with optional Given/When/Then clauses.""" 88 | 89 | desc = description if description is not None else "" 90 | if desc: 91 | self.note(desc) 92 | 93 | def _record(clauses: str | list[str] | None, writer) -> None: 94 | if clauses is None: 95 | return 96 | if isinstance(clauses, str): 97 | writer(clauses) 98 | else: 99 | for clause in clauses: 100 | writer(clause) 101 | 102 | _record(given, self.given) 103 | _record(when, self.when) 104 | _record(then, self.then) 105 | 106 | 107 | def scenario(name: str) -> Scenario: 108 | """Create a scenario helper to emphasize intent inside tests.""" 109 | 110 | return Scenario(name=name) 111 | -------------------------------------------------------------------------------- /wiki/Color-Schemes.md: -------------------------------------------------------------------------------- 1 | # Color Scheme Guide 2 | 3 | This guide explains how to customize the colors of your resume using preset color schemes, custom colors, or palette files. 4 | 5 | ## Color Properties 6 | 7 | The following color properties can be set in the `config` section of the YAML file. All colors must be specified as quoted hexadecimal strings (e.g., `"#0395DE"`). 8 | 9 | - `theme_color`: The primary color for headings and accents. 10 | - `sidebar_color`: The background color of the sidebar. 11 | - `sidebar_text_color`: The text color for the sidebar. If not provided, the application automatically calculates a high-contrast color (either black or white) based on the `sidebar_color` to ensure readability. 12 | - `bar_background_color`: The background color of skill bars. 13 | - `date2_color`: The color for secondary date text. 14 | - `frame_color`: The color of the preview frame in the web preview. 15 | - `bold_color`: The color for bolded text. If not provided, it defaults to a color derived from the `frame_color`. 16 | 17 | ```yaml 18 | config: 19 | theme_color: "#0395DE" 20 | sidebar_color: "#F6F6F6" 21 | sidebar_text_color: "#000000" 22 | bar_background_color: "#DFDFDF" 23 | date2_color: "#616161" 24 | frame_color: "#757575" 25 | bold_color: "#585858" 26 | ``` 27 | 28 | ## Using Color Schemes 29 | 30 | ### Preset Schemes 31 | 32 | To use a built-in color scheme, set the `color_scheme` property in the `config` section of your YAML file. 33 | 34 | ```yaml 35 | config: 36 | color_scheme: "Professional Blue" 37 | ``` 38 | 39 | The following presets are available: 40 | 41 | - **Professional Blue** (default): A high-contrast blue for screen and print. 42 | - **Creative Purple**: A saturated purple for screen viewing. 43 | - **Minimal Dark**: A theme with charcoal and neutral grays. 44 | - **Energetic Orange**: A high-saturation orange. 45 | - **Modern Teal**: A medium-saturation teal. 46 | - **Classic Green**: A WCAG AA compliant green. 47 | 48 | ### Custom Schemes 49 | 50 | To define a custom color scheme, set the color properties directly in the `config` section of your YAML file. 51 | 52 | ```yaml 53 | config: 54 | theme_color: "#0395DE" 55 | sidebar_color: "#F6F6F6" 56 | ``` 57 | 58 | ## Using Palette Files 59 | 60 | Palette files are YAML files that define a set of colors. They are useful for creating reusable color configurations. 61 | 62 | To use a palette file, pass the path to the file as the `--palette` command-line argument. 63 | 64 | ```bash 65 | uv run simple-resume generate --palette resume_private/palettes/my-theme.yaml 66 | ``` 67 | 68 | ### Direct Color Palettes 69 | 70 | A direct color palette file defines the exact colors to be used. 71 | 72 | **`resume_private/palettes/direct-theme.yaml`** 73 | ```yaml 74 | palette: 75 | theme_color: "#4060A0" 76 | sidebar_color: "#D0D8E8" 77 | bar_background_color: "#B8B8B8" 78 | date2_color: "#444444" 79 | sidebar_text_color: "#333333" 80 | frame_color: "#324970" 81 | bold_color: "#263657" 82 | ``` 83 | 84 | ### Generated Palettes 85 | 86 | A generated palette creates a set of colors from a single seed color. 87 | 88 | **`resume_private/palettes/generated-theme.yaml`** 89 | ```yaml 90 | palette: 91 | source: generator 92 | type: hcl 93 | size: 6 94 | seed: 42 95 | hue_range: [200, 220] 96 | luminance_range: [0.25, 0.7] 97 | chroma: 0.25 98 | ``` 99 | 100 | ## Verifying Colors 101 | 102 | To verify your colors, generate the resume in both HTML and PDF formats. Use the HTML preview for quick iteration. For the most accurate representation of printed colors, check the PDF output. 103 | 104 | ```bash 105 | uv run simple-resume generate --format html --open 106 | uv run simple-resume generate --format pdf --open 107 | ``` 108 | 109 | ## Troubleshooting 110 | 111 | - **Colors are not being applied**: Ensure that color values are quoted strings that start with a `#`. 112 | - **Text is hard to read**: Check the contrast between your text and background colors with an online contrast checker. 113 | -------------------------------------------------------------------------------- /src/simple_resume/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Define the Simple Resume public API. 3 | 4 | Symbols listed in `:data:simple_resume.__all__` are covered by the 5 | stability contract, mirroring pandas' curated ``pandas.api`` surface. 6 | Other components (utility helpers, palette plumbing, rendering shell, etc.) 7 | reside under `:mod:simple_resume.internal` and may change without notice. 8 | Import from ``simple_resume.internal`` only if prepared to track upstream changes. 9 | 10 | High-level categories include: 11 | 12 | * **Core models** – `:class:Resume`, `:class:ResumeConfig`, and 13 | `:class:RenderPlan` represent resumes and render plans. 14 | * **Sessions & results** – `:class:ResumeSession`, `:class:SessionConfig`, 15 | `:class:GenerationResult`, and `:class:BatchGenerationResult`. 16 | * **Generation helpers** – ``generate_pdf/html/all/resume`` plus new 17 | convenience wrappers `:func:generate` and `:func:preview` for one-liner 18 | workflows, similar to ``requests`` verb helpers. 19 | * **FCIS Architecture** – Functional core in `:mod:simple_resume.core` 20 | (e.g., `:mod:simple_resume.core.colors`) provides pure functions, 21 | while shell layer handles I/O and side effects. 22 | 23 | Refer to ``docs/reference.md`` for a complete API map and stability labels. 24 | """ 25 | 26 | from __future__ import annotations 27 | 28 | # Exception hierarchy 29 | from simple_resume.core.exceptions import ( 30 | ConfigurationError, 31 | FileSystemError, 32 | GenerationError, 33 | PaletteError, 34 | SessionError, 35 | SimpleResumeError, 36 | TemplateError, 37 | ValidationError, 38 | ) 39 | 40 | # Core classes (data models only - no I/O methods) 41 | from simple_resume.core.models import GenerationConfig, RenderPlan, ResumeConfig 42 | 43 | # Public API namespaces - higher-level generation functions 44 | from simple_resume.shell.generate import ( 45 | generate, 46 | generate_all, 47 | generate_html, 48 | generate_pdf, 49 | generate_resume, 50 | preview, 51 | ) 52 | 53 | # Shell layer I/O operations - these are the primary generation functions 54 | from simple_resume.shell.resume_extensions import ( 55 | generate as resume_generate, 56 | ) 57 | from simple_resume.shell.resume_extensions import ( 58 | to_html, 59 | to_pdf, 60 | ) 61 | 62 | # Rich result objects (lazy-loaded) 63 | from simple_resume.shell.runtime.lazy_import import ( 64 | lazy_BatchGenerationResult as BatchGenerationResult, 65 | ) 66 | 67 | # Session management (lazy-loaded) 68 | from simple_resume.shell.runtime.lazy_import import ( 69 | lazy_create_session as create_session, 70 | ) 71 | from simple_resume.shell.runtime.lazy_import import ( 72 | lazy_GenerationMetadata as GenerationMetadata, 73 | ) 74 | from simple_resume.shell.runtime.lazy_import import ( 75 | lazy_GenerationResult as GenerationResult, 76 | ) 77 | from simple_resume.shell.runtime.lazy_import import ( 78 | lazy_ResumeSession as ResumeSession, 79 | ) 80 | from simple_resume.shell.runtime.lazy_import import ( 81 | lazy_SessionConfig as SessionConfig, 82 | ) 83 | 84 | # Version 85 | __version__ = "0.1.3" 86 | 87 | # Public API exports - organized by functionality 88 | __all__ = [ 89 | "__version__", 90 | # Core models (data only) 91 | "ResumeConfig", 92 | "RenderPlan", 93 | # Exceptions 94 | "SimpleResumeError", 95 | "ValidationError", 96 | "ConfigurationError", 97 | "TemplateError", 98 | "GenerationError", 99 | "PaletteError", 100 | "FileSystemError", 101 | "SessionError", 102 | # Results & sessions 103 | "GenerationResult", 104 | "GenerationMetadata", 105 | "BatchGenerationResult", 106 | "ResumeSession", 107 | "SessionConfig", 108 | "create_session", 109 | # Generation primitives 110 | "GenerationConfig", 111 | "generate_pdf", 112 | "generate_html", 113 | "generate_all", 114 | "generate_resume", 115 | # Shell layer I/O functions 116 | "to_pdf", 117 | "to_html", 118 | "resume_generate", 119 | # Convenience helpers 120 | "generate", 121 | "preview", 122 | ] 123 | -------------------------------------------------------------------------------- /sample/input/sample_2.yaml: -------------------------------------------------------------------------------- 1 | template: resume_no_bars 2 | 3 | full_name: John Doe 4 | 5 | # Address should be a list (with only one element if needed) 6 | address: 7 | - City 8 | 9 | phone: 555 555 5555 10 | web: http://web.com # It will automatically add an hiperlink 11 | linkedin: in/user.com # It will automatically add an hiperlink 12 | email: email@email.com # It will automatically add an hiperlink 13 | 14 | image_uri: images/default_profile_2.png 15 | 16 | # To allow multilanguage 17 | titles: 18 | contact: Contact 19 | certification: Certifications 20 | expertise: Expertise 21 | programming: Programming 22 | keyskills: Key Skills 23 | 24 | description: | 25 | This is where you can write your pitch. It is written using **Markdown**. You can include bold, italics, links... 26 | 27 | Take a look at the [cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). 28 | 29 | If you don't want this section you can just comment it. The same applies for the profile picture. 30 | 31 | 32 | 33 | expertise: 34 | - Data Engineering 35 | - Cloud Platforms 36 | - Artificial Intelligence 37 | 38 | # Max value will be sidebar_width - 2*padding = 36 by default 39 | certification: 40 | - Certificate1 41 | - Certificate2 42 | 43 | keyskills: 44 | - Spark / Dask 45 | - SQL 46 | - Luigi / Airflow 47 | - AWS / Azure 48 | - Python 49 | - Pandas 50 | - Tensorflow / Keras 51 | 52 | body: 53 | Experience: 54 | - 55 | start: 01/2019 56 | end: Actual 57 | title: Developer 58 | title_link: http://google.com 59 | company: Basetis 60 | company_link: https://www.basetis.com/ 61 | description: | # The | Allows to have markdown below 62 | - Description line 1 63 | - Description line 2 64 | - Description line 3 65 | - 66 | start: 09/2018 67 | end: 12/2018 68 | title: Backend 69 | company: Basetis 70 | description: | 71 | Description are written using **Markdown**. You can include bold, italics, links... 72 | 73 | Take a look at the [cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). 74 | 75 | Education: 76 | - 77 | start: 09/2010 78 | end: 09/2015 79 | title: Engineering 80 | company: UPC 81 | description: Simple description 82 | 83 | - 84 | start: 09/2010 85 | end: 09/2015 86 | title: Engineering 87 | company: UPC 88 | description: Simple description 89 | 90 | - 91 | start: 09/2010 92 | end: 09/2015 93 | title: Engineering 94 | company: UPC 95 | description: Simple description 96 | 97 | # You can comment sections you don't want to show 98 | # - 99 | # start: 09/1901 100 | # end: 09/1900 101 | # title: Elementary school 102 | # company: Funny name 103 | # description: Simple description 104 | 105 | config: 106 | # Document colors 107 | theme_color: "#0395DE" # Cool blue 108 | sidebar_color: "#F6F6F6" # Light grey 109 | bar_background_color: "#DFDFDF" # Darker light grey 110 | date2_color: "#616161" # Grey 111 | 112 | # Sizes 113 | padding: 12 114 | page_width: 210 115 | page_height: 297 116 | 117 | sidebar_width: 60 118 | sidebar_padding_top: 5 119 | profile_image_padding_bottom: 6 120 | 121 | pitch_padding_top: 4 122 | pitch_padding_bottom: 4 123 | pitch_padding_left: 4 124 | 125 | h2_padding_left: 4 126 | h3_padding_top: 5 127 | date_container_width: 13 128 | date_container_padding_left: 8 129 | description_container_padding_left: 3 130 | 131 | # Frame for previewing 132 | frame_padding: 10 133 | frame_color: "#757575" # Grey 600 134 | 135 | # Cover letter paddings 136 | cover_padding_top: 10 137 | cover_padding_bottom: 20 138 | cover_padding_h: 25 139 | 140 | # Section heading icon layout customization 141 | section_icon_circle_size: "7.8mm" 142 | section_icon_circle_x_offset: "-0.5mm" 143 | section_icon_design_size: "4mm" 144 | section_icon_design_x_offset: "-0.1mm" 145 | section_icon_design_y_offset: "-0.4mm" 146 | section_heading_text_margin: "-6mm" 147 | -------------------------------------------------------------------------------- /wiki/Getting-Started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide provides a walkthrough for installing `simple-resume`, creating a resume from a YAML file, and generating it as a PDF or HTML document. 4 | 5 | ## 1. Installation and Setup 6 | 7 | This guide is for setting up a local development environment. For standard user installation, see the [README.md](../README.md). 8 | 9 | First, ensure you have Python 3.9+ and `uv` installed. Then, clone the repository and install the required dependencies: 10 | 11 | ```bash 12 | git clone https://github.com/athola/simple-resume.git 13 | cd simple-resume 14 | uv sync 15 | ``` 16 | 17 | ## 2. Create and Generate Your Resume 18 | 19 | ### Start with a Sample 20 | 21 | To get started, copy one of the sample files to the `resume_private/input` directory. 22 | 23 | ```bash 24 | cp sample/input/sample_1.yaml resume_private/input/my_resume.yaml 25 | ``` 26 | 27 | ### Edit the Content 28 | 29 | Open `resume_private/input/my_resume.yaml` in a text editor and replace the placeholder content with your own information. 30 | 31 | ```yaml 32 | full_name: "Your Name" 33 | job_title: "Your Job Title" 34 | email: "your.email@example.com" 35 | # ... and so on 36 | ``` 37 | 38 | ### Generate the Output 39 | 40 | Use the `generate` command to create your resume. The `--open` flag will open the generated file in your default browser or PDF viewer. 41 | 42 | ```bash 43 | # Generate an HTML resume 44 | uv run simple-resume generate --format html --open 45 | 46 | # Generate a PDF resume 47 | uv run simple-resume generate --format pdf --open 48 | ``` 49 | 50 | To generate multiple resumes at once, use the `--data-dir` argument to specify a directory containing your YAML files. 51 | 52 | ## 3. Customize Your Resume 53 | 54 | The appearance of your resume can be customized by editing the YAML file. 55 | 56 | ### Change the Template 57 | 58 | To change the layout of your resume, specify a different `template` in your YAML file. 59 | 60 | ```yaml 61 | template: resume_no_bars # A minimalist design 62 | # template: resume_with_bars # A design with skill level bars 63 | ``` 64 | 65 | ### Template Variables 66 | 67 | Templates have access to the following variables from your YAML file: 68 | 69 | | Variable | Description | 70 | |----------|-------------| 71 | | `name` | Full name | 72 | | `title` | Job title | 73 | | `contact` | Contact information | 74 | | `summary` | Professional summary | 75 | | `experience` | Work experience list | 76 | | `education` | Education list | 77 | | `skills` | Skills list | 78 | | `colors` | Color palette values | 79 | 80 | ### Apply a Color Scheme 81 | 82 | To apply a color scheme, add the `color_scheme` key to the `config` section of your YAML file. 83 | 84 | ```yaml 85 | config: 86 | color_scheme: "Professional Blue" 87 | ``` 88 | 89 | For more information on color schemes, see the [Color Schemes guide](Color-Schemes.md). 90 | 91 | ## Next Steps 92 | 93 | - [Markdown Guide](Markdown-Guide.md): Learn how to format content with Markdown. 94 | - [Examples](../sample/): Review the sample resume files for more examples. 95 | 96 | ## Sample Files 97 | 98 | The `sample/` directory contains the following examples: 99 | 100 | - **`sample_1.yaml`** and **`sample_2.yaml`**: Basic resume examples. 101 | - **`sample_multipage_demo.yaml`**: A multi-page resume with proper pagination. 102 | - **`sample_palette_demo.yaml`**: A demonstration of various color schemes. 103 | - **`sample_dark_sidebar.yaml`**: A dark theme with a sidebar layout. 104 | - **`sample_latex.yaml`**: Examples of LaTeX-specific formatting. 105 | - **`sample_contrast_demo.yaml`**: Examples of color contrast accessibility. 106 | 107 | ## Troubleshooting 108 | 109 | - **PDF Generation Fails**: PDF generation depends on WeasyPrint or LaTeX. Ensure that their dependencies are installed correctly. 110 | - **YAML Syntax Errors**: YAML is sensitive to indentation. Use a YAML linter to check for syntax errors. 111 | - **Template Not Found**: Ensure that the `template` name in your YAML file matches an available template (e.g., `resume_base`, `resume_no_bars`). 112 | 113 | For other issues, please open an issue on [GitHub](https://github.com/athola/simple-resume/issues). 114 | -------------------------------------------------------------------------------- /wiki/Development-Guide.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | This guide explains how to set up a local development environment for `simple-resume`. 4 | 5 | First, fork and clone the repository. This project uses `uv` for dependency management. To install all development and optional dependencies, run the following command: 6 | 7 | ```bash 8 | uv sync --dev --extra utils 9 | ``` 10 | 11 | ## Running Code Quality Checks 12 | 13 | The `Makefile` provides commands for running common development tasks. 14 | 15 | ### Running Tests 16 | 17 | To run the full test suite, including unit and integration tests, use the following command: 18 | 19 | ```bash 20 | make test 21 | ``` 22 | 23 | ### Linting and Formatting 24 | 25 | The `ruff` tool is used for linting and code formatting. To run all checks, including linting, formatting, type-checking, and security scans, use the following command: 26 | 27 | ```bash 28 | make check-all 29 | make validate # Runs secondary validation, like checking the README preview and release assets. 30 | ``` 31 | 32 | Individual checks can also be run: 33 | 34 | ```bash 35 | # Run the linter 36 | make lint 37 | 38 | # Format the code 39 | make format 40 | ``` 41 | 42 | ## Architecture 43 | 44 | The project uses a **functional core, imperative shell** pattern to separate pure data transformations from code that produces side effects. 45 | 46 | - **Functional Core (`src/simple_resume/core/`)**: Contains pure functions for data manipulation. These functions are predictable and do not have side effects, which makes them easy to test. 47 | - **Imperative Shell (`src/simple_resume/shell/`)**: Manages I/O, user interaction, and integrations with external services. Side effects are handled in this layer. 48 | - **API Surface (`src/simple_resume/`)**: The public API provides an interface for reading and writing resume data (e.g., `read_yaml()`, `to_pdf()`). 49 | 50 | ### Key Components 51 | 52 | - **`Resume` class**: The main entry point for the public API. It provides methods such as `read_yaml()`, `to_pdf()`, and `to_html()`. 53 | - **`ResumeSession`**: A class that manages configuration settings for consistency across multiple operations. 54 | - **Palette System**: A system for color management that supports built-in themes, remote palettes, and generated color schemes. 55 | - **Template System**: Uses Jinja2 for HTML and LaTeX templating. 56 | - **Validation**: A validation layer that provides error messages for configuration and data issues. 57 | 58 | ## Testing 59 | 60 | The project maintains over 85% test coverage with unit and integration tests. 61 | 62 | ```bash 63 | # Run all tests 64 | make test 65 | 66 | # Run specific test categories 67 | make test-unit 68 | make test-integration 69 | 70 | # Run tests with coverage 71 | make test-coverage 72 | ``` 73 | 74 | ## Documentation 75 | 76 | Our documentation is organized into these key areas: 77 | 78 | - **[Architecture Decisions (ADRs)](../architecture/)**: Records the history and reasoning behind significant technical decisions. 79 | - **[Usage Guide](Usage-Guide.md)**: Explains how to use the library's features with practical examples. 80 | - **[Migration Guide](Migration-Guide-Generate-Module.md)**: Provides instructions for upgrading between major versions. 81 | - **[Lazy Loading Guide](Lazy-Loading-Guide.md)**: Describes performance optimization strategies. 82 | - **[API Reference](../docs/reference.md)**: A reference for the public API. 83 | 84 | When contributing to the documentation, please adhere to the following best practices: 85 | 86 | - Write clearly and concisely. 87 | - Add code examples for all major features. 88 | - Document both lazy and eager loading approaches. 89 | - Explain performance trade-offs. 90 | - Provide migration paths for breaking changes. 91 | - Update ADRs for significant architectural changes. 92 | - Keep docstrings current. 93 | 94 | ## Contributing 95 | 96 | Please read the [Contributing Guide](Contributing.md) for information on the development process, coding standards, and how to submit pull requests. 97 | 98 | ### Development Workflow 99 | 100 | 1. Create a feature branch from `main`. 101 | 2. Make your changes and add tests. 102 | 3. Run `make check-all` and `make validate` to ensure that all automated checks pass. 103 | 4. Submit a pull request with a clear description of your changes. 104 | -------------------------------------------------------------------------------- /src/simple_resume/shell/runtime/lazy.py: -------------------------------------------------------------------------------- 1 | """Lazy-loaded generation functions for optimal import performance. 2 | 3 | This module provides thin wrappers around the core generation functions 4 | with lazy loading to reduce startup memory footprint. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import importlib 10 | from functools import lru_cache 11 | from pathlib import Path 12 | from types import ModuleType 13 | from typing import Any 14 | 15 | from simple_resume.core.models import GenerationConfig 16 | 17 | 18 | class _LazyRuntimeLoader: 19 | """Lazy loader for runtime generation functions.""" 20 | 21 | def __init__(self) -> None: 22 | self._core: ModuleType | None = None 23 | self._loaded = False 24 | 25 | def _load_core(self) -> ModuleType: 26 | """Load runtime module if not already loaded.""" 27 | if not self._loaded: 28 | self._core = importlib.import_module(".generate", package=__package__) 29 | self._loaded = True 30 | # _core is set when _loaded is True 31 | return self._core # type: ignore[return-value] 32 | 33 | @property 34 | def generate_pdf(self) -> Any: 35 | """Get generate_pdf function from core module.""" 36 | return self._load_core().generate_pdf 37 | 38 | @property 39 | def generate_html(self) -> Any: 40 | """Get generate_html function from core module.""" 41 | return self._load_core().generate_html 42 | 43 | @property 44 | def generate_all(self) -> Any: 45 | """Get generate_all function from core module.""" 46 | return self._load_core().generate_all 47 | 48 | @property 49 | def generate_resume(self) -> Any: 50 | """Get generate_resume function from core module.""" 51 | return self._load_core().generate_resume 52 | 53 | @property 54 | def generate(self) -> Any: 55 | """Get generate function from core module.""" 56 | return self._load_core().generate 57 | 58 | @property 59 | def preview(self) -> Any: 60 | """Get preview function from core module.""" 61 | return self._load_core().preview 62 | 63 | 64 | @lru_cache(maxsize=1) 65 | def _get_lazy_core() -> _LazyRuntimeLoader: 66 | """Return a shared lazy loader instance without module-level globals.""" 67 | return _LazyRuntimeLoader() 68 | 69 | 70 | def generate_pdf( 71 | config: GenerationConfig, 72 | **config_overrides: Any, 73 | ) -> Any: 74 | """Generate PDF resumes using a configuration object (lazy-loaded wrapper).""" 75 | return _get_lazy_core().generate_pdf(config, **config_overrides) 76 | 77 | 78 | def generate_html( 79 | config: GenerationConfig, 80 | **config_overrides: Any, 81 | ) -> Any: 82 | """Generate HTML resumes using a configuration object (lazy-loaded wrapper).""" 83 | return _get_lazy_core().generate_html(config, **config_overrides) 84 | 85 | 86 | def generate_all( 87 | config: GenerationConfig, 88 | **config_overrides: Any, 89 | ) -> Any: 90 | """Generate resumes in all specified formats (lazy-loaded wrapper).""" 91 | return _get_lazy_core().generate_all(config, **config_overrides) 92 | 93 | 94 | def generate_resume( 95 | config: GenerationConfig, 96 | **config_overrides: Any, 97 | ) -> Any: 98 | """Generate a resume in a specific format (lazy-loaded wrapper).""" 99 | return _get_lazy_core().generate_resume(config, **config_overrides) 100 | 101 | 102 | def generate( 103 | source: str | Path, 104 | options: Any | None = None, 105 | **overrides: Any, 106 | ) -> Any: 107 | """Render one or more formats for the same source (lazy-loaded wrapper).""" 108 | return _get_lazy_core().generate(source, options, **overrides) 109 | 110 | 111 | def preview( 112 | source: str | Path, 113 | *, 114 | data_dir: str | Path | None = None, 115 | template: str | None = None, 116 | browser: str | None = None, 117 | open_after: bool = True, 118 | **overrides: Any, 119 | ) -> Any: 120 | """Render a single resume to HTML with preview defaults (lazy-loaded wrapper).""" 121 | return _get_lazy_core().preview( 122 | source, 123 | data_dir=data_dir, 124 | template=template, 125 | browser=browser, 126 | open_after=open_after, 127 | **overrides, 128 | ) 129 | 130 | 131 | __all__ = [ 132 | "generate_pdf", 133 | "generate_html", 134 | "generate_all", 135 | "generate_resume", 136 | "generate", 137 | "preview", 138 | ] 139 | -------------------------------------------------------------------------------- /src/simple_resume/core/generate/plan.py: -------------------------------------------------------------------------------- 1 | """Provide pure generation planning helpers for CLI and session shells.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copy 6 | from collections.abc import Sequence 7 | from dataclasses import dataclass 8 | from enum import Enum 9 | from pathlib import Path 10 | from typing import Any 11 | 12 | from simple_resume.core.constants import OutputFormat 13 | from simple_resume.core.models import GenerationConfig 14 | from simple_resume.core.paths import Paths 15 | 16 | 17 | class CommandType(str, Enum): 18 | """Define kinds of generation commands the shell can execute.""" 19 | 20 | SINGLE = "single" 21 | BATCH_SINGLE = "batch_single" 22 | BATCH_ALL = "batch_all" 23 | 24 | 25 | @dataclass(frozen=True) 26 | class GenerationCommand: 27 | """Define a pure description of a generation step.""" 28 | 29 | kind: CommandType 30 | format: OutputFormat | None 31 | config: GenerationConfig 32 | overrides: dict[str, Any] 33 | 34 | 35 | @dataclass(frozen=True) 36 | class GeneratePlanOptions: 37 | """Define normalized inputs for planning CLI/session work.""" 38 | 39 | name: str | None 40 | data_dir: Path | None 41 | template: str | None 42 | output_path: Path | None 43 | output_dir: Path | None 44 | preview: bool 45 | open_after: bool 46 | browser: str | None 47 | formats: Sequence[OutputFormat] 48 | overrides: dict[str, Any] 49 | paths: Paths | None = None 50 | pattern: str = "*" 51 | 52 | 53 | def build_generation_plan(options: GeneratePlanOptions) -> list[GenerationCommand]: 54 | """Return the deterministic commands needed to satisfy the request.""" 55 | if not options.formats: 56 | raise ValueError("At least one output format must be specified") 57 | 58 | plan: list[GenerationCommand] = [] 59 | overrides = copy.deepcopy(options.overrides) 60 | 61 | if options.name: 62 | for format_type in options.formats: 63 | plan.append( 64 | GenerationCommand( 65 | kind=CommandType.SINGLE, 66 | format=format_type, 67 | config=GenerationConfig( 68 | name=options.name, 69 | data_dir=options.data_dir, 70 | template=options.template, 71 | format=format_type, 72 | output_path=options.output_path, 73 | open_after=options.open_after, 74 | preview=options.preview, 75 | browser=options.browser, 76 | paths=options.paths, 77 | pattern=options.pattern, 78 | ), 79 | overrides=copy.deepcopy(overrides), 80 | ) 81 | ) 82 | return plan 83 | 84 | if len(options.formats) == 1: 85 | format_type = options.formats[0] 86 | plan.append( 87 | GenerationCommand( 88 | kind=CommandType.BATCH_SINGLE, 89 | format=format_type, 90 | config=GenerationConfig( 91 | data_dir=options.data_dir, 92 | template=options.template, 93 | output_dir=options.output_dir, 94 | open_after=options.open_after, 95 | preview=options.preview, 96 | browser=options.browser, 97 | paths=options.paths, 98 | pattern=options.pattern, 99 | ), 100 | overrides=copy.deepcopy(overrides), 101 | ) 102 | ) 103 | return plan 104 | 105 | plan.append( 106 | GenerationCommand( 107 | kind=CommandType.BATCH_ALL, 108 | format=None, 109 | config=GenerationConfig( 110 | data_dir=options.data_dir, 111 | template=options.template, 112 | output_dir=options.output_dir, 113 | open_after=options.open_after, 114 | preview=options.preview, 115 | browser=options.browser, 116 | formats=list(options.formats), 117 | paths=options.paths, 118 | pattern=options.pattern, 119 | ), 120 | overrides=copy.deepcopy(overrides), 121 | ) 122 | ) 123 | return plan 124 | 125 | 126 | __all__ = [ 127 | "CommandType", 128 | "GenerationCommand", 129 | "GeneratePlanOptions", 130 | "build_generation_plan", 131 | ] 132 | -------------------------------------------------------------------------------- /src/simple_resume/shell/assets/templates/html/cover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 32 | 33 | 34 | 125 | 126 | 127 | 128 | 129 | 130 | {% if preview %}
{% endif %} 131 | 132 |
133 |
134 |

{{ full_name }}

135 | 136 | {% if description %} 137 |
138 | {% autoescape false %} 139 | {{ description }} 140 | {% endautoescape %} 141 |
142 | {% endif %} 143 | 144 |
145 |
146 | 147 | 148 | {% if preview %}
{% endif %} 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /tests/unit/core/generate/test_plan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from simple_resume.core.constants import OutputFormat 8 | from simple_resume.core.generate.plan import ( 9 | CommandType, 10 | GeneratePlanOptions, 11 | build_generation_plan, 12 | ) 13 | from tests.bdd import Scenario 14 | 15 | 16 | class TestBuildGenerationPlan: 17 | def test_single_resume_multiple_formats( 18 | self, 19 | tmp_path: Path, 20 | story: Scenario, 21 | ) -> None: 22 | story.given("a single resume request that needs multiple formats") 23 | options = GeneratePlanOptions( 24 | name="candidate", 25 | data_dir=tmp_path, 26 | template="modern", 27 | output_path=tmp_path / "candidate.pdf", 28 | output_dir=None, 29 | preview=False, 30 | open_after=False, 31 | browser=None, 32 | formats=[OutputFormat.PDF, OutputFormat.HTML], 33 | overrides={"theme_color": "#123456"}, 34 | ) 35 | 36 | story.when("the plan builder runs") 37 | commands = build_generation_plan(options) 38 | 39 | story.then("a single command is generated per requested format") 40 | assert len(commands) == 2 41 | assert all(cmd.kind is CommandType.SINGLE for cmd in commands) 42 | pdf_command = commands[0] 43 | assert pdf_command.config.name == "candidate" 44 | assert pdf_command.config.output_path == tmp_path / "candidate.pdf" 45 | assert pdf_command.overrides["theme_color"] == "#123456" 46 | 47 | def test_batch_plan_single_format(self, tmp_path: Path, story: Scenario) -> None: 48 | story.given("batch generation should produce a single format") 49 | output_dir = tmp_path / "output" 50 | options = GeneratePlanOptions( 51 | name=None, 52 | data_dir=tmp_path, 53 | template="modern", 54 | output_path=None, 55 | output_dir=output_dir, 56 | preview=True, 57 | open_after=True, 58 | browser="firefox", 59 | formats=[OutputFormat.PDF], 60 | overrides={}, 61 | ) 62 | 63 | commands = build_generation_plan(options) 64 | 65 | story.then("one batch-single command is produced") 66 | assert len(commands) == 1 67 | command = commands[0] 68 | assert command.kind is CommandType.BATCH_SINGLE 69 | assert command.format is OutputFormat.PDF 70 | assert command.config.output_dir == output_dir 71 | assert command.config.preview is True 72 | assert command.config.open_after is True 73 | 74 | def test_batch_plan_multiple_formats(self, tmp_path: Path, story: Scenario) -> None: 75 | story.given("batch generation should produce multiple formats at once") 76 | options = GeneratePlanOptions( 77 | name=None, 78 | data_dir=tmp_path, 79 | template=None, 80 | output_path=None, 81 | output_dir=tmp_path / "out", 82 | preview=False, 83 | open_after=False, 84 | browser=None, 85 | formats=[OutputFormat.PDF, OutputFormat.HTML], 86 | overrides={"color_scheme": "ocean"}, 87 | ) 88 | 89 | commands = build_generation_plan(options) 90 | 91 | story.then("a single batch-all command describes the work") 92 | assert len(commands) == 1 93 | command = commands[0] 94 | assert command.kind is CommandType.BATCH_ALL 95 | assert command.format is None 96 | assert command.config.formats == [OutputFormat.PDF, OutputFormat.HTML] 97 | assert command.overrides["color_scheme"] == "ocean" 98 | 99 | def test_raises_error_for_empty_formats( 100 | self, tmp_path: Path, story: Scenario 101 | ) -> None: 102 | story.given("generation options with no formats specified") 103 | options = GeneratePlanOptions( 104 | name="candidate", 105 | data_dir=tmp_path, 106 | template=None, 107 | output_path=None, 108 | output_dir=tmp_path / "out", 109 | preview=False, 110 | open_after=False, 111 | browser=None, 112 | formats=[], # Empty formats list 113 | overrides={}, 114 | ) 115 | 116 | story.when("the plan builder runs with no formats") 117 | with pytest.raises(ValueError, match="At least one output format"): 118 | build_generation_plan(options) 119 | -------------------------------------------------------------------------------- /tests/unit/core/test_file_operations.py: -------------------------------------------------------------------------------- 1 | """Tests for core/file_operations.py - pure file discovery functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | 7 | from simple_resume.core.file_operations import ( 8 | find_yaml_files, 9 | get_resume_name_from_path, 10 | iterate_yaml_files, 11 | ) 12 | 13 | 14 | class TestFindYamlFiles: 15 | """Tests for find_yaml_files function.""" 16 | 17 | def test_finds_yaml_and_yml_files(self, tmp_path: Path) -> None: 18 | """Test finding both .yaml and .yml files.""" 19 | (tmp_path / "resume1.yaml").write_text("config: {}") 20 | (tmp_path / "resume2.yml").write_text("config: {}") 21 | (tmp_path / "readme.txt").write_text("not yaml") 22 | 23 | result = find_yaml_files(tmp_path) 24 | 25 | assert len(result) == 2 26 | assert any(f.name == "resume1.yaml" for f in result) 27 | assert any(f.name == "resume2.yml" for f in result) 28 | 29 | def test_returns_empty_list_for_nonexistent_dir(self) -> None: 30 | """Test returns empty list when directory doesn't exist.""" 31 | nonexistent = Path("/nonexistent/directory") 32 | result = find_yaml_files(nonexistent) 33 | assert result == [] 34 | 35 | def test_filters_by_pattern(self, tmp_path: Path) -> None: 36 | """Test filtering files by pattern.""" 37 | (tmp_path / "john.yaml").write_text("config: {}") 38 | (tmp_path / "jane.yaml").write_text("config: {}") 39 | (tmp_path / "sample.yaml").write_text("config: {}") 40 | 41 | result = find_yaml_files(tmp_path, pattern="john") 42 | 43 | assert len(result) == 1 44 | assert result[0].name == "john.yaml" 45 | 46 | def test_sorts_results(self, tmp_path: Path) -> None: 47 | """Test results are sorted.""" 48 | (tmp_path / "zebra.yaml").write_text("config: {}") 49 | (tmp_path / "apple.yaml").write_text("config: {}") 50 | (tmp_path / "middle.yaml").write_text("config: {}") 51 | 52 | result = find_yaml_files(tmp_path) 53 | 54 | names = [f.name for f in result] 55 | assert names == sorted(names) 56 | 57 | 58 | class TestIterateYamlFiles: 59 | """Tests for iterate_yaml_files generator function.""" 60 | 61 | def test_yields_yaml_files(self, tmp_path: Path) -> None: 62 | """Test yielding YAML files.""" 63 | (tmp_path / "resume1.yaml").write_text("config: {}") 64 | (tmp_path / "resume2.yml").write_text("config: {}") 65 | 66 | result = list(iterate_yaml_files(tmp_path)) 67 | 68 | assert len(result) == 2 69 | assert all(isinstance(f, Path) for f in result) 70 | 71 | def test_is_generator(self, tmp_path: Path) -> None: 72 | """Test that iterate_yaml_files returns a generator.""" 73 | (tmp_path / "test.yaml").write_text("config: {}") 74 | 75 | result = iterate_yaml_files(tmp_path) 76 | 77 | # Verify it's a generator (has __next__) 78 | assert hasattr(result, "__next__") 79 | 80 | def test_yields_with_pattern(self, tmp_path: Path) -> None: 81 | """Test yielding files matching pattern.""" 82 | (tmp_path / "john.yaml").write_text("config: {}") 83 | (tmp_path / "jane.yaml").write_text("config: {}") 84 | 85 | result = list(iterate_yaml_files(tmp_path, pattern="john")) 86 | 87 | assert len(result) == 1 88 | assert result[0].name == "john.yaml" 89 | 90 | 91 | class TestGetResumeNameFromPath: 92 | """Tests for get_resume_name_from_path function.""" 93 | 94 | def test_extracts_name_from_yaml_path(self) -> None: 95 | """Test extracting name from .yaml file.""" 96 | path = Path("/some/dir/john_doe.yaml") 97 | result = get_resume_name_from_path(path) 98 | assert result == "john_doe" 99 | 100 | def test_extracts_name_from_yml_path(self) -> None: 101 | """Test extracting name from .yml file.""" 102 | path = Path("/some/dir/jane_smith.yml") 103 | result = get_resume_name_from_path(path) 104 | assert result == "jane_smith" 105 | 106 | def test_extracts_name_with_special_characters(self) -> None: 107 | """Test extracting name with underscores and hyphens.""" 108 | path = Path("/dir/resume-2024_final.yaml") 109 | result = get_resume_name_from_path(path) 110 | assert result == "resume-2024_final" 111 | 112 | def test_extracts_from_relative_path(self) -> None: 113 | """Test extracting name from relative path.""" 114 | path = Path("input/sample.yaml") 115 | result = get_resume_name_from_path(path) 116 | assert result == "sample" 117 | -------------------------------------------------------------------------------- /src/simple_resume/core/palettes/resolution.py: -------------------------------------------------------------------------------- 1 | """Pure palette resolution logic without network I/O. 2 | 3 | This module contains pure functions that resolve palette configurations 4 | into either colors or fetch requests, without performing any I/O operations. 5 | """ 6 | 7 | from typing import Any 8 | 9 | from simple_resume.core.constants.colors import CONFIG_COLOR_FIELDS 10 | from simple_resume.core.palettes.common import PaletteSource 11 | from simple_resume.core.palettes.exceptions import ( 12 | PaletteError, 13 | PaletteGenerationError, 14 | PaletteLookupError, 15 | ) 16 | from simple_resume.core.palettes.fetch_types import ( 17 | PaletteFetchRequest, 18 | PaletteResolution, 19 | ) 20 | from simple_resume.core.palettes.generators import generate_hcl_palette 21 | from simple_resume.core.palettes.registry import PaletteRegistry 22 | 23 | 24 | def resolve_palette_config( 25 | block: dict[str, Any], *, registry: PaletteRegistry 26 | ) -> PaletteResolution: 27 | """Pure palette resolution - returns colors OR fetch request. 28 | 29 | This function performs pure logic to determine what colors are needed 30 | and how to obtain them. It never performs I/O operations. 31 | 32 | Args: 33 | block: Palette configuration block from resume config. 34 | registry: Palette registry to look up named palettes (injected dependency). 35 | 36 | Returns: 37 | PaletteResolution with either colors (for local sources) or 38 | a fetch request (for remote sources). 39 | 40 | Raises: 41 | PaletteError: If palette configuration is invalid. 42 | 43 | """ 44 | try: 45 | source = PaletteSource.normalize(block.get("source"), param_name="palette") 46 | except (TypeError, ValueError) as exc: 47 | raise PaletteError( 48 | f"Unsupported palette source: {block.get('source')}" 49 | ) from exc 50 | 51 | if source is PaletteSource.REGISTRY: 52 | """Pure lookup from local registry (no I/O).""" 53 | name = block.get("name") 54 | if not name: 55 | raise PaletteLookupError("registry source requires 'name'") 56 | 57 | palette = registry.get(str(name)) 58 | 59 | colors = list(palette.swatches) 60 | metadata = { 61 | "source": source.value, 62 | "name": palette.name, 63 | "size": len(palette.swatches), 64 | "attribution": palette.metadata, 65 | } 66 | 67 | return PaletteResolution(colors=colors, metadata=metadata) 68 | 69 | elif source is PaletteSource.GENERATOR: 70 | """Pure generation - no I/O.""" 71 | 72 | size = int(block.get("size", len(CONFIG_COLOR_FIELDS))) 73 | seed = block.get("seed") 74 | hue_range = tuple(block.get("hue_range", (0, 360))) 75 | luminance_range = tuple(block.get("luminance_range", (0.35, 0.85))) 76 | chroma = float(block.get("chroma", 0.12)) 77 | 78 | REQUIRED_RANGE_LENGTH = 2 79 | if ( 80 | len(hue_range) != REQUIRED_RANGE_LENGTH 81 | or len(luminance_range) != REQUIRED_RANGE_LENGTH 82 | ): 83 | raise PaletteGenerationError( 84 | "hue_range and luminance_range must have two values" 85 | ) 86 | 87 | colors = generate_hcl_palette( 88 | size, 89 | seed=int(seed) if seed is not None else None, 90 | hue_range=(float(hue_range[0]), float(hue_range[1])), 91 | chroma=chroma, 92 | luminance_range=(float(luminance_range[0]), float(luminance_range[1])), 93 | ) 94 | 95 | metadata = { 96 | "source": source.value, 97 | "size": len(colors), 98 | "seed": int(seed) if seed is not None else None, 99 | "hue_range": [float(hue_range[0]), float(hue_range[1])], 100 | "luminance_range": [float(luminance_range[0]), float(luminance_range[1])], 101 | "chroma": chroma, 102 | } 103 | 104 | return PaletteResolution(colors=colors, metadata=metadata) 105 | 106 | elif source is PaletteSource.REMOTE: 107 | """Return request for shell to execute - no network I/O here.""" 108 | fetch_request = PaletteFetchRequest( 109 | source=source.value, 110 | keywords=block.get("keywords"), 111 | num_results=int(block.get("num_results", 1)), 112 | order_by=str(block.get("order_by", "score")), 113 | ) 114 | 115 | return PaletteResolution(fetch_request=fetch_request) 116 | 117 | else: 118 | raise PaletteError(f"Unsupported palette source: {source.value}") 119 | 120 | 121 | __all__ = [ 122 | "resolve_palette_config", 123 | ] 124 | --------------------------------------------------------------------------------