diff --git a/docs/api/utils.rst b/docs/api/utils.rst new file mode 100644 index 000000000..0d61f985e --- /dev/null +++ b/docs/api/utils.rst @@ -0,0 +1,19 @@ +========= +Utilities +========= + + +.. currentmodule:: xsdata.utils + +.. autosummary:: + :toctree: reference + :nosignatures: + + text.capitalize + text.pascal_case + text.camel_case + text.mixed_case + text.mixed_pascal_case + text.mixed_snake_case + text.snake_case + text.kebab_case diff --git a/docs/models.rst b/docs/models.rst index 7e499c9b9..97609c467 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -27,6 +27,9 @@ Basic Model Class Meta ========== +Through the Meta class you can control the model's behaviour during data binding +procedures. + .. list-table:: :widths: 20 10 300 :header-rows: 1 @@ -36,13 +39,19 @@ Class Meta - Description * - name - str - - The real name of the element this class represents. + - The real/local name of the element this class represents. * - nillable - bool - Specifies whether an explicit empty value can be assigned, default: False * - namespace - str - The element xml namespace. + * - element_name_generator + - Callable + - Element name generator + * - attribute_name_generator + - Callable + - Attribute name generator Field Typing @@ -55,6 +64,9 @@ Simply follow the Python lib Field Metadata ============== +Through the metadata properties you can control the field's behaviour during data +binding procedures. + .. list-table:: :widths: 20 10 250 :header-rows: 1 @@ -64,7 +76,7 @@ Field Metadata - Description * - name - str - - The real name of the element or attribute this field represents. + - The real/local name of the element or attribute this field represents. * - type - str - The field xml type: @@ -328,3 +340,142 @@ is directly assigned as text to elements. .. code-block:: xml 2020 + + +Advance Topics +============== + +Customize element and attribute names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Through the model and field metadata you can explicitly specify the serialized +names. You can also provide callables to set the real/local names per model or +for the entire binding context. + + +.. doctest:: + + >>> from dataclasses import dataclass, field + >>> from datetime import date + >>> from xsdata.formats.dataclass.context import XmlContext + >>> from xsdata.formats.dataclass.parsers import XmlParser + >>> from xsdata.formats.dataclass.serializers import XmlSerializer + >>> from xsdata.formats.dataclass.serializers.config import SerializerConfig + >>> from xsdata.utils import text + >>> config = SerializerConfig(pretty_print=True, xml_declaration=False) + >>> serializer = XmlSerializer(config=config) + +**Ordered by priority** + +.. tab:: Explicit names + + Explicit model and field names is the most straight forward way to customize + the real/local names for elements and attributes. It can become tedious though + when you have to do this for models with a lot of fields. + + .. doctest:: + + >>> @dataclass + ... class Person: + ... + ... class Meta: + ... name = "Person" # Explicit name + ... + ... first_name: str = field(metadata=dict(name="firstName")) + ... last_name: str = field(metadata=dict(name="lastName")) + ... birth_date: date = field( + ... metadata=dict( + ... type="Attribute", + ... format="%Y-%m-%d", + ... name="dob" # Explicit name + ... ) + ... ) + ... + >>> obj = Person( + ... first_name="Chris", + ... last_name="T", + ... birth_date=date(1986, 9, 25), + ... ) + >>> print(serializer.render(obj)) + + Chris + T + + + + +.. tab:: Model name generators + + Through the Meta class you can provide callables to apply a naming scheme for all + the model fields. The :mod:`xsdata.utils.text` has various helpers that you can + reuse. + + .. doctest:: + + >>> @dataclass + ... class person: + ... + ... class Meta: + ... element_name_generator = text.pascal_case + ... attribute_name_generator = text.camel_case + ... + ... first_name: str + ... last_name: str + ... birth_date: date = field( + ... metadata=dict( + ... type="Attribute", + ... format="%Y-%m-%d" + ... ) + ... ) + ... + >>> obj = person( + ... first_name="Chris", + ... last_name="T", + ... birth_date=date(1986, 9, 25), + ... ) + >>> print(serializer.render(obj)) + + Chris + T + + + + +.. tab:: Context name generators + + Through the :class:`~xsdata.formats.dataclass.context.XmlContext` instance you can + provide callables to apply a naming scheme for all models and their fields. This way + you can avoid declaring them for every model but you have to use the same context + whenever you want to use a parser/serializer. + + .. doctest:: + + >>> @dataclass + ... class Person: + ... + ... first_name: str + ... last_name: str + ... birth_date: date = field( + ... metadata=dict( + ... type="Attribute", + ... format="%Y-%m-%d" + ... ) + ... ) + ... + >>> obj = Person( + ... first_name="Chris", + ... last_name="T", + ... birth_date=date(1986, 9, 25), + ... ) + ... + >>> context = XmlContext( + ... element_name_generator=text.camel_case, + ... attribute_name_generator=text.kebab_case + ... ) + >>> serializer = XmlSerializer(context=context, config=config) + >>> print(serializer.render(obj)) + + Chris + T + + diff --git a/tests/codegen/handlers/test_attribute_type.py b/tests/codegen/handlers/test_attribute_type.py index 9eb9f65ca..c2af49e0c 100644 --- a/tests/codegen/handlers/test_attribute_type.py +++ b/tests/codegen/handlers/test_attribute_type.py @@ -10,12 +10,8 @@ from xsdata.codegen.models import Restrictions from xsdata.codegen.models import Status from xsdata.codegen.utils import ClassUtils -from xsdata.exceptions import AnalyzerValueError from xsdata.models.enums import DataType from xsdata.models.enums import Tag -from xsdata.models.xsd import ComplexType -from xsdata.models.xsd import Element -from xsdata.models.xsd import SimpleType class AttributeTypeHandlerTests(FactoryTestCase): diff --git a/tests/codegen/mappers/test_definitions.py b/tests/codegen/mappers/test_definitions.py index 6baf42e95..7ecff8728 100644 --- a/tests/codegen/mappers/test_definitions.py +++ b/tests/codegen/mappers/test_definitions.py @@ -22,7 +22,6 @@ from xsdata.models.wsdl import PortTypeOperation from xsdata.models.wsdl import Service from xsdata.models.wsdl import ServicePort -from xsdata.models.xsd import Element from xsdata.utils.namespaces import build_qname diff --git a/tests/codegen/models/test_attr.py b/tests/codegen/models/test_attr.py index 3a01fac2c..dc17d45e5 100644 --- a/tests/codegen/models/test_attr.py +++ b/tests/codegen/models/test_attr.py @@ -1,11 +1,8 @@ import sys -from unittest import mock from tests.factories import AttrFactory from tests.factories import FactoryTestCase -from xsdata.codegen.models import Attr from xsdata.codegen.models import Restrictions -from xsdata.formats.dataclass.models.elements import XmlType from xsdata.models.enums import Namespace from xsdata.models.enums import Tag diff --git a/tests/codegen/test_writer.py b/tests/codegen/test_writer.py index 462d0f804..1f845ec69 100644 --- a/tests/codegen/test_writer.py +++ b/tests/codegen/test_writer.py @@ -52,9 +52,9 @@ def test_write(self, mock_designate, mock_render): def test_print(self, mock_print, mock_designate, mock_render): classes = ClassFactory.list(2) mock_render.return_value = [ - GeneratorResult(Path(f"foo/a.py"), "file", "aAa"), - GeneratorResult(Path(f"bar/b.py"), "file", "bBb"), - GeneratorResult(Path(f"c.py"), "file", ""), + GeneratorResult(Path("foo/a.py"), "file", "aAa"), + GeneratorResult(Path("bar/b.py"), "file", "bBb"), + GeneratorResult(Path("c.py"), "file", ""), ] self.writer.print(classes) diff --git a/tests/conftest.py b/tests/conftest.py index 5fff10611..c29f688e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import json from pathlib import Path from typing import Type diff --git a/tests/formats/dataclass/test_context.py b/tests/formats/dataclass/test_context.py index b598d52cb..9de172f01 100644 --- a/tests/formats/dataclass/test_context.py +++ b/tests/formats/dataclass/test_context.py @@ -29,6 +29,7 @@ from xsdata.models.datatype import XmlDate from xsdata.models.enums import DataType from xsdata.utils import text +from xsdata.utils.constants import return_input from xsdata.utils.constants import return_true from xsdata.utils.namespaces import build_qname @@ -130,7 +131,9 @@ def test_build_build_vars(self, mock_get_type_hints): ) self.assertEqual(expected, result) - mock_get_type_hints.assert_called_once_with(ItemsType, None) + mock_get_type_hints.assert_called_once_with( + ItemsType, None, return_input, return_input + ) @mock.patch.object(XmlContext, "get_type_hints", return_value={}) def test_build_with_meta_namespace(self, mock_get_type_hints): @@ -139,18 +142,22 @@ def test_build_with_meta_namespace(self, mock_get_type_hints): self.assertEqual(build_qname(namespace, "product"), result.qname) self.assertEqual(build_qname(namespace, "product"), result.source_qname) - mock_get_type_hints.assert_called_once_with(Product, namespace) + mock_get_type_hints.assert_called_once_with( + Product, namespace, return_input, return_input + ) @mock.patch.object(XmlContext, "get_type_hints", return_value={}) def test_build_with_parent_ns(self, mock_get_type_hints): result = self.ctx.build(ProductType, "http://xsdata") self.assertEqual(build_qname("http://xsdata", "ProductType"), str(result.qname)) - mock_get_type_hints.assert_called_once_with(ProductType, "http://xsdata") + mock_get_type_hints.assert_called_once_with( + ProductType, "http://xsdata", return_input, return_input + ) @mock.patch.object(XmlContext, "get_type_hints", return_value={}) def test_build_with_no_meta_name_and_name_generator(self, *args): - inspect = XmlContext(element_name=lambda x: text.snake_case(x)) + inspect = XmlContext(element_name_generator=lambda x: text.snake_case(x)) result = inspect.build(ItemsType) self.assertEqual("items_type", result.qname) @@ -184,21 +191,21 @@ def test_build_with_no_dataclass_raises_exception(self, *args): self.assertEqual(f"Object {int} is not a dataclass.", str(cm.exception)) def test_get_type_hints(self): - result = self.ctx.get_type_hints(BookForm, None) + result = self.ctx.get_type_hints(BookForm, None, text.pascal_case, str.upper) self.assertIsInstance(result, Iterator) expected = [ - XmlVar(element=True, name="author", qname="author", types=[str]), - XmlVar(element=True, name="title", qname="title", types=[str]), - XmlVar(element=True, name="genre", qname="genre", types=[str]), - XmlVar(element=True, name="price", qname="price", types=[float]), - XmlVar(element=True, name="pub_date", qname="pub_date", types=[XmlDate]), - XmlVar(element=True, name="review", qname="review", types=[str]), - XmlVar(attribute=True, name="id", qname="id", types=[str]), + XmlVar(element=True, name="author", qname="Author", types=[str]), + XmlVar(element=True, name="title", qname="Title", types=[str]), + XmlVar(element=True, name="genre", qname="Genre", types=[str]), + XmlVar(element=True, name="price", qname="Price", types=[float]), + XmlVar(element=True, name="pub_date", qname="PubDate", types=[XmlDate]), + XmlVar(element=True, name="review", qname="Review", types=[str]), + XmlVar(attribute=True, name="id", qname="ID", types=[str]), XmlVar( attribute=True, name="lang", - qname="lang", + qname="LANG", types=[str], init=False, default="en", @@ -218,7 +225,9 @@ class Example: int: int union: Union[int, bool] - result = list(self.ctx.get_type_hints(Example, None)) + result = list( + self.ctx.get_type_hints(Example, None, return_input, return_input) + ) expected = [ XmlVar(element=True, name="bool", qname="bool", types=[bool]), XmlVar(element=True, name="int", qname="int", types=[int]), @@ -231,7 +240,7 @@ class Example: self.assertEqual(expected, result) def test_get_type_hints_with_dataclass_list(self): - result = list(self.ctx.get_type_hints(Books, None)) + result = list(self.ctx.get_type_hints(Books, None, return_input, return_input)) expected = XmlVar( element=True, @@ -250,7 +259,7 @@ def test_get_type_hints_with_dataclass_list(self): self.assertEqual(BookForm, result[0].clazz) def test_get_type_hints_with_wildcard_element(self): - result = list(self.ctx.get_type_hints(Umbrella, None)) + actual = self.ctx.get_type_hints(Umbrella, None, return_input, return_input) expected = XmlVar( wildcard=True, @@ -261,8 +270,7 @@ def test_get_type_hints_with_wildcard_element(self): namespaces=["##any"], ) - self.assertEqual(1, len(result)) - self.assertEqual(expected, result[0]) + self.assertEqual([expected], list(actual)) def test_get_type_hints_with_undefined_types(self): @dataclass @@ -282,7 +290,8 @@ class Currencies: XmlVar(element=True, name="iso_code", qname="CharCode", types=[str]), XmlVar(element=True, name="nominal", qname="Nominal", types=[int]), ] - self.assertEqual(expected, list(self.ctx.get_type_hints(Currency, None))) + actual = self.ctx.get_type_hints(Currency, None, return_input, return_input) + self.assertEqual(expected, list(actual)) expected = [ XmlVar(attribute=True, name="name", qname="name", types=[str]), @@ -303,10 +312,12 @@ class Currencies: types=[Currency], ), ] - self.assertEqual(expected, list(self.ctx.get_type_hints(Currencies, None))) + + actual = self.ctx.get_type_hints(Currencies, None, return_input, return_input) + self.assertEqual(expected, list(actual)) def test_get_type_hints_with_choices(self): - actual = self.ctx.get_type_hints(Node, "bar") + actual = self.ctx.get_type_hints(Node, "bar", return_input, return_input) self.assertIsInstance(actual, Generator) expected = XmlVar( elements=True, diff --git a/tests/models/test_datatype.py b/tests/models/test_datatype.py index 63b185b8b..a64923eeb 100644 --- a/tests/models/test_datatype.py +++ b/tests/models/test_datatype.py @@ -1,4 +1,3 @@ -from datetime import date from datetime import datetime from datetime import time from datetime import timedelta diff --git a/tests/models/test_mixins.py b/tests/models/test_mixins.py index db1a44913..fd3d39dbd 100644 --- a/tests/models/test_mixins.py +++ b/tests/models/test_mixins.py @@ -1,10 +1,8 @@ -from dataclasses import dataclass from typing import Generator from typing import Iterator from unittest import TestCase from xsdata.exceptions import SchemaValueError -from xsdata.models.enums import DataType from xsdata.models.enums import FormType from xsdata.models.enums import Namespace from xsdata.models.mixins import ElementBase diff --git a/tests/models/xsd/test_list.py b/tests/models/xsd/test_list.py index ce16606dd..1f8981063 100644 --- a/tests/models/xsd/test_list.py +++ b/tests/models/xsd/test_list.py @@ -1,4 +1,3 @@ -import sys from unittest import TestCase from xsdata.models.xsd import List diff --git a/tests/models/xsd/test_restriction.py b/tests/models/xsd/test_restriction.py index 62b263b4c..d87d2ec54 100644 --- a/tests/models/xsd/test_restriction.py +++ b/tests/models/xsd/test_restriction.py @@ -1,8 +1,6 @@ from typing import Iterator from unittest import TestCase -from xsdata.models.enums import DataType -from xsdata.models.enums import Namespace from xsdata.models.xsd import Enumeration from xsdata.models.xsd import FractionDigits from xsdata.models.xsd import Length diff --git a/tests/utils/test_collections.py b/tests/utils/test_collections.py index 1df28f421..f7ab69bc9 100644 --- a/tests/utils/test_collections.py +++ b/tests/utils/test_collections.py @@ -1,6 +1,5 @@ from typing import Generator from typing import Hashable -from typing import Iterable from unittest import TestCase from xsdata.utils import collections diff --git a/xsdata/formats/dataclass/context.py b/xsdata/formats/dataclass/context.py index bcf81c6e4..c1dbeb9cd 100644 --- a/xsdata/formats/dataclass/context.py +++ b/xsdata/formats/dataclass/context.py @@ -26,6 +26,7 @@ from xsdata.models.enums import DataType from xsdata.models.enums import NamespaceType from xsdata.utils.constants import EMPTY_SEQUENCE +from xsdata.utils.constants import return_input from xsdata.utils.namespaces import build_qname @@ -34,15 +35,15 @@ class XmlContext: """ The service provider for binding operations metadata. - :param element_name: Default callable to convert field names to element tags - :param attribute_name: Default callable to convert field names to attribute tags + :param element_name_generator: Default element name generator + :param attribute_name_generator: Default attribute name generator :ivar cache: Cache models metadata :ivar xsi_cache: Index models by xsi:type :ivar sys_modules: Number of imported modules """ - element_name: Callable = field(default=lambda x: x) - attribute_name: Callable = field(default=lambda x: x) + element_name_generator: Callable = field(default=return_input) + attribute_name_generator: Callable = field(default=return_input) cache: Dict[Type, XmlMeta] = field(init=False, default_factory=dict) xsi_cache: Dict[str, List[Type]] = field( init=False, default_factory=lambda: defaultdict(list) @@ -83,10 +84,14 @@ def build_xsi_cache(self): for clazz in self.get_subclasses(object): if is_dataclass(clazz): meta = clazz.Meta if "Meta" in clazz.__dict__ else None - name = getattr(meta, "name", None) or self.local_name(clazz.__name__) + local_name = getattr(meta, "name", None) + element_name_generator = getattr( + meta, "element_name_generator", self.element_name_generator + ) + local_name = local_name or element_name_generator(clazz.__name__) module = sys.modules[clazz.__module__] source_namespace = getattr(module, "__NAMESPACE__", None) - source_qname = build_qname(source_namespace, name) + source_qname = build_qname(source_namespace, local_name) self.xsi_cache[source_qname].append(clazz) self.sys_modules = len(sys.modules) @@ -169,7 +174,14 @@ def build(self, clazz: Type, parent_ns: Optional[str] = None) -> XmlMeta: # Fetch the dataclass meta settings and make sure we don't inherit # the parent class meta. meta = clazz.Meta if "Meta" in clazz.__dict__ else None - name = getattr(meta, "name", None) or self.local_name(clazz.__name__) + element_name_generator = getattr( + meta, "element_name_generator", self.element_name_generator + ) + attribute_name_generator = getattr( + meta, "attribute_name_generator", self.attribute_name_generator + ) + local_name = getattr(meta, "name", None) + local_name = local_name or element_name_generator(clazz.__name__) nillable = getattr(meta, "nillable", False) namespace = getattr(meta, "namespace", parent_ns) module = sys.modules[clazz.__module__] @@ -177,14 +189,27 @@ def build(self, clazz: Type, parent_ns: Optional[str] = None) -> XmlMeta: self.cache[clazz] = XmlMeta( clazz=clazz, - qname=build_qname(namespace, name), - source_qname=build_qname(source_namespace, name), + qname=build_qname(namespace, local_name), + source_qname=build_qname(source_namespace, local_name), nillable=nillable, - vars=list(self.get_type_hints(clazz, namespace)), + vars=list( + self.get_type_hints( + clazz, + namespace, + element_name_generator, + attribute_name_generator, + ) + ), ) return self.cache[clazz] - def get_type_hints(self, clazz: Type, parent_ns: Optional[str]) -> Iterator[XmlVar]: + def get_type_hints( + self, + clazz: Type, + parent_ns: Optional[str], + element_name_generator: Callable, + attribute_name_generator: Callable, + ) -> Iterator[XmlVar]: """ Build the model fields binding metadata. @@ -211,7 +236,12 @@ def get_type_hints(self, clazz: Type, parent_ns: Optional[str]) -> Iterator[XmlV element_list = self.is_element_list(type_hint, tokens) is_class = any(is_dataclass(clazz) for clazz in types) xml_type = xml_type or (XmlType.ELEMENT if is_class else default_xml_type) - local_name = local_name or self.local_name(var.name, xml_type) + + if not local_name: + if xml_type == XmlType.ATTRIBUTE: + local_name = attribute_name_generator(var.name) + else: + local_name = element_name_generator(var.name) namespaces = self.resolve_namespaces(xml_type, namespace, parent_ns) default_namespace = self.default_namespace(namespaces) @@ -429,9 +459,3 @@ def get_subclasses(cls, clazz: Type): yield subclass except TypeError: pass - - def local_name(self, name: str, xml_type: Optional[str] = None) -> str: - if xml_type == "Attribute": - return self.attribute_name(name) - - return self.element_name(name) diff --git a/xsdata/models/config.py b/xsdata/models/config.py index 799644737..350b27646 100644 --- a/xsdata/models/config.py +++ b/xsdata/models/config.py @@ -259,14 +259,20 @@ def create(cls) -> "GeneratorConfig": @classmethod def read(cls, path: Path) -> "GeneratorConfig": - ctx = XmlContext(element_name=text.pascal_case, attribute_name=text.camel_case) + ctx = XmlContext( + element_name_generator=text.pascal_case, + attribute_name_generator=text.camel_case, + ) config = ParserConfig(fail_on_unknown_properties=False) parser = XmlParser(context=ctx, config=config) return parser.from_path(path, cls) @classmethod def write(cls, output: TextIO, obj: "GeneratorConfig"): - ctx = XmlContext(element_name=text.pascal_case, attribute_name=text.camel_case) + ctx = XmlContext( + element_name_generator=text.pascal_case, + attribute_name_generator=text.camel_case, + ) config = SerializerConfig(pretty_print=True) serializer = XmlSerializer(context=ctx, config=config, writer=XmlEventWriter) serializer.write(output, obj, ns_map={None: "http://pypi.org/project/xsdata"}) diff --git a/xsdata/utils/constants.py b/xsdata/utils/constants.py index 9313d084e..758484b25 100644 --- a/xsdata/utils/constants.py +++ b/xsdata/utils/constants.py @@ -11,6 +11,11 @@ def return_true(*_: Any) -> bool: return True +def return_input(obj: Any) -> Any: + """A dummy function that always returns input.""" + return obj + + class DateFormat: DATE = "%Y-%m-%d%z" TIME = "%H:%M:%S%z" diff --git a/xsdata/utils/text.py b/xsdata/utils/text.py index 07e680c9f..768c4619d 100644 --- a/xsdata/utils/text.py +++ b/xsdata/utils/text.py @@ -62,6 +62,11 @@ def snake_case(string: str, **kwargs: Any) -> str: return "_".join(map(str.lower, split_words(string))) +def kebab_case(string: str, **kwargs: Any) -> str: + """Convert the given string to kebab case.""" + return "-".join(split_words(string)) + + def split_words(string: str) -> List[str]: """Split a string on new capital letters and not alphanumeric characters."""