├── 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 |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