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

1import functools 

2import re 

3from collections.abc import Callable 

4from typing import overload 

5 

6import pydantic 

7 

8from rendercv.exception import RenderCVInternalError 

9 

10 

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. 

23 

24 Why: 

25 Multiple transformations (markdown parsing, keyword bolding, escaping) 

26 need sequential application. Functional reduce pattern chains processors 

27 cleanly without intermediate variables. 

28 

29 Args: 

30 string: Input string or None. 

31 string_processors: Functions to apply in order. 

32 

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) 

39 

40 

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. 

46 

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. 

51 

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. 

56 

57 Returns: 

58 Compiled regex pattern. 

59 """ 

60 if not keywords: 

61 message = "Keywords cannot be empty" 

62 raise RenderCVInternalError(message) 

63 

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) + ")" 

80 

81 return re.compile(pattern) 

82 

83 

84def make_keywords_bold(string: str, keywords: list[str]) -> str: 

85 """Wrap all keyword occurrences in Markdown bold syntax. 

86 

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. 

91 

92 Example: 

93 ```py 

94 result = make_keywords_bold("Expert in Python and Java", ["Python"]) 

95 # Returns: "Expert in **Python** and Java" 

96 ``` 

97 

98 Args: 

99 string: Text to process. 

100 keywords: Keywords to make bold. 

101 

102 Returns: 

103 String with keywords wrapped in ** markers. 

104 """ 

105 if not keywords: 

106 return string 

107 

108 pattern = build_keyword_matcher_pattern(frozenset(keywords), word_boundary=True) 

109 return pattern.sub(lambda m: f"**{m.group(0)}**", string) 

110 

111 

112def substitute_placeholders(string: str, placeholders: dict[str, str]) -> str: 

113 """Replace all placeholder occurrences with their values. 

114 

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. 

119 

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 ``` 

127 

128 Args: 

129 string: Template string with placeholders. 

130 placeholders: Map of placeholder names to replacement values. 

131 

132 Returns: 

133 String with all placeholders replaced. 

134 """ 

135 if not placeholders: 

136 return string 

137 

138 pattern = build_keyword_matcher_pattern(frozenset(placeholders.keys())) 

139 return pattern.sub(lambda m: placeholders[m.group(0)], string).strip() 

140 

141 

142def clean_url(url: str | pydantic.HttpUrl) -> str: 

143 """Remove protocol and trailing slashes from URL. 

144 

145 Why: 

146 CV formatting displays cleaner URLs without https:// prefix. Used as 

147 Jinja2 filter in templates for consistent URL presentation. 

148 

149 Example: 

150 ```py 

151 result = clean_url("https://www.example.com/") 

152 # Returns: "www.example.com" 

153 ``` 

154 

155 Args: 

156 url: URL to clean. 

157 

158 Returns: 

159 Clean URL string. 

160 """ 

161 return str(url).replace("https://", "").replace("http://", "").rstrip("/")