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.
 
 
 
 

474 lines
20 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # Copyright (C) 2012 Ben Kurtovic <ben.kurtovic@verizon.net>
  4. #
  5. # Permission is hereby granted, free of charge, to any person obtaining a copy
  6. # of this software and associated documentation files (the "Software"), to deal
  7. # in the Software without restriction, including without limitation the rights
  8. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. # copies of the Software, and to permit persons to whom the Software is
  10. # furnished to do so, subject to the following conditions:
  11. #
  12. # The above copyright notice and this permission notice shall be included in
  13. # all copies or substantial portions of the Software.
  14. #
  15. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21. # SOFTWARE.
  22. from __future__ import unicode_literals
  23. import re
  24. from .compat import maxsize, str
  25. from .nodes import Heading, Node, Tag, Template, Text
  26. from .string_mixin import StringMixIn
  27. from .utils import parse_anything
  28. __all__ = ["Wikicode"]
  29. FLAGS = re.IGNORECASE | re.DOTALL | re.UNICODE
  30. class Wikicode(StringMixIn):
  31. """A ``Wikicode`` is a container for nodes that operates like a string.
  32. Additionally, it contains methods that can be used to extract data from or
  33. modify the nodes, implemented in an interface similar to a list. For
  34. example, :py:meth:`index` can get the index of a node in the list, and
  35. :py:meth:`insert` can add a new node at that index. The :py:meth:`filter()
  36. <ifilter>` series of functions is very useful for extracting and iterating
  37. over, for example, all of the templates in the object.
  38. """
  39. def __init__(self, nodes):
  40. super(Wikicode, self).__init__()
  41. self._nodes = nodes
  42. def __unicode__(self):
  43. return "".join([str(node) for node in self.nodes])
  44. def _get_children(self, node):
  45. """Iterate over all descendants of a given *node*, including itself.
  46. This is implemented by the ``__iternodes__()`` generator of ``Node``
  47. classes, which by default yields itself and nothing more.
  48. """
  49. for context, child in node.__iternodes__(self._get_all_nodes):
  50. yield child
  51. def _get_context(self, node, obj):
  52. """Return a ``Wikicode`` that contains *obj* in its descendants.
  53. The closest (shortest distance from *node*) suitable ``Wikicode`` will
  54. be returned, or ``None`` if the *obj* is the *node* itself.
  55. Raises ``ValueError`` if *obj* is not within *node*.
  56. """
  57. for context, child in node.__iternodes__(self._get_all_nodes):
  58. if child is obj:
  59. return context
  60. raise ValueError(obj)
  61. def _get_all_nodes(self, code):
  62. """Iterate over all of our descendant nodes.
  63. This is implemented by calling :py:meth:`_get_children` on every node
  64. in our node list (:py:attr:`self.nodes <nodes>`).
  65. """
  66. for node in code.nodes:
  67. for child in self._get_children(node):
  68. yield child
  69. def _is_equivalent(self, obj, node):
  70. """Return ``True`` if *obj* and *node* are equivalent, else ``False``.
  71. If *obj* is a ``Node``, the function will test whether they are the
  72. same object, otherwise it will compare them with ``==``.
  73. """
  74. if isinstance(obj, Node):
  75. if node is obj:
  76. return True
  77. else:
  78. if node == obj:
  79. return True
  80. return False
  81. def _contains(self, nodes, obj):
  82. """Return ``True`` if *obj* is inside of *nodes*, else ``False``.
  83. If *obj* is a ``Node``, we will only return ``True`` if *obj* is
  84. actually in the list (and not just a node that equals it). Otherwise,
  85. the test is simply ``obj in nodes``.
  86. """
  87. if isinstance(obj, Node):
  88. for node in nodes:
  89. if node is obj:
  90. return True
  91. return False
  92. return obj in nodes
  93. def _do_search(self, obj, recursive, callback, context, *args, **kwargs):
  94. """Look within *context* for *obj*, executing *callback* if found.
  95. If *recursive* is ``True``, we'll look within context and its
  96. descendants, otherwise we'll just execute callback. We raise
  97. :py:exc:`ValueError` if *obj* isn't in our node list or context. If
  98. found, *callback* is passed the context, the index of the node within
  99. the context, and whatever were passed as ``*args`` and ``**kwargs``.
  100. """
  101. if recursive:
  102. for i, node in enumerate(context.nodes):
  103. if self._is_equivalent(obj, node):
  104. return callback(context, i, *args, **kwargs)
  105. if self._contains(self._get_children(node), obj):
  106. context = self._get_context(node, obj)
  107. return self._do_search(obj, recursive, callback, context,
  108. *args, **kwargs)
  109. raise ValueError(obj)
  110. callback(context, self.index(obj, recursive=False), *args, **kwargs)
  111. def _get_tree(self, code, lines, marker, indent):
  112. """Build a tree to illustrate the way the Wikicode object was parsed.
  113. The method that builds the actual tree is ``__showtree__`` of ``Node``
  114. objects. *code* is the ``Wikicode`` object to build a tree for. *lines*
  115. is the list to append the tree to, which is returned at the end of the
  116. method. *marker* is some object to be used to indicate that the builder
  117. should continue on from the last line instead of starting a new one; it
  118. should be any object that can be tested for with ``is``. *indent* is
  119. the starting indentation.
  120. """
  121. def write(*args):
  122. if lines and lines[-1] is marker: # Continue from the last line
  123. lines.pop() # Remove the marker
  124. last = lines.pop()
  125. lines.append(last + " ".join(args))
  126. else:
  127. lines.append(" " * 6 * indent + " ".join(args))
  128. get = lambda code: self._get_tree(code, lines, marker, indent + 1)
  129. mark = lambda: lines.append(marker)
  130. for node in code.nodes:
  131. node.__showtree__(write, get, mark)
  132. return lines
  133. @property
  134. def nodes(self):
  135. """A list of :py:class:`~mwparserfromhell.nodes.Node` objects.
  136. This is the internal data actually stored within a
  137. :py:class:`~mwparserfromhell.wikicode.Wikicode` object.
  138. """
  139. return self._nodes
  140. @nodes.setter
  141. def nodes(self, value):
  142. self._nodes = value
  143. def get(self, index):
  144. """Return the *index*\ th node within the list of nodes."""
  145. return self.nodes[index]
  146. def set(self, index, value):
  147. """Set the ``Node`` at *index* to *value*.
  148. Raises :py:exc:`IndexError` if *index* is out of range, or
  149. :py:exc:`ValueError` if *value* cannot be coerced into one
  150. :py:class:`~mwparserfromhell.nodes.Node`. To insert multiple nodes at
  151. an index, use :py:meth:`get` with either :py:meth:`remove` and
  152. :py:meth:`insert` or :py:meth:`replace`.
  153. """
  154. nodes = parse_anything(value).nodes
  155. if len(nodes) > 1:
  156. raise ValueError("Cannot coerce multiple nodes into one index")
  157. if index >= len(self.nodes) or -1 * index > len(self.nodes):
  158. raise IndexError("List assignment index out of range")
  159. self.nodes.pop(index)
  160. if nodes:
  161. self.nodes[index] = nodes[0]
  162. def index(self, obj, recursive=False):
  163. """Return the index of *obj* in the list of nodes.
  164. Raises :py:exc:`ValueError` if *obj* is not found. If *recursive* is
  165. ``True``, we will look in all nodes of ours and their descendants, and
  166. return the index of our direct descendant node within *our* list of
  167. nodes. Otherwise, the lookup is done only on direct descendants.
  168. """
  169. if recursive:
  170. for i, node in enumerate(self.nodes):
  171. if self._contains(self._get_children(node), obj):
  172. return i
  173. raise ValueError(obj)
  174. for i, node in enumerate(self.nodes):
  175. if self._is_equivalent(obj, node):
  176. return i
  177. raise ValueError(obj)
  178. def insert(self, index, value):
  179. """Insert *value* at *index* in the list of nodes.
  180. *value* can be anything parasable by
  181. :py:func:`mwparserfromhell.utils.parse_anything`, which includes
  182. strings or other :py:class:`~mwparserfromhell.wikicode.Wikicode` or
  183. :py:class:`~mwparserfromhell.nodes.Node` objects.
  184. """
  185. nodes = parse_anything(value).nodes
  186. for node in reversed(nodes):
  187. self.nodes.insert(index, node)
  188. def insert_before(self, obj, value, recursive=True):
  189. """Insert *value* immediately before *obj* in the list of nodes.
  190. *obj* can be either a string or a
  191. :py:class:`~mwparserfromhell.nodes.Node`. *value* can be anything
  192. parasable by :py:func:`mwparserfromhell.utils.parse_anything`. If
  193. *recursive* is ``True``, we will try to find *obj* within our child
  194. nodes even if it is not a direct descendant of this
  195. :py:class:`~mwparserfromhell.wikicode.Wikicode` object. If *obj* is not
  196. in the node list, :py:exc:`ValueError` is raised.
  197. """
  198. callback = lambda self, i, value: self.insert(i, value)
  199. self._do_search(obj, recursive, callback, self, value)
  200. def insert_after(self, obj, value, recursive=True):
  201. """Insert *value* immediately after *obj* in the list of nodes.
  202. *obj* can be either a string or a
  203. :py:class:`~mwparserfromhell.nodes.Node`. *value* can be anything
  204. parasable by :py:func:`mwparserfromhell.utils.parse_anything`. If
  205. *recursive* is ``True``, we will try to find *obj* within our child
  206. nodes even if it is not a direct descendant of this
  207. :py:class:`~mwparserfromhell.wikicode.Wikicode` object. If *obj* is not
  208. in the node list, :py:exc:`ValueError` is raised.
  209. """
  210. callback = lambda self, i, value: self.insert(i + 1, value)
  211. self._do_search(obj, recursive, callback, self, value)
  212. def replace(self, obj, value, recursive=True):
  213. """Replace *obj* with *value* in the list of nodes.
  214. *obj* can be either a string or a
  215. :py:class:`~mwparserfromhell.nodes.Node`. *value* can be anything
  216. parasable by :py:func:`mwparserfromhell.utils.parse_anything`. If
  217. *recursive* is ``True``, we will try to find *obj* within our child
  218. nodes even if it is not a direct descendant of this
  219. :py:class:`~mwparserfromhell.wikicode.Wikicode` object. If *obj* is not
  220. in the node list, :py:exc:`ValueError` is raised.
  221. """
  222. def callback(self, i, value):
  223. self.nodes.pop(i)
  224. self.insert(i, value)
  225. self._do_search(obj, recursive, callback, self, value)
  226. def append(self, value):
  227. """Insert *value* at the end of the list of nodes.
  228. *value* can be anything parasable by
  229. :py:func:`mwparserfromhell.utils.parse_anything`.
  230. """
  231. nodes = parse_anything(value).nodes
  232. for node in nodes:
  233. self.nodes.append(node)
  234. def remove(self, obj, recursive=True):
  235. """Remove *obj* from the list of nodes.
  236. *obj* can be either a string or a
  237. :py:class:`~mwparserfromhell.nodes.Node`. If *recursive* is ``True``,
  238. we will try to find *obj* within our child nodes even if it is not a
  239. direct descendant of this
  240. :py:class:`~mwparserfromhell.wikicode.Wikicode` object. If *obj* is not
  241. in the node list, :py:exc:`ValueError` is raised.
  242. """
  243. callback = lambda self, i: self.nodes.pop(i)
  244. self._do_search(obj, recursive, callback, self)
  245. def ifilter(self, recursive=False, matches=None, flags=FLAGS,
  246. forcetype=None):
  247. """Iterate over nodes in our list matching certain conditions.
  248. If *recursive* is ``True``, we will iterate over our children and all
  249. descendants of our children, otherwise just our immediate children. If
  250. *matches* is given, we will only yield the nodes that match the given
  251. regular expression (with :py:func:`re.search`). The default flags used
  252. are :py:const:`re.IGNORECASE`, :py:const:`re.DOTALL`, and
  253. :py:const:`re.UNICODE`, but custom flags can be specified by passing
  254. *flags*. If *forcetype* is given, only nodes that are instances of this
  255. type are yielded.
  256. """
  257. if recursive:
  258. nodes = self._get_all_nodes(self)
  259. else:
  260. nodes = self.nodes
  261. for node in nodes:
  262. if not forcetype or isinstance(node, forcetype):
  263. if not matches or re.search(matches, str(node), flags):
  264. yield node
  265. def ifilter_templates(self, recursive=False, matches=None, flags=FLAGS):
  266. """Iterate over template nodes.
  267. This is equivalent to :py:meth:`ifilter` with *forcetype* set to
  268. :py:class:`~mwparserfromhell.nodes.template.Template`.
  269. """
  270. return self.filter(recursive, matches, flags, forcetype=Template)
  271. def ifilter_text(self, recursive=False, matches=None, flags=FLAGS):
  272. """Iterate over text nodes.
  273. This is equivalent to :py:meth:`ifilter` with *forcetype* set to
  274. :py:class:`~mwparserfromhell.nodes.text.Text`.
  275. """
  276. return self.filter(recursive, matches, flags, forcetype=Text)
  277. def ifilter_tags(self, recursive=False, matches=None, flags=FLAGS):
  278. """Iterate over tag nodes.
  279. This is equivalent to :py:meth:`ifilter` with *forcetype* set to
  280. :py:class:`~mwparserfromhell.nodes.tag.Tag`.
  281. """
  282. return self.ifilter(recursive, matches, flags, forcetype=Tag)
  283. def filter(self, recursive=False, matches=None, flags=FLAGS,
  284. forcetype=None):
  285. """Return a list of nodes within our list matching certain conditions.
  286. This is equivalent to calling :py:func:`list` on :py:meth:`ifilter`.
  287. """
  288. return list(self.ifilter(recursive, matches, flags, forcetype))
  289. def filter_templates(self, recursive=False, matches=None, flags=FLAGS):
  290. """Return a list of template nodes.
  291. This is equivalent to calling :py:func:`list` on
  292. :py:meth:`ifilter_templates`.
  293. """
  294. return list(self.ifilter_templates(recursive, matches, flags))
  295. def filter_text(self, recursive=False, matches=None, flags=FLAGS):
  296. """Return a list of text nodes.
  297. This is equivalent to calling :py:func:`list` on
  298. :py:meth:`ifilter_text`.
  299. """
  300. return list(self.ifilter_text(recursive, matches, flags))
  301. def filter_tags(self, recursive=False, matches=None, flags=FLAGS):
  302. """Return a list of tag nodes.
  303. This is equivalent to calling :py:func:`list` on
  304. :py:meth:`ifilter_tags`.
  305. """
  306. return list(self.ifilter_tags(recursive, matches, flags))
  307. def get_sections(self, flat=True, matches=None, levels=None, flags=FLAGS,
  308. include_headings=True):
  309. """Return a list of sections within the page.
  310. Sections are returned as
  311. :py:class:`~mwparserfromhell.wikicode.Wikicode` objects with a shared
  312. node list (implemented using
  313. :py:class:`~mwparserfromhell.smart_list.SmartList`) so that changes to
  314. sections are reflected in the parent Wikicode object.
  315. With *flat* as ``True``, each returned section contains all of its
  316. subsections within the :py:class:`~mwparserfromhell.wikicode.Wikicode`;
  317. otherwise, the returned sections contain only the section up to the
  318. next heading, regardless of its size. If *matches* is given, it should
  319. be a regex to matched against the titles of section headings; only
  320. sections whose headings match the regex will be included. If *levels*
  321. is given, it should be a list of integers; only sections whose heading
  322. levels are within the list will be returned. If *include_headings* is
  323. ``True``, the section's literal
  324. :py:class:`~mwparserfromhell.nodes.heading.Heading` object will be
  325. included in returned :py:class:`~mwparserfromhell.wikicode.Wikicode`
  326. objects; otherwise, this is skipped.
  327. """
  328. if matches:
  329. matches = r"^(=+?)\s*" + matches + r"\s*\1$"
  330. headings = self.filter(recursive=True, matches=matches, flags=flags,
  331. forcetype=Heading)
  332. if levels:
  333. headings = [head for head in headings if head.level in levels]
  334. sections = []
  335. buffers = [[maxsize, 0]]
  336. i = 0
  337. while i < len(self.nodes):
  338. if self.nodes[i] in headings:
  339. this = self.nodes[i].level
  340. for (level, start) in buffers:
  341. if not flat or this <= level:
  342. buffers.remove([level, start])
  343. sections.append(Wikicode(self.nodes[start:i]))
  344. buffers.append([this, i])
  345. if not include_headings:
  346. i += 1
  347. i += 1
  348. for (level, start) in buffers:
  349. if start != i:
  350. sections.append(Wikicode(self.nodes[start:i]))
  351. return sections
  352. def strip_code(self, normalize=True, collapse=True):
  353. """Return a rendered string without unprintable code such as templates.
  354. The way a node is stripped is handled by the
  355. :py:meth:`~mwparserfromhell.nodes.Node.__showtree__` method of
  356. :py:class:`~mwparserfromhell.nodes.Node` objects, which generally
  357. return a subset of their nodes or ``None``. For example, templates and
  358. tags are removed completely, links are stripped to just their display
  359. part, headings are stripped to just their title. If *normalize* is
  360. ``True``, various things may be done to strip code further, such as
  361. converting HTML entities like ``&Sigma;``, ``&#931;``, and ``&#x3a3;``
  362. to ``Σ``. If *collapse* is ``True``, we will try to remove excess
  363. whitespace as well (three or more newlines are converted to two, for
  364. example).
  365. """
  366. nodes = []
  367. for node in self.nodes:
  368. stripped = node.__strip__(normalize, collapse)
  369. if stripped:
  370. nodes.append(str(stripped))
  371. if collapse:
  372. stripped = "".join(nodes).strip("\n")
  373. while "\n\n\n" in stripped:
  374. stripped = stripped.replace("\n\n\n", "\n\n")
  375. return stripped
  376. else:
  377. return "".join(nodes)
  378. def get_tree(self):
  379. """Return a hierarchical tree representation of the object.
  380. The representation is a string makes the most sense printed. It is
  381. built by calling :py:meth:`_get_tree` on the
  382. :py:class:`~mwparserfromhell.wikicode.Wikicode` object and its children
  383. recursively. The end result may look something like the following::
  384. >>> text = "Lorem ipsum {{foo|bar|{{baz}}|spam=eggs}}"
  385. >>> print mwparserfromhell.parse(text).get_tree()
  386. Lorem ipsum
  387. {{
  388. foo
  389. | 1
  390. = bar
  391. | 2
  392. = {{
  393. baz
  394. }}
  395. | spam
  396. = eggs
  397. }}
  398. """
  399. marker = object() # Random object we can find with certainty in a list
  400. return "\n".join(self._get_tree(self, [], marker, 0))