Coverage for src / rendercv / renderer / templater / string_processor.py: 100%
40 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-25 13:49 +0000
1import functools
2import re
3from collections.abc import Callable
4from typing import overload
6import pydantic
8from rendercv.exception import RenderCVInternalError
11@overload
12def apply_string_processors(
13 string: None, string_processors: list[Callable[[str], str]]
14) -> None: ...
15@overload
16def apply_string_processors(
17 string: str, string_processors: list[Callable[[str], str]]
18) -> str: ...
19def apply_string_processors(
20 string: str | None, string_processors: list[Callable[[str], str]]
21) -> str | None:
22 """Apply sequence of string transformation functions via reduce.
24 Why:
25 Multiple transformations (markdown parsing, keyword bolding, escaping)
26 need sequential application. Functional reduce pattern chains processors
27 cleanly without intermediate variables.
29 Args:
30 string: Input string or None.
31 string_processors: Functions to apply in order.
33 Returns:
34 Transformed string, or None if input was None.
35 """
36 if string is None:
37 return string
38 return functools.reduce(lambda v, f: f(v), string_processors, string)
41@functools.lru_cache(maxsize=64)
42def build_keyword_matcher_pattern(
43 keywords: frozenset[str], word_boundary: bool = False
44) -> re.Pattern:
45 """Build cached regex pattern for matching keywords with longest-first priority.
47 Why:
48 Keyword matching happens repeatedly during rendering. Cached patterns
49 avoid recompilation. Longest-first sorting prevents "Python" from matching
50 before "Python 3" in same text.
52 Args:
53 keywords: Set of keywords to match.
54 word_boundary: When True, add \\b word boundaries so only whole-word
55 matches are found.
57 Returns:
58 Compiled regex pattern.
59 """
60 if not keywords:
61 message = "Keywords cannot be empty"
62 raise RenderCVInternalError(message)
64 if word_boundary:
65 parts: list[str] = []
66 for k in keywords:
67 esc = re.escape(k)
68 # Only add \b on sides where the keyword character is a word
69 # character (\w). Non-word characters like ":" or "+" have no
70 # word boundary to match against adjacent spaces.
71 prefix = r"\b" if re.match(r"\w", k[0]) else ""
72 suffix = r"\b" if re.match(r"\w", k[-1]) else ""
73 parts.append(f"{prefix}{esc}{suffix}")
74 parts.sort(key=len, reverse=True)
75 pattern = "(" + "|".join(parts) + ")"
76 else:
77 escaped: list[str] = [re.escape(k) for k in keywords]
78 escaped.sort(key=len, reverse=True)
79 pattern = "(" + "|".join(escaped) + ")"
81 return re.compile(pattern)
84def make_keywords_bold(string: str, keywords: list[str]) -> str:
85 """Wrap all keyword occurrences in Markdown bold syntax.
87 Why:
88 Users configure keywords like "Python" or "Machine Learning" to highlight
89 in their CV. Automatic bolding applies consistent emphasis across all
90 content without manual markup.
92 Example:
93 ```py
94 result = make_keywords_bold("Expert in Python and Java", ["Python"])
95 # Returns: "Expert in **Python** and Java"
96 ```
98 Args:
99 string: Text to process.
100 keywords: Keywords to make bold.
102 Returns:
103 String with keywords wrapped in ** markers.
104 """
105 if not keywords:
106 return string
108 pattern = build_keyword_matcher_pattern(frozenset(keywords), word_boundary=True)
109 return pattern.sub(lambda m: f"**{m.group(0)}**", string)
112def substitute_placeholders(string: str, placeholders: dict[str, str]) -> str:
113 """Replace all placeholder occurrences with their values.
115 Why:
116 Output file names use placeholders like NAME and YEAR for dynamic naming.
117 Pattern matching with longest-first ensures "YEAR_IN_TWO_DIGITS" matches
118 before "YEAR" in same string.
120 Example:
121 ```py
122 result = substitute_placeholders(
123 "NAME_CV_YEAR.pdf", {"NAME": "John_Doe", "YEAR": "2025"}
124 )
125 # Returns: "John_Doe_CV_2025.pdf"
126 ```
128 Args:
129 string: Template string with placeholders.
130 placeholders: Map of placeholder names to replacement values.
132 Returns:
133 String with all placeholders replaced.
134 """
135 if not placeholders:
136 return string
138 pattern = build_keyword_matcher_pattern(frozenset(placeholders.keys()))
139 return pattern.sub(lambda m: placeholders[m.group(0)], string).strip()
142def clean_url(url: str | pydantic.HttpUrl) -> str:
143 """Remove protocol and trailing slashes from URL.
145 Why:
146 CV formatting displays cleaner URLs without https:// prefix. Used as
147 Jinja2 filter in templates for consistent URL presentation.
149 Example:
150 ```py
151 result = clean_url("https://www.example.com/")
152 # Returns: "www.example.com"
153 ```
155 Args:
156 url: URL to clean.
158 Returns:
159 Clean URL string.
160 """
161 return str(url).replace("https://", "").replace("http://", "").rstrip("/")