A Python parser for MediaWiki wikicode https://mwparserfromhell.readthedocs.io/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

429 lines
16 KiB

  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. from __future__ import annotations
  21. import re
  22. from collections import defaultdict
  23. from collections.abc import Generator, Mapping
  24. from typing import (
  25. TYPE_CHECKING,
  26. Any,
  27. Callable,
  28. TypeVar,
  29. overload,
  30. )
  31. from ..utils import parse_anything
  32. from ._base import Node
  33. from .extras import Parameter
  34. from .html_entity import HTMLEntity
  35. from .text import Text
  36. if TYPE_CHECKING:
  37. from ..wikicode import Wikicode
  38. __all__ = ["Template"]
  39. FLAGS = re.DOTALL | re.UNICODE
  40. # Used to allow None as a valid fallback value
  41. _UNSET = object()
  42. T = TypeVar("T")
  43. class Template(Node):
  44. """Represents a template in wikicode, like ``{{foo}}``."""
  45. def __init__(self, name: Any, params: list[Parameter] | None = None):
  46. super().__init__()
  47. self.name = name
  48. self._params: list[Parameter] = params or []
  49. def __str__(self) -> str:
  50. if self.params:
  51. params = "|".join([str(param) for param in self.params])
  52. return "{{" + str(self.name) + "|" + params + "}}"
  53. return "{{" + str(self.name) + "}}"
  54. def __children__(self) -> Generator[Wikicode]:
  55. yield self.name
  56. for param in self.params:
  57. if param.showkey:
  58. yield param.name
  59. yield param.value
  60. def __strip__(self, **kwargs: Any) -> str | None:
  61. if kwargs.get("keep_template_params"):
  62. parts = [param.value.strip_code(**kwargs) for param in self.params]
  63. return " ".join(part for part in parts if part)
  64. return None
  65. def __showtree__(
  66. self,
  67. write: Callable[[str], None],
  68. get: Callable[[Wikicode], None],
  69. mark: Callable[[], None],
  70. ) -> None:
  71. write("{{")
  72. get(self.name)
  73. for param in self.params:
  74. write(" | ")
  75. mark()
  76. get(param.name)
  77. write(" = ")
  78. mark()
  79. get(param.value)
  80. write("}}")
  81. @staticmethod
  82. def _surface_escape(code: Wikicode, char: str) -> None:
  83. """Return *code* with *char* escaped as an HTML entity.
  84. The main use of this is to escape pipes (``|``) or equal signs (``=``)
  85. in parameter names or values so they are not mistaken for new
  86. parameters.
  87. """
  88. replacement = str(HTMLEntity(value=ord(char)))
  89. for node in code.filter_text(recursive=False):
  90. if char in node:
  91. code.replace(node, node.replace(char, replacement), False)
  92. @staticmethod
  93. def _select_theory(theories: dict[str, int]) -> str | None:
  94. """Return the most likely spacing convention given different options.
  95. Given a dictionary of convention options as keys and their occurrence
  96. as values, return the convention that occurs the most, or ``None`` if
  97. there is no clear preferred style.
  98. """
  99. if theories:
  100. values = tuple(theories.values())
  101. best = max(values)
  102. confidence = float(best) / sum(values)
  103. if confidence > 0.5:
  104. return tuple(theories.keys())[values.index(best)]
  105. return None
  106. @staticmethod
  107. def _blank_param_value(value: Wikicode) -> None:
  108. """Remove the content from *value* while keeping its whitespace.
  109. Replace *value*\\ 's nodes with two text nodes, the first containing
  110. whitespace from before its content and the second containing whitespace
  111. from after its content.
  112. """
  113. sval = str(value)
  114. if sval.isspace():
  115. before, after = "", sval
  116. else:
  117. match = re.search(r"^(\s*).*?(\s*)$", sval, FLAGS)
  118. assert match, sval
  119. before, after = match.group(1), match.group(2)
  120. value.nodes = [Text(before), Text(after)]
  121. def _get_spacing_conventions(
  122. self, use_names: bool
  123. ) -> tuple[str | None, str | None]:
  124. """Try to determine the whitespace conventions for parameters.
  125. This will examine the existing parameters and use
  126. :meth:`_select_theory` to determine if there are any preferred styles
  127. for how much whitespace to put before or after the value.
  128. """
  129. before_theories: defaultdict[str, int] = defaultdict(int)
  130. after_theories: defaultdict[str, int] = defaultdict(int)
  131. for param in self.params:
  132. if not param.showkey:
  133. continue
  134. if use_names:
  135. component = str(param.name)
  136. else:
  137. component = str(param.value)
  138. match = re.search(r"^(\s*).*?(\s*)$", component, FLAGS)
  139. assert match, component
  140. before, after = match.group(1), match.group(2)
  141. if not use_names and component.isspace() and "\n" in before:
  142. # If the value is empty, we expect newlines in the whitespace
  143. # to be after the content, not before it:
  144. before, after = before.split("\n", 1)
  145. after = "\n" + after
  146. before_theories[before] += 1
  147. after_theories[after] += 1
  148. before = self._select_theory(before_theories)
  149. after = self._select_theory(after_theories)
  150. return before, after
  151. def _fix_dependendent_params(self, i: int) -> None:
  152. """Unhide keys if necessary after removing the param at index *i*."""
  153. if not self.params[i].showkey:
  154. for param in self.params[i + 1 :]:
  155. if not param.showkey:
  156. param.showkey = True
  157. def _remove_exact(self, needle: Parameter, keep_field: bool) -> None:
  158. """Remove a specific parameter, *needle*, from the template."""
  159. for i, param in enumerate(self.params):
  160. if param is needle:
  161. if keep_field:
  162. self._blank_param_value(param.value)
  163. else:
  164. self._fix_dependendent_params(i)
  165. self.params.pop(i)
  166. return
  167. raise ValueError(needle)
  168. def _should_remove(self, i: int, name: str) -> bool:
  169. """Look ahead for a parameter with the same name, but hidden.
  170. If one exists, we should remove the given one rather than blanking it.
  171. """
  172. if self.params[i].showkey:
  173. following = self.params[i + 1 :]
  174. better_matches = [
  175. after.name.strip() == name and not after.showkey for after in following
  176. ]
  177. return any(better_matches)
  178. return False
  179. @property
  180. def name(self) -> Wikicode:
  181. """The name of the template, as a :class:`.Wikicode` object."""
  182. return self._name
  183. @name.setter
  184. def name(self, value: Any) -> None:
  185. self._name = parse_anything(value)
  186. @property
  187. def params(self) -> list[Parameter]:
  188. """The list of parameters contained within the template."""
  189. return self._params
  190. def has(self, name: str | Any, ignore_empty: bool = False) -> bool:
  191. """Return ``True`` if any parameter in the template is named *name*.
  192. With *ignore_empty*, ``False`` will be returned even if the template
  193. contains a parameter with the name *name*, if the parameter's value
  194. is empty. Note that a template may have multiple parameters with the
  195. same name, but only the last one is read by the MediaWiki parser.
  196. """
  197. name = str(name).strip()
  198. for param in self.params:
  199. if param.name.strip() == name:
  200. if ignore_empty and not param.value.strip():
  201. continue
  202. return True
  203. return False
  204. def has_param(self, name: str | Any, ignore_empty: bool = False) -> bool:
  205. """Alias for :meth:`has`."""
  206. return self.has(name, ignore_empty)
  207. @overload
  208. def get(self, name: str | Any) -> Parameter: ...
  209. @overload
  210. def get(self, name: str | Any, default: T) -> Parameter | T: ...
  211. def get(self, name: str | Any, default: T = _UNSET) -> Parameter | T:
  212. """Get the parameter whose name is *name*.
  213. The returned object is a :class:`.Parameter` instance. Raises
  214. :exc:`ValueError` if no parameter has this name. If *default* is set,
  215. returns that instead. Since multiple parameters can have the same name,
  216. we'll return the last match, since the last parameter is the only one
  217. read by the MediaWiki parser.
  218. """
  219. name = str(name).strip()
  220. for param in reversed(self.params):
  221. if param.name.strip() == name:
  222. return param
  223. if default is _UNSET:
  224. raise ValueError(name)
  225. return default
  226. def __getitem__(self, name: str | Any) -> Parameter:
  227. return self.get(name)
  228. def add(
  229. self,
  230. name: Any,
  231. value: Any,
  232. showkey: bool | None = None,
  233. before: Parameter | str | None = None,
  234. after: Parameter | str | None = None,
  235. preserve_spacing: bool = True,
  236. ) -> Parameter:
  237. """Add a parameter to the template with a given *name* and *value*.
  238. *name* and *value* can be anything parsable by
  239. :func:`.utils.parse_anything`; pipes and equal signs are automatically
  240. escaped from *value* when appropriate.
  241. If *name* is already a parameter in the template, we'll replace its
  242. value.
  243. If *showkey* is given, this will determine whether or not to show the
  244. parameter's name (e.g., ``{{foo|bar}}``'s parameter has a name of
  245. ``"1"`` but it is hidden); otherwise, we'll make a safe and intelligent
  246. guess.
  247. If *before* is given (either a :class:`.Parameter` object or a name),
  248. then we will place the parameter immediately before this one.
  249. Otherwise, it will be added at the end. If *before* is a name and
  250. exists multiple times in the template, we will place it before the last
  251. occurrence. If *before* is not in the template, :exc:`ValueError` is
  252. raised. The argument is ignored if *name* is an existing parameter.
  253. If *after* is given (either a :class:`.Parameter` object or a name),
  254. then we will place the parameter immediately after this one. If *after*
  255. is a name and exists multiple times in the template, we will place it
  256. after the last occurrence. If *after* is not in the template,
  257. :exc:`ValueError` is raised. The argument is ignored if *name* is an
  258. existing parameter or if a value is passed to *before*.
  259. If *preserve_spacing* is ``True``, we will try to preserve whitespace
  260. conventions around the parameter, whether it is new or we are updating
  261. an existing value. It is disabled for parameters with hidden keys,
  262. since MediaWiki doesn't strip whitespace in this case.
  263. """
  264. name, value = parse_anything(name), parse_anything(value)
  265. self._surface_escape(value, "|")
  266. if self.has(name):
  267. self.remove(name, keep_field=True)
  268. existing = self.get(name)
  269. if showkey is not None:
  270. existing.showkey = showkey
  271. if not existing.showkey:
  272. self._surface_escape(value, "=")
  273. nodes: list[Node | None] = list(existing.value.nodes)
  274. if preserve_spacing and existing.showkey:
  275. for i in range(2): # Ignore empty text nodes
  276. if not nodes[i]:
  277. nodes[i] = None
  278. existing.value = parse_anything([nodes[0], value, nodes[1]])
  279. else:
  280. existing.value = value
  281. return existing
  282. if showkey is None:
  283. if Parameter.can_hide_key(name):
  284. int_name = int(str(name))
  285. int_keys = set()
  286. for param in self.params:
  287. if not param.showkey:
  288. int_keys.add(int(str(param.name)))
  289. expected = min(set(range(1, len(int_keys) + 2)) - int_keys)
  290. if expected == int_name:
  291. showkey = False
  292. else:
  293. showkey = True
  294. else:
  295. showkey = True
  296. if not showkey:
  297. self._surface_escape(value, "=")
  298. if preserve_spacing and showkey:
  299. before_n, after_n = self._get_spacing_conventions(use_names=True)
  300. before_v, after_v = self._get_spacing_conventions(use_names=False)
  301. name = parse_anything([before_n, name, after_n])
  302. value = parse_anything([before_v, value, after_v])
  303. param = Parameter(name, value, showkey)
  304. if before:
  305. assert after is None, "Cannot set a value for both 'before' and 'after'"
  306. if not isinstance(before, Parameter):
  307. before = self.get(before)
  308. self.params.insert(self.params.index(before), param)
  309. elif after:
  310. if not isinstance(after, Parameter):
  311. after = self.get(after)
  312. self.params.insert(self.params.index(after) + 1, param)
  313. else:
  314. self.params.append(param)
  315. return param
  316. def update(self, params: Mapping[Any, Any], **kwargs: Any) -> None:
  317. """Update the template with multiple parameters at once.
  318. Args:
  319. params: A dictionary mapping parameter names to values.
  320. **kwargs: Optional arguments that will be applied to all parameters,
  321. matching the same arguments in :meth:`add` (showkey, before,
  322. after, preserve_spacing)
  323. """
  324. for name, value in params.items():
  325. self.add(name, value, **kwargs)
  326. def __setitem__(self, name: Any, value: Any) -> Parameter:
  327. return self.add(name, value)
  328. def remove(self, param: Parameter | str, keep_field: bool = False) -> None:
  329. """Remove a parameter from the template, identified by *param*.
  330. If *param* is a :class:`.Parameter` object, it will be matched exactly,
  331. otherwise it will be treated like the *name* argument to :meth:`has`
  332. and :meth:`get`.
  333. If *keep_field* is ``True``, we will keep the parameter's name, but
  334. blank its value. Otherwise, we will remove the parameter completely.
  335. When removing a parameter with a hidden name, subsequent parameters
  336. with hidden names will be made visible. For example, removing ``bar``
  337. from ``{{foo|bar|baz}}`` produces ``{{foo|2=baz}}`` because
  338. ``{{foo|baz}}`` is incorrect.
  339. If the parameter shows up multiple times in the template and *param* is
  340. not a :class:`.Parameter` object, we will remove all instances of it
  341. (and keep only one if *keep_field* is ``True`` - either the one with a
  342. hidden name, if it exists, or the first instance).
  343. """
  344. if isinstance(param, Parameter):
  345. self._remove_exact(param, keep_field)
  346. return
  347. name = str(param).strip()
  348. removed = False
  349. to_remove = []
  350. for i, par in enumerate(self.params):
  351. if par.name.strip() == name:
  352. if keep_field:
  353. if self._should_remove(i, name):
  354. to_remove.append(i)
  355. else:
  356. self._blank_param_value(par.value)
  357. keep_field = False
  358. else:
  359. self._fix_dependendent_params(i)
  360. to_remove.append(i)
  361. if not removed:
  362. removed = True
  363. if not removed:
  364. raise ValueError(name)
  365. for i in reversed(to_remove):
  366. self.params.pop(i)
  367. def __delitem__(self, param: Parameter | str) -> None:
  368. return self.remove(param)