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

earwig / mwparserfromhell / 7405738327

04 Jan 2024 05:05AM UTC coverage: 99.202% (+0.1%) from 99.069%
7405738327

push

github

earwig
run-tests: with-extension env var must be set when building project

2984 of 3008 relevant lines covered (99.2%)

9.9 hits per line

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

98.05
/src/mwparserfromhell/nodes/template.py
1
# Copyright (C) 2012-2020 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 collections import defaultdict
10✔
22
import re
10✔
23

24
from ._base import Node
10✔
25
from .html_entity import HTMLEntity
10✔
26
from .text import Text
10✔
27
from .extras import Parameter
10✔
28
from ..utils import parse_anything
10✔
29

30
__all__ = ["Template"]
10✔
31

32
FLAGS = re.DOTALL | re.UNICODE
10✔
33
# Used to allow None as a valid fallback value
34
_UNSET = object()
10✔
35

36

37
class Template(Node):
10✔
38
    """Represents a template in wikicode, like ``{{foo}}``."""
39

40
    def __init__(self, name, params=None):
10✔
41
        super().__init__()
10✔
42
        self.name = name
10✔
43
        if params:
10✔
44
            self._params = params
10✔
45
        else:
46
            self._params = []
10✔
47

48
    def __str__(self):
10✔
49
        if self.params:
10✔
50
            params = "|".join([str(param) for param in self.params])
10✔
51
            return "{{" + str(self.name) + "|" + params + "}}"
10✔
52
        return "{{" + str(self.name) + "}}"
10✔
53

54
    def __children__(self):
10✔
55
        yield self.name
10✔
56
        for param in self.params:
10✔
57
            if param.showkey:
10✔
58
                yield param.name
10✔
59
            yield param.value
10✔
60

61
    def __strip__(self, **kwargs):
10✔
62
        if kwargs.get("keep_template_params"):
10✔
63
            parts = [param.value.strip_code(**kwargs) for param in self.params]
10✔
64
            return " ".join(part for part in parts if part)
10✔
65
        return None
10✔
66

67
    def __showtree__(self, write, get, mark):
10✔
68
        write("{{")
10✔
69
        get(self.name)
10✔
70
        for param in self.params:
10✔
71
            write("    | ")
10✔
72
            mark()
10✔
73
            get(param.name)
10✔
74
            write("    = ")
10✔
75
            mark()
10✔
76
            get(param.value)
10✔
77
        write("}}")
10✔
78

79
    @staticmethod
10✔
80
    def _surface_escape(code, char):
10✔
81
        """Return *code* with *char* escaped as an HTML entity.
82

83
        The main use of this is to escape pipes (``|``) or equal signs (``=``)
84
        in parameter names or values so they are not mistaken for new
85
        parameters.
86
        """
87
        replacement = str(HTMLEntity(value=ord(char)))
10✔
88
        for node in code.filter_text(recursive=False):
10✔
89
            if char in node:
10✔
90
                code.replace(node, node.replace(char, replacement), False)
10✔
91

92
    @staticmethod
10✔
93
    def _select_theory(theories):
10✔
94
        """Return the most likely spacing convention given different options.
95

96
        Given a dictionary of convention options as keys and their occurrence
97
        as values, return the convention that occurs the most, or ``None`` if
98
        there is no clear preferred style.
99
        """
100
        if theories:
10✔
101
            values = tuple(theories.values())
10✔
102
            best = max(values)
10✔
103
            confidence = float(best) / sum(values)
10✔
104
            if confidence > 0.5:
10✔
105
                return tuple(theories.keys())[values.index(best)]
10✔
106
        return None
10✔
107

108
    @staticmethod
10✔
109
    def _blank_param_value(value):
10✔
110
        """Remove the content from *value* while keeping its whitespace.
111

112
        Replace *value*\\ 's nodes with two text nodes, the first containing
113
        whitespace from before its content and the second containing whitespace
114
        from after its content.
115
        """
116
        sval = str(value)
10✔
117
        if sval.isspace():
10✔
118
            before, after = "", sval
10✔
119
        else:
120
            match = re.search(r"^(\s*).*?(\s*)$", sval, FLAGS)
10✔
121
            before, after = match.group(1), match.group(2)
10✔
122
        value.nodes = [Text(before), Text(after)]
10✔
123

124
    def _get_spacing_conventions(self, use_names):
10✔
125
        """Try to determine the whitespace conventions for parameters.
126

127
        This will examine the existing parameters and use
128
        :meth:`_select_theory` to determine if there are any preferred styles
129
        for how much whitespace to put before or after the value.
130
        """
131
        before_theories = defaultdict(lambda: 0)
10✔
132
        after_theories = defaultdict(lambda: 0)
10✔
133
        for param in self.params:
10✔
134
            if not param.showkey:
10✔
135
                continue
10✔
136
            if use_names:
10✔
137
                component = str(param.name)
10✔
138
            else:
139
                component = str(param.value)
10✔
140
            match = re.search(r"^(\s*).*?(\s*)$", component, FLAGS)
10✔
141
            before, after = match.group(1), match.group(2)
10✔
142
            if not use_names and component.isspace() and "\n" in before:
10✔
143
                # If the value is empty, we expect newlines in the whitespace
144
                # to be after the content, not before it:
145
                before, after = before.split("\n", 1)
10✔
146
                after = "\n" + after
10✔
147
            before_theories[before] += 1
10✔
148
            after_theories[after] += 1
10✔
149

150
        before = self._select_theory(before_theories)
10✔
151
        after = self._select_theory(after_theories)
10✔
152
        return before, after
10✔
153

154
    def _fix_dependendent_params(self, i):
10✔
155
        """Unhide keys if necessary after removing the param at index *i*."""
156
        if not self.params[i].showkey:
10✔
157
            for param in self.params[i + 1 :]:
10✔
158
                if not param.showkey:
10✔
159
                    param.showkey = True
10✔
160

161
    def _remove_exact(self, needle, keep_field):
10✔
162
        """Remove a specific parameter, *needle*, from the template."""
163
        for i, param in enumerate(self.params):
10✔
164
            if param is needle:
10✔
165
                if keep_field:
10✔
166
                    self._blank_param_value(param.value)
10✔
167
                else:
168
                    self._fix_dependendent_params(i)
10✔
169
                    self.params.pop(i)
10✔
170
                return
10✔
171
        raise ValueError(needle)
10✔
172

173
    def _should_remove(self, i, name):
10✔
174
        """Look ahead for a parameter with the same name, but hidden.
175

176
        If one exists, we should remove the given one rather than blanking it.
177
        """
178
        if self.params[i].showkey:
10✔
179
            following = self.params[i + 1 :]
10✔
180
            better_matches = [
10✔
181
                after.name.strip() == name and not after.showkey for after in following
182
            ]
183
            return any(better_matches)
10✔
184
        return False
10✔
185

186
    @property
10✔
187
    def name(self):
10✔
188
        """The name of the template, as a :class:`.Wikicode` object."""
189
        return self._name
10✔
190

191
    @property
10✔
192
    def params(self):
10✔
193
        """The list of parameters contained within the template."""
194
        return self._params
10✔
195

196
    @name.setter
10✔
197
    def name(self, value):
10✔
198
        self._name = parse_anything(value)
10✔
199

200
    def has(self, name, ignore_empty=False):
10✔
201
        """Return ``True`` if any parameter in the template is named *name*.
202

203
        With *ignore_empty*, ``False`` will be returned even if the template
204
        contains a parameter with the name *name*, if the parameter's value
205
        is empty. Note that a template may have multiple parameters with the
206
        same name, but only the last one is read by the MediaWiki parser.
207
        """
208
        name = str(name).strip()
10✔
209
        for param in self.params:
10✔
210
            if param.name.strip() == name:
10✔
211
                if ignore_empty and not param.value.strip():
10✔
212
                    continue
10✔
213
                return True
10✔
214
        return False
10✔
215

216
    def has_param(self, name, ignore_empty=False):
10✔
217
        """Alias for :meth:`has`."""
218
        return self.has(name, ignore_empty)
10✔
219

220
    def get(self, name, default=_UNSET):
10✔
221
        """Get the parameter whose name is *name*.
222

223
        The returned object is a :class:`.Parameter` instance. Raises
224
        :exc:`ValueError` if no parameter has this name. If *default* is set,
225
        returns that instead. Since multiple parameters can have the same name,
226
        we'll return the last match, since the last parameter is the only one
227
        read by the MediaWiki parser.
228
        """
229
        name = str(name).strip()
10✔
230
        for param in reversed(self.params):
10✔
231
            if param.name.strip() == name:
10✔
232
                return param
10✔
233
        if default is _UNSET:
10✔
234
            raise ValueError(name)
10✔
235
        return default
×
236

237
    def __getitem__(self, name):
10✔
238
        return self.get(name)
×
239

240
    def add(self, name, value, showkey=None, before=None, preserve_spacing=True):
10✔
241
        """Add a parameter to the template with a given *name* and *value*.
242

243
        *name* and *value* can be anything parsable by
244
        :func:`.utils.parse_anything`; pipes and equal signs are automatically
245
        escaped from *value* when appropriate.
246

247
        If *name* is already a parameter in the template, we'll replace its
248
        value.
249

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

255
        If *before* is given (either a :class:`.Parameter` object or a name),
256
        then we will place the parameter immediately before this one.
257
        Otherwise, it will be added at the end. If *before* is a name and
258
        exists multiple times in the template, we will place it before the last
259
        occurrence. If *before* is not in the template, :exc:`ValueError` is
260
        raised. The argument is ignored if *name* is an existing parameter.
261

262
        If *preserve_spacing* is ``True``, we will try to preserve whitespace
263
        conventions around the parameter, whether it is new or we are updating
264
        an existing value. It is disabled for parameters with hidden keys,
265
        since MediaWiki doesn't strip whitespace in this case.
266
        """
267
        name, value = parse_anything(name), parse_anything(value)
10✔
268
        self._surface_escape(value, "|")
10✔
269

270
        if self.has(name):
10✔
271
            self.remove(name, keep_field=True)
10✔
272
            existing = self.get(name)
10✔
273
            if showkey is not None:
10✔
274
                existing.showkey = showkey
10✔
275
            if not existing.showkey:
10✔
276
                self._surface_escape(value, "=")
10✔
277
            nodes = existing.value.nodes
10✔
278
            if preserve_spacing and existing.showkey:
10✔
279
                for i in range(2):  # Ignore empty text nodes
10✔
280
                    if not nodes[i]:
10✔
281
                        nodes[i] = None
10✔
282
                existing.value = parse_anything([nodes[0], value, nodes[1]])
10✔
283
            else:
284
                existing.value = value
10✔
285
            return existing
10✔
286

287
        if showkey is None:
10✔
288
            if Parameter.can_hide_key(name):
10✔
289
                int_name = int(str(name))
10✔
290
                int_keys = set()
10✔
291
                for param in self.params:
10✔
292
                    if not param.showkey:
10✔
293
                        int_keys.add(int(str(param.name)))
10✔
294
                expected = min(set(range(1, len(int_keys) + 2)) - int_keys)
10✔
295
                if expected == int_name:
10✔
296
                    showkey = False
10✔
297
                else:
298
                    showkey = True
10✔
299
            else:
300
                showkey = True
10✔
301
        if not showkey:
10✔
302
            self._surface_escape(value, "=")
10✔
303

304
        if preserve_spacing and showkey:
10✔
305
            before_n, after_n = self._get_spacing_conventions(use_names=True)
10✔
306
            before_v, after_v = self._get_spacing_conventions(use_names=False)
10✔
307
            name = parse_anything([before_n, name, after_n])
10✔
308
            value = parse_anything([before_v, value, after_v])
10✔
309

310
        param = Parameter(name, value, showkey)
10✔
311
        if before:
10✔
312
            if not isinstance(before, Parameter):
10✔
313
                before = self.get(before)
10✔
314
            self.params.insert(self.params.index(before), param)
10✔
315
        else:
316
            self.params.append(param)
10✔
317
        return param
10✔
318

319
    def __setitem__(self, name, value):
10✔
320
        return self.add(name, value)
×
321

322
    def remove(self, param, keep_field=False):
10✔
323
        """Remove a parameter from the template, identified by *param*.
324

325
        If *param* is a :class:`.Parameter` object, it will be matched exactly,
326
        otherwise it will be treated like the *name* argument to :meth:`has`
327
        and :meth:`get`.
328

329
        If *keep_field* is ``True``, we will keep the parameter's name, but
330
        blank its value. Otherwise, we will remove the parameter completely.
331

332
        When removing a parameter with a hidden name, subsequent parameters
333
        with hidden names will be made visible. For example, removing ``bar``
334
        from ``{{foo|bar|baz}}`` produces ``{{foo|2=baz}}`` because
335
        ``{{foo|baz}}`` is incorrect.
336

337
        If the parameter shows up multiple times in the template and *param* is
338
        not a :class:`.Parameter` object, we will remove all instances of it
339
        (and keep only one if *keep_field* is ``True`` - either the one with a
340
        hidden name, if it exists, or the first instance).
341
        """
342
        if isinstance(param, Parameter):
10✔
343
            self._remove_exact(param, keep_field)
10✔
344
            return
10✔
345

346
        name = str(param).strip()
10✔
347
        removed = False
10✔
348
        to_remove = []
10✔
349

350
        for i, par in enumerate(self.params):
10✔
351
            if par.name.strip() == name:
10✔
352
                if keep_field:
10✔
353
                    if self._should_remove(i, name):
10✔
354
                        to_remove.append(i)
10✔
355
                    else:
356
                        self._blank_param_value(par.value)
10✔
357
                        keep_field = False
10✔
358
                else:
359
                    self._fix_dependendent_params(i)
10✔
360
                    to_remove.append(i)
10✔
361
                if not removed:
10✔
362
                    removed = True
10✔
363

364
        if not removed:
10✔
365
            raise ValueError(name)
10✔
366
        for i in reversed(to_remove):
10✔
367
            self.params.pop(i)
10✔
368

369
    def __delitem__(self, param):
10✔
370
        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