Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 3 additions & 27 deletions cli/generators/cover_letter_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
50 changes: 6 additions & 44 deletions cli/generators/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,8 +21,6 @@
class TemplateGenerator:
"""Generate resumes from Jinja2 templates."""

_ENV_CACHE: Dict[str, Environment] = {}

def __init__(
self,
yaml_path: Optional[Path] = None,
Expand All @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions cli/utils/template_utils.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 12 additions & 14 deletions tests/test_cover_letter_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down