Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
# Run all matrix jobs even if one of them fails.
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12']
python: ['3.10', '3.11', '3.12', '3.13']
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: windows-latest
Expand Down
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pyxform
.. |pypi| image:: https://badge.fury.io/py/pyxform.svg
:target: https://badge.fury.io/py/pyxform

.. |python| image:: https://img.shields.io/badge/python-3.10,3.11,3.12-blue.svg
.. |python| image:: https://img.shields.io/badge/python-3.10,3.11,3.12,3.13-blue.svg
:target: https://www.python.org/downloads

``pyxform`` is a Python library that simplifies writing forms for ODK Collect and Enketo by converting spreadsheets that follow the `XLSForm standard <http://xlsform.org/>`_ into `ODK XForms <https://github.com/opendatakit/xforms-spec>`_. The XLSForms format is used in a `number of tools <http://xlsform.org/en/#tools-that-support-xlsforms>`_.
Expand Down Expand Up @@ -44,7 +44,7 @@ The ``xls2xform`` command can then be used::

xls2xform path_to_XLSForm [output_path]

The currently supported Python versions for ``pyxform`` are 3.10, 3.11 and 3.12.
The currently supported Python versions for ``pyxform`` are 3.10, 3.11, 3.12, and 3.13.

Running pyxform from local source
---------------------------------
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies = [
[project.optional-dependencies]
# Install with `pip install pyxform[dev]`.
dev = [
"formencode==2.1.0", # Compare XML
"formencode==2.1.1", # Compare XML
"lxml==5.3.0", # XPath test expressions
"psutil==6.1.0", # Process info for performance tests
"ruff==0.7.1", # Format and lint
Expand Down
21 changes: 12 additions & 9 deletions pyxform/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from itertools import chain
from json.decoder import JSONDecodeError
from xml.dom import Node
from xml.dom.minidom import Element, Text, _write_data
from xml.dom.minidom import Element, Text

from defusedxml.minidom import parseString

Expand All @@ -29,6 +29,8 @@
SPACE_TRANS_TABLE = str.maketrans({" ": "_"})
XML_TEXT_SUBS = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
XML_TEXT_TABLE = str.maketrans(XML_TEXT_SUBS)
XML_ATTR_SUBS = {'"': "&quot;", "\r": "&#13;", "\n": "&#10;", "\t": "&#9;"}
XML_ATTR_TABLE = str.maketrans(XML_ATTR_SUBS)


class DetachableElement(Element):
Expand All @@ -54,9 +56,8 @@ def writexml(self, writer, indent="", addindent="", newl=""):

if self._attrs:
for k, v in self._attrs.items():
writer.write(f' {k}="')
_write_data(writer, v.value)
writer.write('"')
# First space prefix separates attr from tagName, then it separates attrs.
writer.write(f' {k}="{escape_text_for_xml(v.value, attribute=True)}"')
if self.childNodes:
writer.write(">")
# For text or mixed content, write without adding indents or newlines.
Expand All @@ -80,11 +81,13 @@ def writexml(self, writer, indent="", addindent="", newl=""):


@lru_cache(maxsize=64)
def escape_text_for_xml(text: str) -> str:
if any(c in set(text) for c in XML_TEXT_SUBS):
return text.translate(XML_TEXT_TABLE)
else:
return text
def escape_text_for_xml(text: str, attribute: bool = False) -> str:
chars = set(text)
if any(c in chars for c in XML_TEXT_SUBS):
text = text.translate(XML_TEXT_TABLE)
if attribute and any(c in chars for c in XML_ATTR_SUBS):
text = text.translate(XML_ATTR_TABLE)
return text


class PatchedText(Text):
Expand Down
29 changes: 20 additions & 9 deletions tests/xform_test_case/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Test XForm XML syntax.
"""

from sys import version_info
from unittest import TestCase
from xml.dom.minidom import getDOMImplementation

Expand Down Expand Up @@ -86,20 +87,30 @@ def test_to_xml(self):


class MinidomTextWriterMonkeyPatchTest(TestCase):
maxDiff = None

def test_patch_lets_node_func_escape_only_necessary(self):
"""Should only escape text chars that should be: ["<", ">", "&"]."""
text = "' \" & < >"
expected = "<root>' \" &amp; &lt; &gt;</root>"
observed = node("root", text).toprettyxml(indent="", newl="")
"""Should find that pyxform escapes ["&<>\r\n\t] in attrs and [&<>] in text."""
replaceable_chars = "' \" & < > \r \n \t"
expected = """<root attr="' &quot; &amp; &lt; &gt; &#13; &#10; &#9;">' " &amp; &lt; &gt; \r \n \t</root>"""
observed = node("root", replaceable_chars, attr=replaceable_chars).toprettyxml(
indent="", newl=""
)
self.assertEqual(expected, observed)

def test_original_escape_escapes_more_than_necessary(self):
"""Should fail if the original is updated (the patch can be removed)."""
text = "' \" & < >"
expected = "<root>' &quot; &amp; &lt; &gt;</root>"
replaceable_chars = "' \" & < > \r \n \t"
document = getDOMImplementation().createDocument(None, "root", None)
root = document.documentElement
text_node = document.createTextNode(text)
root.appendChild(text_node)
root.appendChild(document.createTextNode(replaceable_chars))
root.setAttribute("attr", replaceable_chars)
observed = root.toprettyxml(indent="", newl="")
self.assertEqual(expected, observed)
if version_info.major == 3 and version_info.minor >= 13:
# In 3.13, minidom was updated to only escape " in attrs, and also escape \r\n\t.
expected = """<root attr="' &quot; &amp; &lt; &gt; &#13; &#10; &#9;">' " &amp; &lt; &gt; \r \n \t</root>"""
self.assertEqual(expected, observed)
else:
# Prior to 3.13 " was escaped in text unnecessarily, and \r\n\t not escaped in attrs.
expected = """<root attr="' &quot; &amp; &lt; &gt; \r \n \t">' &quot; &amp; &lt; &gt; \r \n \t</root>"""
self.assertEqual(expected, observed)