Coverage for src/prosemark/templates/domain/values/placeholder_pattern.py: 100%
70 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-30 23:09 +0000
1"""PlaceholderPattern value object for placeholder syntax handling and validation."""
3import re
4from typing import Self
6from prosemark.templates.domain.exceptions.template_exceptions import InvalidPlaceholderError
9class PlaceholderPattern:
10 """Immutable value object representing a validated placeholder pattern.
12 This value object encapsulates a placeholder pattern like '{{variable_name}}'
13 and provides parsing and validation capabilities.
14 """
16 # Regex for valid placeholder patterns: {{valid_identifier}}
17 PLACEHOLDER_REGEX = re.compile(r'^\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}$')
19 def __init__(self, pattern: str) -> None:
20 """Initialize a placeholder pattern with validation.
22 Args:
23 pattern: The placeholder pattern string (e.g., "{{variable_name}}")
25 Raises:
26 InvalidPlaceholderError: If the pattern has invalid syntax
28 """
29 if not isinstance(pattern, str):
30 raise InvalidPlaceholderError('Placeholder pattern must be a string', placeholder_pattern=str(pattern))
32 if not pattern.strip():
33 raise InvalidPlaceholderError('Placeholder pattern cannot be empty', placeholder_pattern=pattern)
35 match = self.PLACEHOLDER_REGEX.match(pattern)
36 if not match:
37 raise InvalidPlaceholderError(
38 'Invalid placeholder pattern syntax. Must be {{valid_identifier}}', placeholder_pattern=pattern
39 )
41 self._pattern = pattern
42 self._name = match.group(1)
44 @property
45 def raw(self) -> str:
46 """Get the raw placeholder pattern string."""
47 return self._pattern
49 @property
50 def name(self) -> str:
51 """Get the extracted variable name from the pattern."""
52 return self._name
54 @property
55 def is_valid(self) -> bool:
56 """Check if the pattern is valid (always True for constructed instances)."""
57 return True
59 def matches_text(self, text: str) -> bool:
60 """Check if this pattern appears in the given text.
62 Args:
63 text: Text to search for this placeholder pattern
65 Returns:
66 True if the pattern is found in the text
68 """
69 return self._pattern in text
71 def extract_from_text(self, text: str) -> list[str]:
72 """Extract all occurrences of this pattern from text.
74 Args:
75 text: Text to extract patterns from
77 Returns:
78 List of pattern occurrences (may contain duplicates)
80 """
81 return re.findall(re.escape(self._pattern), text)
83 def replace_in_text(self, text: str, replacement: str) -> str:
84 """Replace all occurrences of this pattern in text.
86 Args:
87 text: Text to perform replacement in
88 replacement: String to replace the pattern with
90 Returns:
91 Text with all occurrences of the pattern replaced
93 """
94 return text.replace(self._pattern, replacement)
96 @classmethod
97 def from_name(cls, variable_name: str) -> Self:
98 """Create a placeholder pattern from a variable name.
100 Args:
101 variable_name: The variable name (without braces)
103 Returns:
104 New PlaceholderPattern instance
106 Raises:
107 InvalidPlaceholderError: If the variable name is invalid
109 """
110 if not variable_name:
111 raise InvalidPlaceholderError('Variable name cannot be empty')
113 # Validate that the name would be a valid Python identifier
114 if (
115 not variable_name.replace('_', 'a')
116 .replace('0', 'a')
117 .replace('1', 'a')
118 .replace('2', 'a')
119 .replace('3', 'a')
120 .replace('4', 'a')
121 .replace('5', 'a')
122 .replace('6', 'a')
123 .replace('7', 'a')
124 .replace('8', 'a')
125 .replace('9', 'a')
126 .isalpha()
127 ):
128 msg = f'Invalid variable name: {variable_name}. Must be a valid Python identifier'
129 raise InvalidPlaceholderError(msg, placeholder_pattern=f'{ { {variable_name}} } ')
131 if variable_name[0].isdigit():
132 msg = f'Variable name cannot start with a digit: {variable_name}'
133 raise InvalidPlaceholderError(msg, placeholder_pattern=f'{ { {variable_name}} } ')
135 pattern = f'{ { {variable_name}} } '
136 return cls(pattern)
138 @classmethod
139 def extract_all_from_text(cls, text: str) -> list[Self]:
140 """Extract all valid placeholder patterns from text.
142 Args:
143 text: Text to extract placeholder patterns from
145 Returns:
146 List of PlaceholderPattern instances found in text
148 Raises:
149 InvalidPlaceholderError: If malformed patterns are found
151 """
152 # Find all potential placeholder patterns
153 potential_patterns = re.findall(r'\{\{[^}]*\}\}', text)
155 patterns = []
156 for pattern in potential_patterns:
157 try:
158 patterns.append(cls(pattern))
159 except InvalidPlaceholderError as e:
160 # Re-raise with more context about where the invalid pattern was found
161 msg = f'Found malformed placeholder pattern in text: {pattern}'
162 raise InvalidPlaceholderError(msg, placeholder_pattern=pattern) from e
164 return patterns
166 @classmethod
167 def is_valid_pattern(cls, pattern: str) -> bool:
168 """Check if a string is a valid placeholder pattern without raising exceptions.
170 Args:
171 pattern: Pattern string to validate
173 Returns:
174 True if the pattern is valid, False otherwise
176 """
177 try:
178 cls(pattern)
179 except InvalidPlaceholderError:
180 return False
181 else:
182 return True
184 def __str__(self) -> str:
185 """String representation of the placeholder pattern."""
186 return self._pattern
188 def __repr__(self) -> str:
189 """Developer representation of the placeholder pattern."""
190 return f'PlaceholderPattern({self._pattern!r})'
192 def __eq__(self, other: object) -> bool:
193 """Check equality with another PlaceholderPattern."""
194 if not isinstance(other, PlaceholderPattern):
195 return NotImplemented
196 return self._pattern == other._pattern
198 def __hash__(self) -> int:
199 """Hash based on the pattern value."""
200 return hash(self._pattern)