Luke Ross

lxmlmeld

6 releases git clone https://lukeross.name/projects/lxmlmeld.git/

Meld-like templating using lxml.

Commit 05bbbfd2e5d5b2d57e4199e70937ec718a6b4fe8

Fix issue #1 and issue #2

Committed 15 Mar 2017 by Luke Ross & Luke Ross

lxmlmeld/__init__.py

@@ -58,6 +58,23 @@ class Element(etree.ElementBase):
         super(Element, self).replace(old_element, new_element)
 
     def replace(self, text, structure=False):
+        """
+        Replace the element with argument given.
+
+        If the argument is text and structure is False (the default), argument
+        is treated as a plain-text string (and is escaped if required).
+
+        If the argument is text and structure is True, the argument is treated
+        as text containing some XML elements and inserted as part of the
+        document. The text needs to be parseable as XML.
+
+        If the argument is an lxml Element node it is used as the replacement.
+
+        If the argument is a list or tuple it is expected to be a list of
+        lxml Element nodes which will all be used as the replacement.
+
+        Returns nothing.
+        """
         parent = self.getparent()
         if parent is None:
             return
@@ -77,8 +94,16 @@ class Element(etree.ElementBase):
             xml = etree.XML("<dispose>{}</dispose>".format(text))
             self.replace(list(xml) or xml.text)
         else:
+            if self.tail:
+                text += self.tail
             prev = self.getprevious()
-            prev.tail = (prev.tail or '') + text
+            if prev is not None:
+                prev.tail = (prev.tail or '') + text
+            else:
+                if parent.text:
+                    parent.text += text
+                else:
+                    parent.text = text
             parent.remove(self)
 
     def content(self, text, structure=False):
@@ -193,18 +218,26 @@ def _check_tree(tree):
 
 
 def parse_xml(xml):
+    """
+    Parses XML from a file-like object. Returns the root element.
+    """
     t = etree.parse(xml, _parser()).getroot()
     _check_tree(t)
     return t
 
 
 def parse_xmlstring(xml):
-    t = etree.fromstring(xml, _parser()).getroot()
+    """
+    Parses a str or unicode of XML. Returns the root element.
+    """
+    t = etree.fromstring(xml, _parser())
     _check_tree(t)
     return t
 
 
 def _fix_html(tree):
+    # The HTML parser deliberately doesn't parse namespaces, so this phase
+    # moves the meld:id attributes into the correct namespace.
     qn = etree.QName(NS, "id").text
     for ele in tree.iter():
         if "meld:id" in ele.attrib:
@@ -219,7 +252,7 @@ def parse_html(html):
 
 
 def parse_htmlstring(html):
-    t = etree.fromstring(html, _parser(etree.HTMLParser)).getroot()
+    t = etree.fromstring(html, _parser(etree.HTMLParser))
     _fix_html(t)
     _check_tree(t)
     return t


tests/test_calls.py

@@ -0,0 +1,39 @@
+from unittest import TestCase
+
+from lxmlmeld import parse_xmlstring
+
+
+class ReplaceTests(TestCase):
+    def as_expected(self, arg, expected_in_output, **kwargs):
+        docs = (
+            (  # Naked
+                "<foo xmlns:meld='http://www.plope.com/software/meld3'><replaceme meld:id='r' /></foo>",
+                "<?xml version='1.0' encoding='ASCII'?>\n<foo>{}</foo>",
+            ),
+            (  # Surrounded by elements
+                "<foo xmlns:meld='http://www.plope.com/software/meld3'><bar /><replaceme meld:id='r' /><baz /></foo>",
+                "<?xml version='1.0' encoding='ASCII'?>\n<foo><bar/>{}<baz/></foo>",
+            ),
+            (  # Surrounded by text
+                "<foo xmlns:meld='http://www.plope.com/software/meld3'>bar <replaceme meld:id='r' />baz</foo>",
+                "<?xml version='1.0' encoding='ASCII'?>\n<foo>bar {}baz</foo>",
+            ),
+            (  # Surrounded by mixed
+                "<foo xmlns:meld='http://www.plope.com/software/meld3'><bar />bar <replaceme meld:id='r' />baz</foo>",
+                "<?xml version='1.0' encoding='ASCII'?>\n<foo><bar/>bar {}baz</foo>",
+            ),
+        )
+        for ip, op in docs:
+            doc = parse_xmlstring(ip)
+            doc.findmeld("r").replace(arg, **kwargs)
+            self.assertEqual(doc.write_xmlstring(), op.format(expected_in_output).encode("ascii"), op.format(expected_in_output))
+
+    def test_replace_with_plain_text(self):
+        self.as_expected("<hello world!>", "&lt;hello world!&gt;")
+
+    def test_replace_with_structure(self):
+        self.as_expected("<hello word='world' /><a />",
+                         '<hello word="world"/><a/>', structure=True)
+
+    def test_replace_with_nodes(self):
+        pass


tests/test_parsing.py

@@ -0,0 +1,22 @@
+from io import StringIO
+from unittest import TestCase
+
+from lxmlmeld import parse_xml, parse_xmlstring
+
+
+class XMLTests(TestCase):
+    def as_expected(self, handler):
+        scenarios = (
+            ("<xml />", "<?xml version='1.0' encoding='ASCII'?>\n<xml/>"),
+            ("<?xml version='1.0' ?><xml />", "<?xml version='1.0' encoding='ASCII'?>\n<xml/>"),
+            ("<xml><a /><b /></xml>", "<?xml version='1.0' encoding='ASCII'?>\n<xml><a/><b/></xml>"),
+        )
+        for ip, op in scenarios:
+            doc = handler(ip)
+            self.assertEqual(doc.write_xmlstring(), op.encode("ascii"))
+
+    def test_parse_string(self):
+        self.as_expected(parse_xmlstring)
+
+    def test_parse_handle(self):
+        self.as_expected(lambda i: parse_xml(StringIO(i)))