Skip to content

Commit

Permalink
Add PostponedAnnotations configuration option (#653)
Browse files Browse the repository at this point in the history
* Add ImportAnnotations to generator configuration

This allows schemas having collisions between class members name and
type to generate correct Python bindings, by enabling Postponed
Evaluation of Annotations (see PEP 563).
This is done by adding __future__.annotations as a default import to
all generated modules when this option is enabled.

* Add unit tests for ImportAnnotations option

* Add integration test for ImportAnnotations option

This test generates bindings having a collision between a class member
name and its type, but uses the ImportAnnotations configuration option
to postpone the evaluation of the type hint.
The test then verifies that the binding can be loaded without errors,
and that its colliding member can be accessed.

* Add FAQ documentation for ImportAnnotations

This helps users diagnose and fix name/type collisions by using the
ImportAnnotations configuration option.

* Add docs about minimum Python version for PEP 563

This informs users that enabling Postponed Evaluation of Annotations is
only available for Python 3.7+.

* Rename ImportAnnotations to PostponedAnnotations

This commit renames references to the newly introduced feature to
describe _what_ this feature does rather than _how_ it does it.

* Add minimum Python version in CLI help

* Skip test on unsupported Python versions

* Add validation to generator output configuration

This allows users to be informed that PostponedAnnotations are only
available in Python 3.7+ by displaying a warning.

* Trick pyupgrade into not deleting test code

Extracting the Python version into a local variable prevents pyupgrade
from refactoring version checks and changing the test semantics.
  • Loading branch information
teobouvard authored Feb 6, 2022
1 parent 7ca1d39 commit eb4b0f1
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 19 deletions.
23 changes: 5 additions & 18 deletions docs/faq/why-i-get-a-typeerror-requires-a-single-type.rst
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
Why I get a TypeError: requires a single type
=============================================
Why do I get a TypeError: requires a single type
================================================

The full error message looks something like this:

.. code-block::
TypeError: typing.Optional requires a single type. Got Field(name=None,type=None,default=<dataclasses._MISSING_TYPE object at 0x7f79f4b0d700>,default_facto.
The error means the dataclass wrapper can't build the typing annotations for a model
because the field type is ambiguous. If you are using the code generator make sure you
are not using the same convention for both field and class names.
This error means the typing annotations for a model are ambiguous because they collide with a class field. If you use Python 3.7 or later, you can set :code:`PostponedAnnotations` to :code:`true` in the :ref:`GeneratorOutput` section of the :ref:`generator config <Generator Config>` to solve this issue. This will enable Postponed Evaluations of Annotations (`PEP 563 <https://www.python.org/dev/peps/pep-0563/>`_) and the generated bindings will be able to be imported without errors. This feature is not available for Python 3.6 because :code:`annotations` were added to the :code:`__future__` module in Python 3.7.0b1.

**Example**

.. code-block:: python
@dataclass
class unit:
pass
@dataclass
class element:
unit: Optional[unit] = field()
Read :ref:`more <Generator Config>`
.. literalinclude:: /../tests/fixtures/annotations/model.py
:language: python
11 changes: 11 additions & 0 deletions tests/fixtures/annotations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from tests.fixtures.annotations.model import (
Measurement,
Weight,
)
from tests.fixtures.annotations.units import unit

__all__ = [
"Measurement",
"Weight",
"unit",
]
28 changes: 28 additions & 0 deletions tests/fixtures/annotations/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from tests.fixtures.annotations.units import unit

__NAMESPACE__ = "http://domain.org/schema/model"


@dataclass
class Measurement:
value: Optional[float] = field(
default=None,
metadata={
"required": True,
}
)
unit: Optional[unit] = field(
default=None,
metadata={
"type": "Attribute",
}
)


@dataclass
class Weight(Measurement):
class Meta:
namespace = "http://domain.org/schema/model"
18 changes: 18 additions & 0 deletions tests/fixtures/annotations/model.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xs:schema targetNamespace="http://domain.org/schema/model"
xmlns="http://domain.org/schema/model"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:utd="http://domain.org/schema/model/units">

<xs:import schemaLocation="./units.xsd"/>

<xs:complexType name="Measurement">
<xs:simpleContent>
<xs:extension base="xs:double">
<xs:attribute name="unit" type="utd:unit"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>

<xs:element name="Weight" type="Measurement"/>
</xs:schema>
2 changes: 2 additions & 0 deletions tests/fixtures/annotations/sample.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<Weight unit="kg">2.0</Weight>
11 changes: 11 additions & 0 deletions tests/fixtures/annotations/units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations
from enum import Enum

__NAMESPACE__ = "http://domain.org/schema/model/units"


class unit(Enum):
M = "m"
KG = "kg"
VALUE = "%"
NA = "NA"
23 changes: 23 additions & 0 deletions tests/fixtures/annotations/units.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xs:schema targetNamespace="http://domain.org/schema/model/units"
xmlns="http://domain.org/schema/model/units"
xmlns:xs="http://www.w3.org/2001/XMLSchema">

<xs:simpleType name="stdUnit">
<xs:restriction base="xs:string">
<xs:enumeration value="m"/>
<xs:enumeration value="kg"/>
</xs:restriction>
</xs:simpleType>

<xs:simpleType name="miscUnit">
<xs:restriction base="xs:string">
<xs:enumeration value="%"/>
<xs:enumeration value="NA"/>
</xs:restriction>
</xs:simpleType>

<xs:simpleType name="unit">
<xs:union memberTypes="stdUnit miscUnit"/>
</xs:simpleType>
</xs:schema>
20 changes: 20 additions & 0 deletions tests/fixtures/annotations/xsdata.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<Config xmlns="http://pypi.org/project/xsdata" version="22.1">
<Output maxLineLength="79">
<Package>tests.fixtures.annotations</Package>
<Format repr="true" eq="true" order="false" unsafeHash="false" frozen="false" slots="false" kwOnly="false">dataclasses</Format>
<Structure>filenames</Structure>
<DocstringStyle>reStructuredText</DocstringStyle>
<RelativeImports>false</RelativeImports>
<CompoundFields defaultName="choice" forceDefaultName="false">true</CompoundFields>
<PostponedAnnotations>true</PostponedAnnotations>
</Output>
<Conventions>
<ClassName case="originalCase" safePrefix="type"/>
<FieldName case="originalCase" safePrefix="value"/>
<ConstantName case="screamingSnakeCase" safePrefix="value"/>
<ModuleName case="snakeCase" safePrefix="mod"/>
<PackageName case="originalCase" safePrefix="pkg"/>
</Conventions>
<Substitutions/>
</Config>
8 changes: 8 additions & 0 deletions tests/formats/dataclass/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,14 @@ def test_default_imports_with_module(self):
expected = "import attrs"
self.assertEqual(expected, self.filters.default_imports(output))

def test_default_imports_with_annotations(self):
config = GeneratorConfig()
config.output.postponed_annotations = True
filters = Filters(config)

expected = "from __future__ import annotations"
self.assertEqual(expected, filters.default_imports(""))

def test_format_metadata(self):
data = dict(
num=1,
Expand Down
39 changes: 39 additions & 0 deletions tests/integration/test_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import os
import sys

import pytest
from click.testing import CliRunner

from tests import fixtures_dir
from tests import root
from xsdata.cli import cli
from xsdata.formats.dataclass.context import XmlContext
from xsdata.formats.dataclass.parsers.xml import XmlParser
from xsdata.utils.testing import load_class

os.chdir(root)


@pytest.mark.skipif(sys.version_info < (3, 7), reason="PEP563 introduced in 3.7")
def test_annotations():
filepath = fixtures_dir.joinpath("annotations")
schema = filepath.joinpath("model.xsd")
runner = CliRunner()
result = runner.invoke(
cli, [str(schema), f"--config={str(filepath.joinpath('xsdata.xml'))}"]
)

if result.exception:
raise result.exception

try:
Measurement = load_class(result.output, "Measurement")
unit = load_class(result.output, "unit")
except Exception:
pytest.fail("Could not load class with member having the same name as type")

filename = str(filepath.joinpath("sample.xml"))
parser = XmlParser(context=XmlContext())
measurement = parser.parse(filename, Measurement)
assert measurement.value == 2.0
assert measurement.unit == unit.KG
24 changes: 24 additions & 0 deletions tests/models/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from xsdata.models.config import GeneratorAlias
from xsdata.models.config import GeneratorAliases
from xsdata.models.config import GeneratorConfig
from xsdata.models.config import GeneratorOutput
from xsdata.models.config import ObjectType
from xsdata.models.config import OutputFormat

Expand All @@ -34,6 +35,7 @@ def test_create(self):
" <DocstringStyle>reStructuredText</DocstringStyle>\n"
" <RelativeImports>false</RelativeImports>\n"
' <CompoundFields defaultName="choice" forceDefaultName="false">false</CompoundFields>\n'
" <PostponedAnnotations>false</PostponedAnnotations>\n"
" </Output>\n"
" <Conventions>\n"
' <ClassName case="pascalCase" safePrefix="type"/>\n'
Expand Down Expand Up @@ -89,6 +91,7 @@ def test_read(self):
" <DocstringStyle>reStructuredText</DocstringStyle>\n"
" <RelativeImports>false</RelativeImports>\n"
' <CompoundFields defaultName="choice" forceDefaultName="false">false</CompoundFields>\n'
" <PostponedAnnotations>false</PostponedAnnotations>\n"
" </Output>\n"
" <Conventions>\n"
' <ClassName case="pascalCase" safePrefix="type"/>\n'
Expand Down Expand Up @@ -152,6 +155,27 @@ def test_format_kw_only_requires_310(self):
else:
self.assertIsNotNone(OutputFormat(kw_only=True))

def test_postponed_annotations_requires_37(self):
# We need to confuse pyupgrade into not discarding Python version checks,
# which are needed to know when the warning is triggered or not
# Extracting the version in a local variable before performing the check is
# sufficient for that purpose.
v = sys.version_info
if v < (3, 7):
with warnings.catch_warnings(record=True) as w:
self.assertFalse(
GeneratorOutput(postponed_annotations=True).postponed_annotations
)
self.assertEqual(
"postponed annotations requires python >= 3.7, reverting...",
str(w[-1].message),
)

if v >= (3, 7):
self.assertTrue(
GeneratorOutput(postponed_annotations=True).postponed_annotations
)

def test_init_config_with_aliases(self):
config = GeneratorConfig(
aliases=GeneratorAliases(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,4 @@ def test_resolve_source(self):
self.assertEqual(3, len(list(resolve_source(str(def_xml_path), False))))

actual = list(resolve_source(str(fixtures_dir), True))
self.assertEqual(32, len(actual))
self.assertEqual(36, len(actual))
6 changes: 6 additions & 0 deletions xsdata/formats/dataclass/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Filters:
"docstring_style",
"max_line_length",
"relative_imports",
"postponed_annotations",
"format",
"import_patterns",
)
Expand All @@ -72,6 +73,7 @@ def __init__(self, config: GeneratorConfig):
self.docstring_style: DocstringStyle = config.output.docstring_style
self.max_line_length: int = config.output.max_line_length
self.relative_imports: bool = config.output.relative_imports
self.postponed_annotations: bool = config.output.postponed_annotations
self.format = config.output.format

# Build things
Expand Down Expand Up @@ -676,6 +678,10 @@ def literal_value(cls, value: Any) -> str:
def default_imports(self, output: str) -> str:
"""Generate the default imports for the given package output."""
result = []

if self.postponed_annotations:
result.append("from __future__ import annotations")

for library, types in self.import_patterns.items():
names = [
name
Expand Down
14 changes: 14 additions & 0 deletions xsdata/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ class GeneratorOutput:
:param relative_imports: Use relative imports, default: false
:param compound_fields: Use compound fields for repeatable elements, default: false
:param max_line_length: Adjust the maximum line length, default: 79
:param postponed_annotations: Enable postponed evaluation of annotations,
default: false, python>=3.7 Only
"""

package: str = element(default="generated")
Expand All @@ -237,6 +239,18 @@ class GeneratorOutput:
relative_imports: bool = element(default=False)
compound_fields: CompoundFields = element(default_factory=CompoundFields)
max_line_length: int = attribute(default=79)
postponed_annotations: bool = element(default=False)

def __post_init__(self):
self.validate()

def validate(self):
if self.postponed_annotations and sys.version_info < (3, 7):
self.postponed_annotations = False
warnings.warn(
"postponed annotations requires python >= 3.7, reverting...",
CodeGenerationWarning,
)

def update(self, **kwargs: Any):
objects.update(self, **kwargs)
Expand Down

0 comments on commit eb4b0f1

Please sign in to comment.