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

earwig / mwparserfromhell / 15990296633

01 Jul 2025 05:01AM UTC coverage: 98.438% (-0.2%) from 98.662%
15990296633

push

github

earwig
Improve Wikicode/Node typing

239 of 252 new or added lines in 6 files covered. (94.84%)

2 existing lines in 2 files now uncovered.

3276 of 3328 relevant lines covered (98.44%)

9.84 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__(  # pyright: ignore[reportIncompatibleMethodOverride]
10✔
264
        self, name: str | Any
265
    ) -> Parameter:
UNCOV
266
        return self.get(name)
×
267

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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