diff --git a/cli/generators/cover_letter_generator.py b/cli/generators/cover_letter_generator.py index 6a2d425..51cfbcd 100644 --- a/cli/generators/cover_letter_generator.py +++ b/cli/generators/cover_letter_generator.py @@ -39,20 +39,15 @@ except ImportError: OPENAI_AVAILABLE = False -from jinja2 import Environment, FileSystemLoader, select_autoescape - from ..utils.config import Config -from ..utils.template_filters import latex_escape +from ..utils.template_utils import get_jinja_env from ..utils.yaml_parser import ResumeYAML from .ai_judge import create_ai_judge -from .template import TemplateGenerator class CoverLetterGenerator: """Generate personalized cover letters with AI.""" - _ENV_CACHE = {} - def __init__(self, yaml_path: Optional[Path] = None, config: Optional[Config] = None): """ Initialize cover letter generator. @@ -64,31 +59,12 @@ def __init__(self, yaml_path: Optional[Path] = None, config: Optional[Config] = self.config = config or Config() self.yaml_path = yaml_path self.yaml_handler = ResumeYAML(yaml_path) - self.template_generator = TemplateGenerator(yaml_path, config=config) # Set up template directory template_dir = Path(__file__).parent.parent.parent / "templates" - # Check cache - cache_key = str(template_dir.resolve()) - if cache_key in self._ENV_CACHE: - self.env = self._ENV_CACHE[cache_key] - else: - # Set up Jinja2 environment - self.env = Environment( - loader=FileSystemLoader(template_dir), - autoescape=select_autoescape(), - trim_blocks=True, - lstrip_blocks=True, - ) - - # Add now() function for templates - self.env.globals["now"] = datetime.now - - # Add LaTeX escape filter (reuse from template_generator via template_filters) - self.env.filters["latex_escape"] = latex_escape - - self._ENV_CACHE[cache_key] = self.env + # Set up Jinja2 environment (cached via template_utils) + self.env = get_jinja_env(template_dir) # Initialize AI client (same as AIGenerator) provider = self.config.ai_provider diff --git a/cli/generators/template.py b/cli/generators/template.py index 7b920dc..9ec0445 100644 --- a/cli/generators/template.py +++ b/cli/generators/template.py @@ -5,11 +5,8 @@ from pathlib import Path from typing import Any, Dict, Optional -from jinja2 import Environment, FileSystemLoader, select_autoescape -from markupsafe import Markup - from ..utils.config import Config -from ..utils.template_filters import latex_escape, proper_title +from ..utils.template_utils import get_jinja_env, get_jinja_tex_env from ..utils.yaml_parser import ResumeYAML # Optional: resume_pdf_lib for enhanced PDF generation @@ -24,8 +21,6 @@ class TemplateGenerator: """Generate resumes from Jinja2 templates.""" - _ENV_CACHE: Dict[str, Environment] = {} - def __init__( self, yaml_path: Optional[Path] = None, @@ -50,45 +45,12 @@ def __init__( self.template_dir = Path(template_dir) - # Set up Jinja2 environment (with caching) - cache_key = str(self.template_dir.resolve()) - if cache_key in self._ENV_CACHE: - self.env = self._ENV_CACHE[cache_key] - else: - self.env = Environment( - loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(), - trim_blocks=True, - lstrip_blocks=True, - ) - # Add filters - self.env.filters["latex_escape"] = latex_escape - self.env.filters["proper_title"] = proper_title - - self._ENV_CACHE[cache_key] = self.env - - # Set up Jinja2 environment for LaTeX (with caching) - # Separate environment for LaTeX to handle automatic escaping via finalize - tex_cache_key = cache_key + "_tex" - if tex_cache_key in self._ENV_CACHE: - self.tex_env = self._ENV_CACHE[tex_cache_key] - else: - self.tex_env = Environment( - loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(["tex"]), - trim_blocks=True, - lstrip_blocks=True, - ) - # Add filters - self.tex_env.filters["latex_escape"] = latex_escape - self.tex_env.filters["proper_title"] = proper_title - - # Auto-escape all variables for LaTeX - self.tex_env.finalize = lambda x: ( - latex_escape(x) if isinstance(x, str) and not isinstance(x, Markup) else x - ) + # Set up Jinja2 environment (cached via template_utils) + self.env = get_jinja_env(self.template_dir) - self._ENV_CACHE[tex_cache_key] = self.tex_env + # Set up Jinja2 environment for LaTeX (cached via template_utils) + # Separate environment for LaTeX with automatic escaping to prevent injection + self.tex_env = get_jinja_tex_env(self.template_dir) def generate( self, diff --git a/cli/utils/template_utils.py b/cli/utils/template_utils.py new file mode 100644 index 0000000..2d1a504 --- /dev/null +++ b/cli/utils/template_utils.py @@ -0,0 +1,90 @@ +"""Utility functions for Jinja2 template environment management.""" + +from datetime import datetime +from pathlib import Path +from typing import Dict + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from markupsafe import Markup + +from .template_filters import latex_escape, proper_title + +# Cache for Jinja2 environments to avoid expensive re-initialization +_ENV_CACHE: Dict[str, Environment] = {} + + +def get_jinja_env(template_dir: Path) -> Environment: + """ + Get a cached Jinja2 environment for the given template directory. + + Args: + template_dir: Path to the templates directory. + + Returns: + A configured Jinja2 Environment instance. + """ + cache_key = str(template_dir.resolve()) + + if cache_key in _ENV_CACHE: + return _ENV_CACHE[cache_key] + + # Initialize new environment + env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=select_autoescape(), + trim_blocks=True, + lstrip_blocks=True, + ) + + # Add filters + env.filters["latex_escape"] = latex_escape + env.filters["proper_title"] = proper_title + + # Add globals + env.globals["now"] = datetime.now + + # Cache the environment + _ENV_CACHE[cache_key] = env + + return env + + +def get_jinja_tex_env(template_dir: Path) -> Environment: + """ + Get a cached Jinja2 environment configured for LaTeX template rendering. + + This environment includes automatic LaTeX escaping via the finalize hook + to prevent LaTeX injection vulnerabilities. + + Args: + template_dir: Path to the templates directory. + + Returns: + A configured Jinja2 Environment instance for LaTeX rendering. + """ + cache_key = str(template_dir.resolve()) + "_tex" + + if cache_key in _ENV_CACHE: + return _ENV_CACHE[cache_key] + + # Initialize LaTeX-specific environment + tex_env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=select_autoescape(["tex"]), + trim_blocks=True, + lstrip_blocks=True, + ) + + # Add filters + tex_env.filters["latex_escape"] = latex_escape + tex_env.filters["proper_title"] = proper_title + + # Auto-escape all variables for LaTeX to prevent injection + tex_env.finalize = lambda x: ( + latex_escape(x) if isinstance(x, str) and not isinstance(x, Markup) else x + ) + + # Cache the environment + _ENV_CACHE[cache_key] = tex_env + + return tex_env diff --git a/tests/test_cover_letter_generator.py b/tests/test_cover_letter_generator.py index f8e06a1..22fffd0 100644 --- a/tests/test_cover_letter_generator.py +++ b/tests/test_cover_letter_generator.py @@ -316,7 +316,7 @@ def test_generate_single_version_invalid_json(self, sample_yaml_file: Path, monk class TestGenerateInteractive: """Test generate_interactive method.""" - def test_generate_interactive(self, sample_yaml_file: Path, monkeypatch, mocker): + def test_generate_interactive(self, sample_yaml_file: Path, monkeypatch): """Test interactive generation with mocked input.""" monkeypatch.setenv("AI_PROVIDER", "anthropic") monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") @@ -333,10 +333,9 @@ def test_generate_interactive(self, sample_yaml_file: Path, monkeypatch, mocker) ) # Mock input to return values - mocker.patch("builtins.input", return_value="I am excited about this role.") - - job_desc = "Looking for senior engineer" - outputs, job_details = gen.generate_interactive(job_desc, company_name="Acme Corp") + with patch("builtins.input", return_value="I am excited about this role."): + job_desc = "Looking for senior engineer" + outputs, job_details = gen.generate_interactive(job_desc, company_name="Acme Corp") assert isinstance(outputs, dict) assert "md" in outputs @@ -451,19 +450,18 @@ def test_clear_cache_clears_dict(self, sample_yaml_file: Path, monkeypatch): class TestGenerateCoverLetterFunction: """Test generate_cover_letter function.""" - def test_generate_cover_letter_interactive(self, sample_yaml_file: Path, monkeypatch, mocker): + def test_generate_cover_letter_interactive(self, sample_yaml_file: Path, monkeypatch): """Test generate_cover_letter in interactive mode.""" monkeypatch.setenv("AI_PROVIDER", "anthropic") monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - mocker.patch("builtins.input", return_value="Excited about role") - - outputs, job_details = generate_cover_letter( - job_description="Job description", - company_name="Acme", - yaml_path=sample_yaml_file, - interactive=True, - ) + with patch("builtins.input", return_value="Excited about role"): + outputs, job_details = generate_cover_letter( + job_description="Job description", + company_name="Acme", + yaml_path=sample_yaml_file, + interactive=True, + ) assert isinstance(outputs, dict) assert job_details["company"] == "Acme"