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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-01 14:22 +0000
1"""Alias Registry."""
3from collections.abc import Iterator, Mapping
4import logging
5import re
6from typing import Final, Literal, TYPE_CHECKING
8from fpdf2_textindex.constants import LOGGER
9from fpdf2_textindex.interface import Alias
10from fpdf2_textindex.interface import LabelPath
12if TYPE_CHECKING:
13 from fpdf2_textindex.interface import LabelPathT
16class AliasRegistry(Mapping[str, Alias]):
17 """Alias Registry.
19 Maps an alias by a name `"#alias"` to an entry by its label path.
20 """
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 )
30 def __init__(self) -> None:
31 self._aliases: dict[str, Alias] = {}
33 def __getitem__(self, name: str) -> Alias:
34 return self._aliases[name]
36 def __iter__(self) -> Iterator[str]:
37 return iter(self._aliases)
39 def __len__(self) -> int:
40 return len(self._aliases)
42 def __repr__(self) -> str:
43 return f"{type(self).__name__:s}({len(self):d} aliases)"
45 def define(self, name: str, label_path: "LabelPathT") -> None:
46 """Defines an alias.
48 Args:
49 name: The name of the alias.
50 label_path: The label path the alias will be replaced by.
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)
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
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 )
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.
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.
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
107 if alias_name.startswith(self._ALIAS_PREFIX):
108 unreferenced_alias = True
109 alias_name = alias_name.lstrip(self._ALIAS_PREFIX)
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)))
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
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 )
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
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
175 def replace_aliases(self, directive_str: str) -> str:
176 """Replaces aliases in a directive by its defined label path.
178 Args:
179 directive_str: The original directive.
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)
188 def strip_alias(self, directive_str: str) -> tuple[str, str | None, int]:
189 """Strips an alias definition from the end of a directive.
191 Args:
192 directive_str: The original directive.
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