Coverage for fpdf2_textindex/alias.py: 66.34%

77 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-01 14:22 +0000

1"""Alias Registry.""" 

2 

3from collections.abc import Iterator, Mapping 

4import logging 

5import re 

6from typing import Final, Literal, TYPE_CHECKING 

7 

8from fpdf2_textindex.constants import LOGGER 

9from fpdf2_textindex.interface import Alias 

10from fpdf2_textindex.interface import LabelPath 

11 

12if TYPE_CHECKING: 

13 from fpdf2_textindex.interface import LabelPathT 

14 

15 

16class AliasRegistry(Mapping[str, Alias]): 

17 """Alias Registry. 

18 

19 Maps an alias by a name `"#alias"` to an entry by its label path. 

20 """ 

21 

22 _ALIAS_PREFIX: Final[Literal["#"]] = "#" 

23 _ALIAS_TOKEN_PATTERN: re.Pattern[str] = re.compile( 

24 rf"(?<!{_ALIAS_PREFIX:s}){_ALIAS_PREFIX:s}([a-zA-Z0-9\-_]+)" 

25 ) 

26 _ALIAS_DEFINITION_PATTERN: re.Pattern[str] = re.compile( 

27 rf"{_ALIAS_PREFIX:s}({_ALIAS_PREFIX:s}?[a-zA-Z0-9\-_]+)$" 

28 ) 

29 

30 def __init__(self) -> None: 

31 self._aliases: dict[str, Alias] = {} 

32 

33 def __getitem__(self, name: str) -> Alias: 

34 return self._aliases[name] 

35 

36 def __iter__(self) -> Iterator[str]: 

37 return iter(self._aliases) 

38 

39 def __len__(self) -> int: 

40 return len(self._aliases) 

41 

42 def __repr__(self) -> str: 

43 return f"{type(self).__name__:s}({len(self):d} aliases)" 

44 

45 def define(self, name: str, label_path: "LabelPathT") -> None: 

46 """Defines an alias. 

47 

48 Args: 

49 name: The name of the alias. 

50 label_path: The label path the alias will be replaced by. 

51 

52 Raises: 

53 ValueError: If the label path is empty. 

54 """ 

55 label_path = LabelPath(label_path) 

56 if len(label_path) == 0: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 msg = f"cannot create alias {name!r:s} with empty label path" 

58 raise ValueError(msg) 

59 

60 redefinition = False 

61 if ( 61 ↛ 65line 61 didn't jump to line 65 because the condition on line 61 was never true

62 name in self._aliases 

63 and self._aliases[name].label_path != label_path 

64 ): 

65 redefinition = True 

66 

67 self._aliases[name] = Alias(name=name, label_path=label_path) 

68 LOGGER.log( 

69 logging.WARNING if redefinition else logging.INFO, 

70 "\t%s alias '%s%s' as %r", 

71 "Redefined existing" if redefinition else "Defined new", 

72 self._ALIAS_PREFIX, 

73 name, 

74 self._aliases[name].joined_label_path, 

75 ) 

76 

77 def define_or_replace_from_label_path( 

78 self, 

79 label_path: "LabelPathT", 

80 label: str | None, 

81 content: str, 

82 alias_name: str | None, 

83 alias_start: int, 

84 directive_str: str, 

85 ) -> tuple[LabelPath, str | None, bool]: 

86 """Defines an alias from a label path and label or replaces an alias in 

87 it. 

88 

89 Args: 

90 label_path: The label path to use for the definition. 

91 label: The label of the parsed directive. 

92 content: The content of the parsed directive. 

93 alias_name: The name of the alias. 

94 alias_start: The start index of the alias in the directive. 

95 directive_str: The original directive. 

96 

97 Returns: 

98 The label path, the label, and whether it has been an unreferenced 

99 alias. The label path and the label can differ from the input in 

100 case the alias existed before. 

101 """ 

102 label_path = LabelPath(label_path) 

103 unreferenced_alias = False 

104 if alias_name is None: 

105 return label_path, label, unreferenced_alias 

106 

107 if alias_name.startswith(self._ALIAS_PREFIX): 

108 unreferenced_alias = True 

109 alias_name = alias_name.lstrip(self._ALIAS_PREFIX) 

110 

111 # Alias definition at end of an internally-specified label. 

112 if alias_start > 0: 112 ↛ 119line 112 didn't jump to line 119 because the condition on line 112 was always true

113 assert label is not None 

114 self.define(alias_name, LabelPath((*label_path, label))) 

115 

116 # Alias found at start of label: 

117 # Either an alias reference, or a definition without an internal label 

118 # (foo>#bar or just #bar) 

119 elif len(label_path) == 0: 

120 # No path components. Could be an alias definition at root, or an 

121 # alias reference 

122 

123 # Try to load the alias 

124 if alias_name in self._aliases: 

125 # Valid alias reference, load alias 

126 alias = self._aliases[alias_name] 

127 label_path = alias.label_path 

128 assert label is None 

129 label = label_path[-1] 

130 label_path = label_path[:-1] 

131 LOGGER.info( 

132 "\tLoaded alias %r as %r for directive: %r", 

133 alias_name, 

134 alias.joined_label_path, 

135 directive_str, 

136 ) 

137 # No path components, and an alias reference to a non-existing 

138 # alias, define a new alias instead 

139 elif content: 

140 label = content 

141 self.define(alias_name, LabelPath(label)) 

142 else: 

143 LOGGER.warning( 

144 "Cannot load nor define alias %r for directive: %r", 

145 alias_name, 

146 directive_str, 

147 ) 

148 

149 # Path components exist, so this is an alias definition without an 

150 # internal label 

151 else: 

152 if content: 

153 # We already had a label from either a bracketed span, or 

154 # implicitly, define alias 

155 label = content 

156 self.define(alias_name, LabelPath((*label_path, label))) 

157 else: 

158 # No label specified either internally or previously; 

159 # can't define an alias. 

160 label = None 

161 LOGGER.warning( 

162 "Alias definition %r without a label: %r", 

163 alias_name, 

164 directive_str, 

165 ) 

166 return label_path, label, unreferenced_alias 

167 

168 def _replace_match(self, match: re.Match[str]) -> str: 

169 name = match.group(1) 

170 replacement = match.group(0) 

171 if name and name in self._aliases: 

172 replacement = self._aliases[name].joined_label_path 

173 return replacement 

174 

175 def replace_aliases(self, directive_str: str) -> str: 

176 """Replaces aliases in a directive by its defined label path. 

177 

178 Args: 

179 directive_str: The original directive. 

180 

181 Returns: 

182 The directive with replaced aliases. 

183 """ 

184 if len(self._aliases) == 0 or len(directive_str) == 0: 

185 return directive_str 

186 return self._ALIAS_TOKEN_PATTERN.sub(self._replace_match, directive_str) 

187 

188 def strip_alias(self, directive_str: str) -> tuple[str, str | None, int]: 

189 """Strips an alias definition from the end of a directive. 

190 

191 Args: 

192 directive_str: The original directive. 

193 

194 Returns: 

195 A tuple comprising the directive without the alias, 

196 the found alias name (or `None` in case of no alias directive), 

197 and the start index of the alias (or `-1` in case of no alias 

198 directive). 

199 """ 

200 match = self._ALIAS_DEFINITION_PATTERN.search(directive_str) 

201 alias_start = -1 

202 alias_name = None 

203 if match: 

204 alias_start = match.start() 

205 alias_name = match.group(1) 

206 directive_str = directive_str[: match.start()] 

207 return directive_str, alias_name, alias_start