Repository: tefra/xsdata

Issue:
Attribute values are parsed as qnames
If an attribute value looks like a qname, but isn't, serializing breaks.

Example:

```py
from dataclasses import dataclass, field

from xsdata.formats.dataclass.serializers import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig


@dataclass
class Element:
    attr: str = field(metadata=dict(type="Attribute", name="attr"))


def test(value):
    element = Element(attr=value)
    config = SerializerConfig(pretty_print=True)
    serializer = XmlSerializer(config=config)
    print(serializer.render(element))


if __name__ == '__main__':
    test('{{}}')

```

When running the above script, lxml throws a `ValueError` exception:

```
Traceback (most recent call last):
  File "scratch.py", line 20, in <module>
    test('{{}}')
  File "scratch.py", line 16, in test
    print(serializer.render(element))
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\xml.py", line 55, in render
    self.write(output, obj, ns_map)
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\xml.py", line 70, in write
    handler.write(events)
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\writers\lxml.py", line 18, in write
    super().write(events)
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\mixins.py", line 82, in write
    self.end_tag(*args)
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\mixins.py", line 170, in end_tag
    self.flush_start(True)
  File "env38.win\lib\site-packages\xsdata\formats\dataclass\serializers\mixins.py", line 206, in flush_start
    self.handler.startElementNS(self.pending_tag, None, self.attrs)
  File "src\lxml\sax.py", line 111, in lxml.sax.ElementTreeContentHandler.startElementNS
  File "src\lxml\etree.pyx", line 3022, in lxml.etree.Element
  File "src\lxml\apihelpers.pxi", line 131, in lxml.etree._makeElement
  File "src\lxml\apihelpers.pxi", line 118, in lxml.etree._makeElement
  File "src\lxml\apihelpers.pxi", line 215, in lxml.etree._setNodeNamespaces
  File "src\lxml\apihelpers.pxi", line 1755, in lxml.etree._uriValidOrRaise
ValueError: Invalid namespace URI '{'
```

Tested with versions 20.11.1 and 20.12

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=347&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=347&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=347&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=347&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=347) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=347&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=347&metric=new_duplicated_lines_density&view=list)


codecov[bot]: # [Codecov](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=h1) Report
> Merging [#347](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=desc) (88de6e8) into [master](https://codecov.io/gh/tefra/xsdata/commit/8f0cacd3640b76d282f859261d5832e27d77fbc9?el=desc) (8f0cacd) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tefra/xsdata/pull/347/graphs/tree.svg?width=650&height=150&src=pr&token=YzDDLtywvl)](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #347   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           71        71           
  Lines         5725      5730    +5     
  Branches       997       998    +1     
=========================================
+ Hits          5725      5730    +5     
```


| [Impacted Files](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [xsdata/formats/dataclass/serializers/mixins.py](https://codecov.io/gh/tefra/xsdata/pull/347/diff?src=pr&el=tree#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL3NlcmlhbGl6ZXJzL21peGlucy5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/formats/dataclass/serializers/xml.py](https://codecov.io/gh/tefra/xsdata/pull/347/diff?src=pr&el=tree#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL3NlcmlhbGl6ZXJzL3htbC5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/codegen/sanitizer.py](https://codecov.io/gh/tefra/xsdata/pull/347/diff?src=pr&el=tree#diff-eHNkYXRhL2NvZGVnZW4vc2FuaXRpemVyLnB5) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=footer). Last update [8f0cacd...88de6e8](https://codecov.io/gh/tefra/xsdata/pull/347?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/xsdata/formats/dataclass/serializers/mixins.py b/xsdata/formats/dataclass/serializers/mixins.py
index d6c862e3..76b8a597 100644
--- a/xsdata/formats/dataclass/serializers/mixins.py
+++ b/xsdata/formats/dataclass/serializers/mixins.py
@@ -14,6 +14,7 @@ from xml.sax.handler import ContentHandler
 from xsdata.exceptions import XmlWriterError
 from xsdata.formats.converter import converter
 from xsdata.formats.dataclass.serializers.config import SerializerConfig
+from xsdata.models.enums import DataType
 from xsdata.models.enums import Namespace
 from xsdata.models.enums import QNames
 from xsdata.utils.constants import EMPTY_MAP
@@ -120,7 +121,7 @@ class XmlWriter:
         if not self.pending_tag and check_pending:
             raise XmlWriterError("Empty pending tag.")
 
-        if isinstance(value, str) and value and value[0] == "{" and len(value) > 1:
+        if self.is_xsi_type(key, value):
             value = QName(value)
 
         name = split_qname(key)
@@ -235,3 +236,12 @@ class XmlWriter:
         not qualified."""
         if not self.pending_tag[0] and None in self.ns_map:
             self.ns_map[None] = ""
+
+    @classmethod
+    def is_xsi_type(cls, key: str, value: Any) -> bool:
+        """Return whether the value is an xsi:type or not."""
+
+        if isinstance(value, str) and value.startswith("{"):
+            return key == QNames.XSI_TYPE or DataType.from_qname(value) is not None
+
+        return False
diff --git a/xsdata/formats/dataclass/serializers/xml.py b/xsdata/formats/dataclass/serializers/xml.py
index 872130e6..147ce0ab 100644
--- a/xsdata/formats/dataclass/serializers/xml.py
+++ b/xsdata/formats/dataclass/serializers/xml.py
@@ -272,7 +272,7 @@ class XmlSerializer(AbstractSerializer):
         if value is not None and var.is_any_type:
             datatype = DataType.from_value(value)
             if datatype != DataType.STRING:
-                yield XmlWriterEvent.ATTR, QNames.XSI_TYPE, str(datatype)
+                yield XmlWriterEvent.ATTR, QNames.XSI_TYPE, QName(str(datatype))
 
         yield XmlWriterEvent.DATA, value
         yield XmlWriterEvent.END, var.qname


Test patch:
diff --git a/tests/formats/dataclass/serializers/test_mixins.py b/tests/formats/dataclass/serializers/test_mixins.py
index 70c08e23..66ad0040 100644
--- a/tests/formats/dataclass/serializers/test_mixins.py
+++ b/tests/formats/dataclass/serializers/test_mixins.py
@@ -172,8 +172,16 @@ class XmlWriterTests(TestCase):
             (None, "a"): "bar",
             (None, "b"): "true",
             (None, "c"): "{",
-            (None, "d"): "ns0:b",
+            (None, "d"): "{a}b",
             ("http://www.w3.org/2001/XMLSchema-instance", "type"): "xs:string",
         }
 
         self.assertEqual(expected, self.writer.attrs)
+
+    def test_is_xsi_type(self):
+        self.assertFalse(self.writer.is_xsi_type("key", 1))
+        self.assertFalse(self.writer.is_xsi_type(QNames.XSI_TYPE, 1))
+        self.assertFalse(self.writer.is_xsi_type(QNames.XSI_TYPE, "a"))
+        self.assertTrue(self.writer.is_xsi_type(QNames.XSI_TYPE, "{b}a"))
+        self.assertFalse(self.writer.is_xsi_type("type", "{b}a"))
+        self.assertTrue(self.writer.is_xsi_type("type", str(DataType.STRING)))
diff --git a/tests/formats/dataclass/serializers/test_xml.py b/tests/formats/dataclass/serializers/test_xml.py
index ab5da7a2..c0f2269e 100644
--- a/tests/formats/dataclass/serializers/test_xml.py
+++ b/tests/formats/dataclass/serializers/test_xml.py
@@ -282,7 +282,7 @@ class XmlSerializerTests(TestCase):
         var = XmlElement(qname="a", name="a", types=[object])
         expected = [
             (XmlWriterEvent.START, "a"),
-            (XmlWriterEvent.ATTR, QNames.XSI_TYPE, str(DataType.INT)),
+            (XmlWriterEvent.ATTR, QNames.XSI_TYPE, QName(str(DataType.INT))),
             (XmlWriterEvent.DATA, 123),
             (XmlWriterEvent.END, "a"),
         ]