• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

earwig / mwparserfromhell / 25774853618

13 May 2026 02:38AM UTC coverage: 98.777% (+0.3%) from 98.438%
25774853618

push

github

web-flow
Route calloc through PyObject_Calloc in C tokenizer (#356, fixes #352)

3231 of 3271 relevant lines covered (98.78%)

11.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.15
/src/mwparserfromhell/nodes/template.py
1
# Copyright (C) 2012-2025 Ben Kurtovic <ben.kurtovic@gmail.com>
2
#
3
# Permission is hereby granted, free of charge, to any person obtaining a copy
4
# of this software and associated documentation files (the "Software"), to deal
5
# in the Software without restriction, including without limitation the rights
6
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
# copies of the Software, and to permit persons to whom the Software is
8
# furnished to do so, subject to the following conditions:
9
#
10
# The above copyright notice and this permission notice shall be included in
11
# all copies or substantial portions of the Software.
12
#
13
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
# SOFTWARE.
20

21
from __future__ import annotations
12✔
22

23
import re
12✔
24
from collections import defaultdict
12✔
25
from collections.abc import Callable, Generator, Mapping
12✔
26
from typing import (
12✔
27
    TYPE_CHECKING,
28
    Any,
29
    TypeVar,
30
    overload,
31
)
32

33
from ..utils import parse_anything
12✔
34
from ._base import Node
12✔
35
from .extras import Parameter
12✔
36
from .html_entity import HTMLEntity
12✔
37
from .text import Text
12✔
38

39
if TYPE_CHECKING:
40
    from ..wikicode import Wikicode
41

42
__all__ = ["Template"]
12✔
43

44
# Used to allow None as a valid fallback value
45
_UNSET = object()
12✔
46

47
T = TypeVar("T")
12✔
48

49

50
class Template(Node):
12✔
51
    """Represents a template in wikicode, like ``{{foo}}``."""
52

53
    def __init__(self, name: Any, params: list[Parameter] | None = None):
12✔
54
        super().__init__()
12✔
55
        self.name = name
12✔
56
        self._params: list[Parameter] = params or []
12✔
57

58
    def __str__(self) -> str:
12✔
59
        if self.params:
12✔
60
            params = "|".join([str(param) for param in self.params])
12✔
61
            return "{{" + str(self.name) + "|" + params + "}}"
12✔
62
        return "{{" + str(self.name) + "}}"
12✔
63

64
    def __children__(self) -> Generator[Wikicode]:
12✔
65
        yield self.name
12✔
66
        for param in self.params:
12✔
67
            if param.showkey:
12✔
68
                yield param.name
12✔
69
            yield param.value
12✔
70

71
    def __strip__(self, **kwargs: Any) -> str | None:
12✔
72
        if kwargs.get("keep_template_params"):
12✔
73
            parts = [param.value.strip_code(**kwargs) for param in self.params]
12✔
74
            return " ".join(part for part in parts if part)
12✔
75
        return None
12✔
76

77
    def __showtree__(
12✔
78
        self,
79
        write: Callable[[str], None],
80
        get: Callable[[Wikicode], None],
81
        mark: Callable[[], None],
82
    ) -> None:
83
        write("{{")
12✔
84
        get(self.name)
12✔
85
        for param in self.params:
12✔
86
            write("    | ")
12✔
87
            mark()
12✔
88
            get(param.name)
12✔
89
            write("    = ")
12✔
90
            mark()
12✔
91
            get(param.value)
12✔
92
        write("}}")
12✔
93

94
    @staticmethod
12✔
95
    def _surface_escape(code: Wikicode, char: str) -> None:
12✔
96
        """Return *code* with *char* escaped as an HTML entity.
97

98
        The main use of this is to escape pipes (``|``) or equal signs (``=``)
99
        in parameter names or values so they are not mistaken for new
100
        parameters.
101
        """
102
        replacement = str(HTMLEntity(value=ord(char)))
12✔
103
        for node in code.filter_text(recursive=False):
12✔
104
            if char in node:
12✔
105
                code.replace(node, node.replace(char, replacement), False)
12✔
106

107
    @staticmethod
12✔
108
    def _select_theory(theories: dict[str, int]) -> str | None:
12✔
109
        """Return the most likely spacing convention given different options.
110

111
        Given a dictionary of convention options as keys and their occurrence
112
        as values, return the convention that occurs the most, or ``None`` if
113
        there is no clear preferred style.
114
        """
115
        if theories:
12✔
116
            values = tuple(theories.values())
12✔
117
            best = max(values)
12✔
118
            confidence = float(best) / sum(values)
12✔
119
            if confidence > 0.5:
12✔
120
                return tuple(theories.keys())[values.index(best)]
12✔
121
        return None
12✔
122

123
    @staticmethod
12✔
124
    def _blank_param_value(value: Wikicode) -> None:
12✔
125
        """Remove the content from *value* while keeping its whitespace.
126

127
        Replace *value*\\ 's nodes with two text nodes, the first containing
128
        whitespace from before its content and the second containing whitespace
129
        from after its content.
130
        """
131
        sval = str(value)
12✔
132
        if sval.isspace():
12✔
133
            before, after = "", sval
12✔
134
        else:
135
            match = re.search(r"^(\s*).*?(\s*)$", sval, re.DOTALL)
12✔
136
            assert match, sval
12✔
137
            before, after = match.group(1), match.group(2)
12✔
138
        value.nodes = [Text(before), Text(after)]
12✔
139

140
    def _get_spacing_conventions(
12✔
141
        self, use_names: bool
142
    ) -> tuple[str | None, str | None]:
143
        """Try to determine the whitespace conventions for parameters.
144

145
        This will examine the existing parameters and use
146
        :meth:`_select_theory` to determine if there are any preferred styles
147
        for how much whitespace to put before or after the value.
148
        """
149
        before_theories: defaultdict[str, int] = defaultdict(int)
12✔
150
        after_theories: defaultdict[str, int] = defaultdict(int)
12✔
151
        for param in self.params:
12✔
152
            if not param.showkey:
12✔
153
                continue
12✔
154
            if use_names:
12✔
155
                component = str(param.name)
12✔
156
            else:
157
                component = str(param.value)
12✔
158
            match = re.search(r"^(\s*).*?(\s*)$", component, re.DOTALL)
12✔
159
            assert match, component
12✔
160
            before, after = match.group(1), match.group(2)
12✔
161
            if not use_names and component.isspace() and "\n" in before:
12✔
162
                # If the value is empty, we expect newlines in the whitespace
163
                # to be after the content, not before it:
164
                before, after = before.split("\n", 1)
12✔
165
                after = "\n" + after
12✔
166
            before_theories[before] += 1
12✔
167
            after_theories[after] += 1
12✔
168

169
        before = self._select_theory(before_theories)
12✔
170
        after = self._select_theory(after_theories)
12✔
171
        return before, after
12✔
172

173
    def _fix_dependendent_params(self, i: int) -> None:
12✔
174
        """Unhide keys if necessary after removing the param at index *i*."""
175
        if not self.params[i].showkey:
12✔
176
            for param in self.params[i + 1 :]:
12✔
177
                if not param.showkey:
12✔
178
                    param.showkey = True
12✔
179

180
    def _remove_exact(self, needle: Parameter, keep_field: bool) -> None:
12✔
181
        """Remove a specific parameter, *needle*, from the template."""
182
        for i, param in enumerate(self.params):
12✔
183
            if param is needle:
12✔
184
                if keep_field:
12✔
185
                    self._blank_param_value(param.value)
12✔
186
                else:
187
                    self._fix_dependendent_params(i)
12✔
188
                    self.params.pop(i)
12✔
189
                return
12✔
190
        raise ValueError(needle)
12✔
191

192
    def _should_remove(self, i: int, name: str) -> bool:
12✔
193
        """Look ahead for a parameter with the same name, but hidden.
194

195
        If one exists, we should remove the given one rather than blanking it.
196
        """
197
        if self.params[i].showkey:
12✔
198
            following = self.params[i + 1 :]
12✔
199
            better_matches = [
12✔
200
                after.name.strip() == name and not after.showkey for after in following
201
            ]
202
            return any(better_matches)
12✔
203
        return False
12✔
204

205
    @property
12✔
206
    def name(self) -> Wikicode:
12✔
207
        """The name of the template, as a :class:`.Wikicode` object."""
208
        return self._name
12✔
209

210
    @name.setter
12✔
211
    def name(self, value: Any) -> None:
12✔
212
        self._name = parse_anything(value)
12✔
213

214
    @property
12✔
215
    def params(self) -> list[Parameter]:
12✔
216
        """The list of parameters contained within the template."""
217
        return self._params
12✔
218

219
    def has(self, name: str | Any, ignore_empty: bool = False) -> bool:
12✔
220
        """Return ``True`` if any parameter in the template is named *name*.
221

222
        With *ignore_empty*, ``False`` will be returned even if the template
223
        contains a parameter with the name *name*, if the parameter's value
224
        is empty. Note that a template may have multiple parameters with the
225
        same name, but only the last one is read by the MediaWiki parser.
226
        """
227
        name = str(name).strip()
12✔
228
        for param in self.params:
12✔
229
            if param.name.strip() == name:
12✔
230
                if ignore_empty and not param.value.strip():
12✔
231
                    continue
12✔
232
                return True
12✔
233
        return False
12✔
234

235
    def has_param(self, name: str | Any, ignore_empty: bool = False) -> bool:
12✔
236
        """Alias for :meth:`has`."""
237
        return self.has(name, ignore_empty)
12✔
238

239
    @overload
240
    def get(self, name: str | Any) -> Parameter: ...
241

242
    @overload
243
    def get(self, name: str | Any, default: T) -> Parameter | T: ...
244

245
    def get(self, name: str | Any, default: T = _UNSET) -> Parameter | T:
12✔
246
        """Get the parameter whose name is *name*.
247

248
        The returned object is a :class:`.Parameter` instance. Raises
249
        :exc:`ValueError` if no parameter has this name. If *default* is set,
250
        returns that instead. Since multiple parameters can have the same name,
251
        we'll return the last match, since the last parameter is the only one
252
        read by the MediaWiki parser.
253
        """
254
        name = str(name).strip()
12✔
255
        for param in reversed(self.params):
12✔
256
            if param.name.strip() == name:
12✔
257
                return param
12✔
258
        if default is _UNSET:
12✔
259
            raise ValueError(name)
12✔
260
        return default
×
261

262
    def __getitem__(  # pyright: ignore[reportIncompatibleMethodOverride]
12✔
263
        self, name: str | Any
264
    ) -> Parameter:
265
        return self.get(name)
×
266

267
    def add(
12✔
268
        self,
269
        name: Any,
270
        value: Any,
271
        showkey: bool | None = None,
272
        before: Parameter | str | None = None,
273
        after: Parameter | str | None = None,
274
        preserve_spacing: bool = True,
275
    ) -> Parameter:
276
        """Add a parameter to the template with a given *name* and *value*.
277

278
        *name* and *value* can be anything parsable by
279
        :func:`.utils.parse_anything`; pipes and equal signs are automatically
280
        escaped from *value* when appropriate.
281

282
        If *name* is already a parameter in the template, we'll replace its
283
        value.
284

285
        If *showkey* is given, this will determine whether or not to show the
286
        parameter's name (e.g., ``{{foo|bar}}``'s parameter has a name of
287
        ``"1"`` but it is hidden); otherwise, we'll make a safe and intelligent
288
        guess.
289

290
        If *before* is given (either a :class:`.Parameter` object or a name),
291
        then we will place the parameter immediately before this one.
292
        Otherwise, it will be added at the end. If *before* is a name and
293
        exists multiple times in the template, we will place it before the last
294
        occurrence. If *before* is not in the template, :exc:`ValueError` is
295
        raised. The argument is ignored if *name* is an existing parameter.
296

297
        If *after* is given (either a :class:`.Parameter` object or a name),
298
        then we will place the parameter immediately after this one. If *after*
299
        is a name and exists multiple times in the template, we will place it
300
        after the last occurrence. If *after* is not in the template,
301
        :exc:`ValueError` is raised. The argument is ignored if *name* is an
302
        existing parameter or if a value is passed to *before*.
303

304
        If *preserve_spacing* is ``True``, we will try to preserve whitespace
305
        conventions around the parameter, whether it is new or we are updating
306
        an existing value. It is disabled for parameters with hidden keys,
307
        since MediaWiki doesn't strip whitespace in this case.
308
        """
309
        name, value = parse_anything(name), parse_anything(value)
12✔
310
        self._surface_escape(value, "|")
12✔
311

312
        if self.has(name):
12✔
313
            self.remove(name, keep_field=True)
12✔
314
            existing = self.get(name)
12✔
315
            if showkey is not None:
12✔
316
                existing.showkey = showkey
12✔
317
            if not existing.showkey:
12✔
318
                self._surface_escape(value, "=")
12✔
319
            nodes: list[Node | None] = list(existing.value.nodes)
12✔
320
            if preserve_spacing and existing.showkey:
12✔
321
                for i in range(2):  # Ignore empty text nodes
12✔
322
                    if not nodes[i]:
12✔
323
                        nodes[i] = None
12✔
324
                existing.value = parse_anything([nodes[0], value, nodes[1]])
12✔
325
            else:
326
                existing.value = value
12✔
327
            return existing
12✔
328

329
        if showkey is None:
12✔
330
            if Parameter.can_hide_key(name):
12✔
331
                int_name = int(str(name))
12✔
332
                int_keys = set()
12✔
333
                for param in self.params:
12✔
334
                    if not param.showkey:
12✔
335
                        int_keys.add(int(str(param.name)))
12✔
336
                expected = min(set(range(1, len(int_keys) + 2)) - int_keys)
12✔
337
                if expected == int_name:
12✔
338
                    showkey = False
12✔
339
                else:
340
                    showkey = True
12✔
341
            else:
342
                showkey = True
12✔
343
        if not showkey:
12✔
344
            self._surface_escape(value, "=")
12✔
345

346
        if preserve_spacing and showkey:
12✔
347
            before_n, after_n = self._get_spacing_conventions(use_names=True)
12✔
348
            before_v, after_v = self._get_spacing_conventions(use_names=False)
12✔
349
            name = parse_anything([before_n, name, after_n])
12✔
350
            value = parse_anything([before_v, value, after_v])
12✔
351

352
        param = Parameter(name, value, showkey)
12✔
353
        if before:
12✔
354
            assert after is None, "Cannot set a value for both 'before' and 'after'"
12✔
355
            if not isinstance(before, Parameter):
12✔
356
                before = self.get(before)
12✔
357
            self.params.insert(self.params.index(before), param)
12✔
358
        elif after:
12✔
359
            if not isinstance(after, Parameter):
12✔
360
                after = self.get(after)
12✔
361
            self.params.insert(self.params.index(after) + 1, param)
12✔
362
        else:
363
            self.params.append(param)
12✔
364
        return param
12✔
365

366
    def update(self, params: Mapping[Any, Any], **kwargs: Any) -> None:
12✔
367
        """Update the template with multiple parameters at once.
368

369
        - *params*: A dictionary mapping parameter names to values.
370
        - *kwargs*: Optional arguments that will be applied to all parameters, matching
371
          the same arguments in :meth:`add` (*showkey*, *before*, *after*,
372
          *preserve_spacing*).
373
        """
374
        for name, value in params.items():
12✔
375
            self.add(name, value, **kwargs)
12✔
376

377
    def __setitem__(self, name: Any, value: Any) -> Parameter:
12✔
378
        return self.add(name, value)
×
379

380
    def remove(self, param: Parameter | str | int, keep_field: bool = False) -> None:
12✔
381
        """Remove a parameter from the template, identified by *param*.
382

383
        If *param* is a :class:`.Parameter` object, it will be matched exactly,
384
        otherwise it will be treated like the *name* argument to :meth:`has`
385
        and :meth:`get`.
386

387
        If *keep_field* is ``True``, we will keep the parameter's name, but
388
        blank its value. Otherwise, we will remove the parameter completely.
389

390
        When removing a parameter with a hidden name, subsequent parameters
391
        with hidden names will be made visible. For example, removing ``bar``
392
        from ``{{foo|bar|baz}}`` produces ``{{foo|2=baz}}`` because
393
        ``{{foo|baz}}`` is incorrect.
394

395
        If the parameter shows up multiple times in the template and *param* is
396
        not a :class:`.Parameter` object, we will remove all instances of it
397
        (and keep only one if *keep_field* is ``True`` - either the one with a
398
        hidden name, if it exists, or the first instance).
399
        """
400
        if isinstance(param, Parameter):
12✔
401
            self._remove_exact(param, keep_field)
12✔
402
            return
12✔
403

404
        name = str(param).strip()
12✔
405
        removed = False
12✔
406
        to_remove = []
12✔
407

408
        for i, par in enumerate(self.params):
12✔
409
            if par.name.strip() == name:
12✔
410
                if keep_field:
12✔
411
                    if self._should_remove(i, name):
12✔
412
                        to_remove.append(i)
12✔
413
                    else:
414
                        self._blank_param_value(par.value)
12✔
415
                        keep_field = False
12✔
416
                else:
417
                    self._fix_dependendent_params(i)
12✔
418
                    to_remove.append(i)
12✔
419
                if not removed:
12✔
420
                    removed = True
12✔
421

422
        if not removed:
12✔
423
            raise ValueError(name)
12✔
424
        for i in reversed(to_remove):
12✔
425
            self.params.pop(i)
12✔
426

427
    def __delitem__(self, param: Parameter | str) -> None:
12✔
428
        return self.remove(param)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc