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

earwig / mwparserfromhell / 15949141982

28 Jun 2025 11:18PM UTC coverage: 98.886% (-0.3%) from 99.204%
15949141982

push

github

earwig
Fix a failing test

3106 of 3141 relevant lines covered (98.89%)

9.85 hits per line

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

97.75
/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
10✔
22

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

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

40
if TYPE_CHECKING:
10✔
41
    from ..wikicode import Wikicode
×
42

43
__all__ = ["Template"]
10✔
44

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

48
T = TypeVar("T")
10✔
49

50

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

263
    def __getitem__(self, name: str | Any) -> Parameter:
10✔
264
        return self.get(name)
×
265

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

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

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

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

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

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

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

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

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

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

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

365
    def update(self, params: Mapping[Any, Any], **kwargs: Any) -> None:
10✔
366
        """Update the template with multiple parameters at once.
367
        Args:
368
            params: A dictionary mapping parameter names to values.
369
            **kwargs: Optional arguments that will be applied to all parameters,
370
                matching the same arguments in :meth:`add` (showkey, before,
371
                after, preserve_spacing)
372
        """
373
        for name, value in params.items():
10✔
374
            self.add(name, value, **kwargs)
10✔
375

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

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

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

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

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

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

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

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

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

426
    def __delitem__(self, param: Parameter | str) -> None:
10✔
427
        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