Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ImportAnnotations configuration option #653

Merged
merged 10 commits into from
Feb 6, 2022
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():
teobouvard marked this conversation as resolved.
Show resolved Hide resolved
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'))}"]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All GeneratorOutput options appear in the cli automatically, no need to add a new config file.

❯ xsdata generate --help
...
...
...

Options:
  -r, --recursive                 Search files recursively in the source
                                  directory
  -c, --config TEXT               Project configuration
  -pp, --print                    Print output
  -p, --package TEXT              Target package, default: generated
  -o, --output [dataclasses]      Output format name, default: dataclasses
  --repr / --no-repr              Generate __repr__ method, default: true
  --eq / --no-eq                  Generate __eq__ method, default: true
...
...
...
...
  --import-annotations / --no-import-annotations
                                  Import annotations from :obj:`__future__` in
                                  generated modules, default: false
  --help                          Show this message and exit.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And fix the help text as well, it comes fromt the docstring

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to document it in such a way that the HTML docs have the link for the __future__ module, but the CLI help is only text ?

Copy link
Contributor Author

@teobouvard teobouvard Feb 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there is no need to have implementations details in the docstring. I think the following should be sufficient.

:param postponed_annotations: Enable postponed evaluation of annotations, default: false

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All GeneratorOutput options appear in the cli automatically, no need to add a new config file.

That's awesome ! However, I think I still need the configuration file to set the namecase, which should be originalCase for the purpose of the test.

)

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