Repository: python/cpython
Pull request: gh-91447: Fix findtext to only give an empty string on None
The API documentation for [findtext](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element.findtext) states that this function gives back an empty string on "no text content." With the previous implementation, this would give back a empty string even on text content values such as 0 or False. This patch attempts to resolve that by only giving back an empty string if the text attribute is set to `None`. Resolves #91447.

<!-- gh-issue-number: gh-91447 -->
* Issue: gh-91447
<!-- /gh-issue-number -->

Automerge-Triggered-By: GH:gvanrossum

Problem statement:
findtext returns empty string on integer zero value
ElementTree's "findtext" function returns an empty string value if the element's text field contains an integer with value 0. Below example illustrates the issue:

```python
import xml.etree.ElementTree as ET

# Create root element
test = ET.Element("test")

# Compute sum and difference on two numbers
firstNumber = 5000
secondNumber = 5000

sum = firstNumber + secondNumber
difference = firstNumber - secondNumber

# Create subelements for sum and difference values
el = ET.SubElement(test, "sum")
el.text = sum

el = ET.SubElement(test, "diff")
el.text = difference

# Print data types
print("type(sum): " + str(type(sum)))
print("type(diff): " + str(type(difference)))
print("type(sum subelt): " + str(type(test.findtext('./sum'))))
print("type(diff subelt): " + str(type(test.findtext('./diff'))))

# Print values of sub elements
print("sum subelt: " + str(test.findtext('./sum')))
print("diff subelt: " + str(test.findtext('./diff')))
```
This gives me the following output:

```
type(sum): <class 'int'>
type(diff): <class 'int'>
type(sum subelt): <class 'int'>
type(diff subelt): <class 'str'>
sum subelt: 10000
diff subelt: 
```
Note how the data type of the "diff" sub-element is "string", even though the source data is an integer.

I'm using Python 3.8.10 on Linux Mint 20.1 Ulyssa (based on Ubuntu Focal Fossa 20.04).

Hints from issue discussion:
I anticipate this would happen for any "falsey" text attribute value. The `findtext` function is returning `elem.text or ""` and 0 is leading it to return the empty string. 

For instance:

```
>>> import xml.etree.ElementTree as ET
>>> test = ET.Element("test")
>>> el = ET.SubElement(test, "diff")
>>> el.text = False
>>> print(test.findtext('./diff'))

>>> 
```

You can stringify the value for the text attribute as a workaround.
```
>>> el.text = str(0)
>>> print(test.findtext('./diff'))
0
```

But I'm not familiar with why the current behavior is the way it is. From the [API docs](https://docs.python.org/3/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element.findtext): "Note that if the matching element has no text content an empty string is returned." 

What is considered "no text content"? Any reason not to do something like the following?

```
if elem.text is None:
    return ""

return elem.text
```
@eugenetriguba Good call, hadn't tried the Boolean False myself! I ran into this problem while writing some [unit tests](https://github.com/KBNLresearch/isolyzer/blob/415b0bb585af4321f276199a9addb52206d152f9/tests/unit/test_testfiles.py#L82) that take a large element tree with low-level properties of an ISO image file as input. Since these values are used internally by the software for various calculations, stringifying them isn't really an option, as that would make things unnecessarily complex.

My own interpretation of "no text content" would also be a "None" match. After posting this, I realized I'd come across this very same issue some ten years ago in another project (for reasons I can't remember I never got round to reporting it back then!).  At the time we created a replacement for the *findText* function (link [here](https://github.com/openpreserve/jpylyzer/blob/94c11a5ef7033346dce0e4b06c0d4b5549438995/jpylyzer/etpatch.py#L45)), which is along similar lines as your suggestion.

Commit message: Add news blurb

Patch to review:
diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
index 60a41506d8795d..0275da81e8d12c 100644
--- a/Lib/test/test_xml_etree.py
+++ b/Lib/test/test_xml_etree.py
@@ -2735,6 +2735,22 @@ def test_findtext_with_error(self):
         except ZeroDivisionError:
             pass
 
+    def test_findtext_with_falsey_text_attribute(self):
+        root_elem = ET.Element('foo')
+        sub_elem = ET.SubElement(root_elem, 'bar')
+        falsey = ["", 0, False, [], (), {}]
+
+        for val in falsey:
+            sub_elem.text = val
+            self.assertEqual(root_elem.findtext('./bar'), val)
+
+    def test_findtext_with_none_text_attribute(self):
+        root_elem = ET.Element('foo')
+        sub_elem = ET.SubElement(root_elem, 'bar')
+        sub_elem.text = None
+
+        self.assertEqual(root_elem.findtext('./bar'), '')
+
     def test_findall_with_mutating(self):
         e = ET.Element('foo')
         e.extend([ET.Element('bar')])
diff --git a/Lib/xml/etree/ElementPath.py b/Lib/xml/etree/ElementPath.py
index cd3c354d0813ee..c2ad7509b2f938 100644
--- a/Lib/xml/etree/ElementPath.py
+++ b/Lib/xml/etree/ElementPath.py
@@ -416,6 +416,10 @@ def findall(elem, path, namespaces=None):
 def findtext(elem, path, default=None, namespaces=None):
     try:
         elem = next(iterfind(elem, path, namespaces))
-        return elem.text or ""
+
+        if elem.text is None:
+            return ""
+
+        return elem.text
     except StopIteration:
         return default
diff --git a/Misc/NEWS.d/next/Library/2022-04-12-18-05-40.gh-issue-91447.N_Fs4H.rst b/Misc/NEWS.d/next/Library/2022-04-12-18-05-40.gh-issue-91447.N_Fs4H.rst
new file mode 100644
index 00000000000000..6f9be2d3e9be41
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-04-12-18-05-40.gh-issue-91447.N_Fs4H.rst
@@ -0,0 +1,2 @@
+Fix findtext in the xml module to only give an empty string when the text
+attribute is set to None.


Code review comments:
diff_hunk: @@ -2735,6 +2735,22 @@ def test_findtext_with_error(self):
         except ZeroDivisionError:
             pass
 
+    def test_findtext_with_falsey_text_attribute(self):
+        root_elem = ET.Element('foo')
+        sub_elem = ET.SubElement(root_elem, 'bar')
+        falsey = ["", 0, False, [], (), {}]
+
+        for val in falsey:
+            sub_elem.text = val
+            self.assertEqual(root_elem.findtext('./bar'), val)
+
+    def test_findtext_with_none_text_attribute(self):
+        root_elem = ET.Element('foo')
+        sub_elem = ET.SubElement(root_elem, 'bar')
+        sub_elem.text = None
+
path: Lib/test/test_xml_etree.py
text: @user1:
```suggestion
```

diff_hunk: @@ -2735,6 +2735,22 @@ def test_findtext_with_error(self):
         except ZeroDivisionError:
             pass
 
+    def test_findtext_with_falsey_text_attribute(self):
+        root_elem = ET.Element('foo')
+        sub_elem = ET.SubElement(root_elem, 'bar')
+        falsey = ["", 0, False, [], (), {}]
+
path: Lib/test/test_xml_etree.py
text: @user1:
```suggestion
```

diff_hunk: @@ -416,6 +416,10 @@ def findall(elem, path, namespaces=None):
 def findtext(elem, path, default=None, namespaces=None):
     try:
         elem = next(iterfind(elem, path, namespaces))
-        return elem.text or ""
+
+        if elem.text is None:
+            return ""
+
path: Lib/xml/etree/ElementPath.py
text: @user1:
```suggestion
        if elem.text is None:
            return ""
```