From 7dc04b0ba6c99137b6d402bda6daa41e4d9b605a Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 4 Aug 2021 22:28:01 +0300 Subject: [PATCH] Automate generate options - Generate the click options by the config model - Allow to enable/disable any flag - Removed -cf/-ri we can't have switches with short names - Allow to bypass any value from the config --- .pre-commit-config.yaml | 2 +- docs/examples/dataclasses-features.rst | 4 +- .../codegen/handlers/test_class_designate.py | 10 +- .../handlers/test_class_name_conflict.py | 4 +- tests/integration/test_compound.py | 2 +- tests/test_cli.py | 87 ++----------- tests/utils/test_objects.py | 18 +++ xsdata/cli.py | 121 +++--------------- xsdata/codegen/handlers/class_designate.py | 2 +- .../codegen/handlers/class_name_conflict.py | 2 +- xsdata/models/config.py | 55 ++++---- xsdata/utils/click.py | 89 +++++++++++++ xsdata/utils/objects.py | 17 +++ 13 files changed, 198 insertions(+), 215 deletions(-) create mode 100644 tests/utils/test_objects.py create mode 100644 xsdata/utils/click.py create mode 100644 xsdata/utils/objects.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06a3a3245..7d92cc05a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: tests/fixtures repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.1 + rev: v2.23.3 hooks: - id: pyupgrade args: [--py37-plus] diff --git a/docs/examples/dataclasses-features.rst b/docs/examples/dataclasses-features.rst index eed988775..5b1cbf48d 100644 --- a/docs/examples/dataclasses-features.rst +++ b/docs/examples/dataclasses-features.rst @@ -3,8 +3,8 @@ Dataclasses Features ==================== By default xsdata with generate :mod:`python:dataclasses` with the default features on -but you can use a :ref:`generator config ` to toggle almost all of -them. +but you can use the cli flags or a :ref:`generator config ` to toggle +all of them. .. literalinclude:: /../tests/fixtures/stripe/.xsdata.xml diff --git a/tests/codegen/handlers/test_class_designate.py b/tests/codegen/handlers/test_class_designate.py index 346b98b64..88c49dfd7 100644 --- a/tests/codegen/handlers/test_class_designate.py +++ b/tests/codegen/handlers/test_class_designate.py @@ -64,7 +64,7 @@ def test_group_by_namespace(self): ] self.container.extend(classes) - self.config.output.structure = StructureStyle.NAMESPACES + self.config.output.structure_style = StructureStyle.NAMESPACES self.config.output.package = "bar" self.handler.run() @@ -84,7 +84,7 @@ def test_group_all_together(self): ] self.container.extend(classes) - self.config.output.structure = StructureStyle.SINGLE_PACKAGE + self.config.output.structure_style = StructureStyle.SINGLE_PACKAGE self.config.output.package = "foo.bar.thug" self.handler.run() @@ -113,7 +113,7 @@ def test_group_by_strong_components(self): classes[1].attrs.append(AttrFactory.reference(classes[2].qname)) classes[2].attrs.append(AttrFactory.reference(classes[3].qname)) - self.config.output.structure = StructureStyle.CLUSTERS + self.config.output.structure_style = StructureStyle.CLUSTERS self.config.output.package = "foo.bar" self.container.extend(classes) @@ -144,7 +144,7 @@ def test_group_by_namespace_clusters(self): classes[2].attrs.append(AttrFactory.reference(classes[3].qname)) classes[3].attrs.append(AttrFactory.reference(classes[1].qname, circular=True)) - self.config.output.structure = StructureStyle.NAMESPACE_CLUSTERS + self.config.output.structure_style = StructureStyle.NAMESPACE_CLUSTERS self.config.output.package = "models" self.container.extend(classes) @@ -172,7 +172,7 @@ def test_group_by_namespace_clusters_raises_exception(self): classes[2].attrs.append(AttrFactory.reference(classes[3].qname)) classes[3].attrs.append(AttrFactory.reference(classes[1].qname, circular=True)) - self.config.output.structure = StructureStyle.NAMESPACE_CLUSTERS + self.config.output.structure_style = StructureStyle.NAMESPACE_CLUSTERS self.config.output.package = "models" self.container.extend(classes) diff --git a/tests/codegen/handlers/test_class_name_conflict.py b/tests/codegen/handlers/test_class_name_conflict.py index b58875f29..4dd84df2b 100644 --- a/tests/codegen/handlers/test_class_name_conflict.py +++ b/tests/codegen/handlers/test_class_name_conflict.py @@ -58,7 +58,7 @@ def test_run_with_single_location_source(self, mock_rename_classes): ClassFactory.create(qname="a"), ] - self.container.config.output.structure = StructureStyle.SINGLE_PACKAGE + self.container.config.output.structure_style = StructureStyle.SINGLE_PACKAGE self.container.extend(classes) self.processor.run() @@ -71,7 +71,7 @@ def test_run_with_clusters_structure(self, mock_rename_classes): ClassFactory.create(qname="{bar}a"), ClassFactory.create(qname="a"), ] - self.container.config.output.structure = StructureStyle.CLUSTERS + self.container.config.output.structure_style = StructureStyle.CLUSTERS self.container.extend(classes) self.processor.run() diff --git a/tests/integration/test_compound.py b/tests/integration/test_compound.py index 29eead98a..7fececd59 100644 --- a/tests/integration/test_compound.py +++ b/tests/integration/test_compound.py @@ -16,7 +16,7 @@ def test_xml_documents(): package = "tests.fixtures.compound.models" runner = CliRunner() result = runner.invoke( - cli, [str(schema), "-p", package, "-ss", "single-package", "-cf"] + cli, [str(schema), "-p", package, "-ss", "single-package", "--compound-fields"] ) if result.exception: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0d30b2248..09034665b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,6 @@ from xsdata.codegen.writer import CodeWriter from xsdata.formats.dataclass.generator import DataclassGenerator from xsdata.logger import logger -from xsdata.models.config import DocstringStyle from xsdata.models.config import GeneratorConfig from xsdata.models.config import StructureStyle from xsdata.utils.downloader import Downloader @@ -37,7 +36,7 @@ def tearDownClass(cls): @mock.patch.object(SchemaTransformer, "process") @mock.patch.object(SchemaTransformer, "__init__", return_value=None) - def test_generate_with_default_output(self, mock_init, mock_process): + def test_generate(self, mock_init, mock_process): source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") result = self.runner.invoke(cli, [str(source), "--package", "foo"]) config = mock_init.call_args[1]["config"] @@ -47,63 +46,24 @@ def test_generate_with_default_output(self, mock_init, mock_process): self.assertEqual("foo", config.output.package) self.assertEqual("dataclasses", config.output.format.value) self.assertFalse(config.output.relative_imports) - self.assertEqual(StructureStyle.FILENAMES, config.output.structure) + self.assertEqual(StructureStyle.FILENAMES, config.output.structure_style) self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) - @mock.patch.object(SchemaTransformer, "process") - @mock.patch.object(SchemaTransformer, "__init__", return_value=None) - def test_generate_with_print_mode(self, mock_init, mock_process): - source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") - result = self.runner.invoke(cli, [str(source), "--package", "foo", "--print"]) - - self.assertIsNone(result.exception) - self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) - self.assertEqual(logging.ERROR, logger.getEffectiveLevel()) - self.assertTrue(mock_init.call_args[1]["print"]) - - @mock.patch.object(SchemaTransformer, "process") - @mock.patch.object(SchemaTransformer, "__init__", return_value=None) - def test_generate_with_structure_style_mode(self, mock_init, mock_process): - source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") - result = self.runner.invoke( - cli, - [str(source), "--package", "foo", "--structure-style", "single-package"], - ) - config = mock_init.call_args[1]["config"] - - self.assertIsNone(result.exception) - self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) - self.assertFalse(mock_init.call_args[1]["print"]) - self.assertEqual("foo", config.output.package) - self.assertEqual("dataclasses", config.output.format.value) - self.assertEqual(StructureStyle.SINGLE_PACKAGE, config.output.structure) - - @mock.patch.object(SchemaTransformer, "process") - @mock.patch.object(SchemaTransformer, "__init__", return_value=None) - def test_generate_with_docstring_style(self, mock_init, mock_process): - source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") - result = self.runner.invoke( - cli, [str(source), "--package", "foo", "--docstring-style", "Google"] - ) - config = mock_init.call_args[1]["config"] - - self.assertIsNone(result.exception) - self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) - self.assertEqual(DocstringStyle.GOOGLE, config.output.docstring_style) - @mock.patch.object(SchemaTransformer, "process") @mock.patch.object(SchemaTransformer, "__init__", return_value=None) def test_generate_with_configuration_file(self, mock_init, mock_process): file_path = Path(tempfile.mktemp()) config = GeneratorConfig() config.output.package = "foo.bar" - config.output.structure = StructureStyle.NAMESPACES + config.output.structure_style = StructureStyle.NAMESPACES with file_path.open("w") as fp: config.write(fp, config) source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") result = self.runner.invoke( - cli, [str(source), "--config", str(file_path)], catch_exceptions=False + cli, + [str(source), "--config", str(file_path), "--no-eq"], + catch_exceptions=False, ) config = mock_init.call_args[1]["config"] @@ -111,40 +71,21 @@ def test_generate_with_configuration_file(self, mock_init, mock_process): self.assertFalse(mock_init.call_args[1]["print"]) self.assertEqual("foo.bar", config.output.package) self.assertEqual("dataclasses", config.output.format.value) - self.assertEqual(StructureStyle.NAMESPACES, config.output.structure) + self.assertFalse(config.output.format.eq) + self.assertEqual(StructureStyle.NAMESPACES, config.output.structure_style) self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) file_path.unlink() @mock.patch.object(SchemaTransformer, "process") @mock.patch.object(SchemaTransformer, "__init__", return_value=None) - def test_generate_with_configuration_file_and_overriding_args(self, mock_init, _): - file_path = Path(tempfile.mktemp()) - config = GeneratorConfig() - config.output.package = "foo.bar" - config.output.structure = StructureStyle.FILENAMES - with file_path.open("w") as fp: - config.write(fp, config) - + def test_generate_with_print_mode(self, mock_init, mock_process): source = fixtures_dir.joinpath("defxmlschema/chapter03.xsd") + result = self.runner.invoke(cli, [str(source), "--package", "foo", "--print"]) - result = self.runner.invoke( - cli, - [ - str(source), - f"--config={file_path}", - "--output=testing", - "--package=foo", - "--structure-style=namespaces", - "--relative-imports", - ], - ) - self.assertIsNone(result.exception, msg=result.output) - config = mock_init.call_args[1]["config"] - - self.assertEqual("foo", config.output.package) - self.assertTrue(config.output.relative_imports) - self.assertEqual(StructureStyle.NAMESPACES, config.output.structure) - file_path.unlink() + self.assertIsNone(result.exception) + self.assertEqual([source.as_uri()], mock_process.call_args[0][0]) + self.assertEqual(logging.ERROR, logger.getEffectiveLevel()) + self.assertTrue(mock_init.call_args[1]["print"]) @mock.patch("xsdata.cli.logger.info") def test_init_config(self, mock_info): diff --git a/tests/utils/test_objects.py b/tests/utils/test_objects.py new file mode 100644 index 000000000..dd94c0efe --- /dev/null +++ b/tests/utils/test_objects.py @@ -0,0 +1,18 @@ +from types import SimpleNamespace +from unittest import TestCase + +from xsdata.utils import objects + + +class ObjectsTests(TestCase): + def test_update(self): + + obj = SimpleNamespace() + obj.foo = SimpleNamespace() + obj.foo.bar = 2 + obj.bar = 1 + + kwargs = {"foo.bar": 1, "bar": 2} + objects.update(obj, **kwargs) + self.assertEqual(1, obj.foo.bar) + self.assertEqual(2, obj.bar) diff --git a/xsdata/cli.py b/xsdata/cli.py index 7f2813515..dd930953a 100644 --- a/xsdata/cli.py +++ b/xsdata/cli.py @@ -14,8 +14,9 @@ from xsdata.logger import logger from xsdata.models.config import DocstringStyle from xsdata.models.config import GeneratorConfig -from xsdata.models.config import OutputFormat +from xsdata.models.config import GeneratorOutput from xsdata.models.config import StructureStyle +from xsdata.utils.click import model_options from xsdata.utils.downloader import Downloader from xsdata.utils.hooks import load_entry_points @@ -75,86 +76,9 @@ def download(source: str, output: str): @cli.command("generate") @click.argument("source", required=True) -@click.option( - "-c", - "--config", - default=".xsdata.xml", - help="Specify a configuration file with advanced options.", -) -@click.option( - "-p", - "--package", - required=False, - help=( - "Specify the target package to be created inside the current working directory " - "Default: generated" - ), - default="generated", -) -@click.option( - "-o", - "--output", - type=outputs, - help=( - "Specify the output format from the builtin code generator and any third " - "party installed plugins. Default: dataclasses" - ), - default="dataclasses", -) -@click.option( - "-ds", - "--docstring-style", - type=docstring_styles, - help=( - "Specify the docstring style for the default output format. " - "Default: reStructuredText" - ), - default="reStructuredText", -) -@click.option( - "-ss", - "--structure-style", - type=structure_styles, - help=( - "Specify a structure style to organize classes " - "Default: filenames" - "\n\n" - "filenames: groups classes by the schema location" - "\n\n" - "namespaces: group classes by the target namespace" - "\n\n" - "clusters: group by strong connected dependencies" - "\n\n" - "namespace-clusters: group by strong connected dependencies and namespaces" - "\n\n" - "single-package: group all classes together" - ), - default="filenames", -) -@click.option( - "-cf", - "--compound-fields", - is_flag=True, - default=False, - help=( - "Use compound fields for repeating choices in order to maintain elements " - "ordering between data binding operations." - ), -) -@click.option( - "-ri", - "--relative-imports", - is_flag=True, - default=False, - help="Enable relative imports", -) -@click.option( - "-pp", - "--print", - is_flag=True, - default=False, - help="Print to console instead of writing the generated output to files", -) +@click.option("-c", "--config", default=".xsdata.xml", help="Project configuration") +@click.option("-pp", "--print", is_flag=True, default=False, help="Print output") +@model_options(GeneratorOutput) def generate(**kwargs: Any): """ Generate code from xml schemas, webservice definitions and any xml or json @@ -163,34 +87,19 @@ def generate(**kwargs: Any): The input source can be either a filepath, uri or a directory containing xml, json, xsd and wsdl files. """ - if kwargs["print"]: - logger.setLevel(logging.ERROR) - - config_file = Path(kwargs["config"]) - if config_file.exists(): - config = GeneratorConfig.read(config_file) - if kwargs["package"] != "generated": - config.output.package = kwargs["package"] - else: - config = GeneratorConfig() - config.output.format = OutputFormat(value=kwargs["output"]) - config.output.package = kwargs["package"] - config.output.relative_imports = kwargs["relative_imports"] - config.output.compound_fields = kwargs["compound_fields"] - config.output.docstring_style = DocstringStyle(kwargs["docstring_style"]) - - if kwargs["structure_style"] != StructureStyle.FILENAMES.value: - config.output.structure = StructureStyle(kwargs["structure_style"]) + source = kwargs.pop("source") + stdout = kwargs.pop("print") + config_file = Path(kwargs.pop("config")).resolve() - if kwargs["output"] != "dataclasses": - config.output.format.value = kwargs["output"] + if stdout: + logger.setLevel(logging.ERROR) - if kwargs["relative_imports"]: - config.output.relative_imports = True + params = {k.replace("__", "."): v for k, v in kwargs.items() if v is not None} + config = GeneratorConfig.read(config_file) + config.output.update(**params) - uris = resolve_source(kwargs["source"]) - transformer = SchemaTransformer(config=config, print=kwargs["print"]) - transformer.process(list(uris)) + transformer = SchemaTransformer(config=config, print=stdout) + transformer.process(list(resolve_source(source))) def resolve_source(source: str) -> Iterator[str]: diff --git a/xsdata/codegen/handlers/class_designate.py b/xsdata/codegen/handlers/class_designate.py index 286e65271..c39d8fc28 100644 --- a/xsdata/codegen/handlers/class_designate.py +++ b/xsdata/codegen/handlers/class_designate.py @@ -30,7 +30,7 @@ class ClassDesignateHandler(ContainerHandlerInterface): __slots__ = () def run(self): - structure_style = self.container.config.output.structure + structure_style = self.container.config.output.structure_style if structure_style == StructureStyle.NAMESPACES: self.group_by_namespace() elif structure_style == StructureStyle.SINGLE_PACKAGE: diff --git a/xsdata/codegen/handlers/class_name_conflict.py b/xsdata/codegen/handlers/class_name_conflict.py index 66e8f99d9..99975600d 100644 --- a/xsdata/codegen/handlers/class_name_conflict.py +++ b/xsdata/codegen/handlers/class_name_conflict.py @@ -44,7 +44,7 @@ def should_use_names(self) -> bool: - All classes have the same source location. """ return ( - self.container.config.output.structure in REQUIRE_UNIQUE_NAMES + self.container.config.output.structure_style in REQUIRE_UNIQUE_NAMES or len(set(map(get_location, self.container))) == 1 ) diff --git a/xsdata/models/config.py b/xsdata/models/config.py index 9b76e6e42..85a5b9561 100644 --- a/xsdata/models/config.py +++ b/xsdata/models/config.py @@ -22,6 +22,7 @@ from xsdata.models.mixins import array_element from xsdata.models.mixins import attribute from xsdata.models.mixins import element +from xsdata.utils import objects from xsdata.utils import text @@ -29,11 +30,12 @@ class StructureStyle(Enum): """ Code writer output structure strategies. - :cvar FILENAMES: filenames - :cvar NAMESPACES: namespaces - :cvar CLUSTERS: clusters - :cvar SINGLE_PACKAGE: single-package - :cvar NAMESPACE_CLUSTERS: namespace-clusters + :cvar FILENAMES: filenames: groups classes by the schema location + :cvar NAMESPACES: namespaces: group classes by the target namespace + :cvar CLUSTERS: clusters: group by strong connected dependencies + :cvar SINGLE_PACKAGE: single-package: group all classes together + :cvar NAMESPACE_CLUSTERS: namespace-clusters: group by strong + connected dependencies and namespaces """ FILENAMES = "filenames" @@ -134,14 +136,14 @@ class OutputFormat: """ Output format options. - :param value: Name of the format - :param repr: Generate repr methods - :param eq: Generate equal method - :param order: Generate rich comparison methods - :param unsafe_hash: Generate hash method when frozen is false - :param frozen: Enable read only properties with immutable containers - :param slots: Enable __slots__, python >= 3.10 - :param kw_only: Enable keyword only constructor arguments, python >= 3.10 + :param value: Output format name, default: dataclasses + :param repr: Generate __repr__ method, default: true + :param eq: Generate __eq__ method, default: true + :param order: Generate __lt__, __le__, __gt__, and __ge__ methods, default: false + :param unsafe_hash: Generate __hash__ method if not frozen, default: false + :param frozen: Enable read only properties, default false + :param slots: Enable __slots__, default: false, python>=3.10 Only + :param kw_only: Enable keyword only arguments, default: false, python>=3.10 Only """ value: str = field(default="dataclasses") @@ -178,23 +180,27 @@ class GeneratorOutput: """ Main generator output options. - :param max_line_length: Maximum line length - :param package: Package name eg foo.bar.models - :param format: Code generator output format name - :param structure: Select an output structure - :param docstring_style: Select a docstring style - :param relative_imports: Enable relative imports - :param compound_fields: Use compound fields for repeating choices. - Enable if elements ordering matters for your case. + :param package: Target package, default: generated + :param format: Output format + :param structure_style: Output structure style, default: filenames + :param docstring_style: Docstring style, default: reStructuredText + :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 """ - max_line_length: int = attribute(default=79) package: str = element(default="generated") format: OutputFormat = element(default_factory=OutputFormat) - structure: StructureStyle = element(default=StructureStyle.FILENAMES) + structure_style: StructureStyle = element( + default=StructureStyle.FILENAMES, name="Structure" + ) docstring_style: DocstringStyle = element(default=DocstringStyle.RST) relative_imports: bool = element(default=False) compound_fields: bool = element(default=False) + max_line_length: int = attribute(default=79) + + def update(self, **kwargs: Any): + objects.update(self, **kwargs) @dataclass @@ -320,6 +326,9 @@ def create(cls) -> "GeneratorConfig": @classmethod def read(cls, path: Path) -> "GeneratorConfig": + if not path.exists(): + return cls() + ctx = XmlContext( element_name_generator=text.pascal_case, attribute_name_generator=text.camel_case, diff --git a/xsdata/utils/click.py b/xsdata/utils/click.py new file mode 100644 index 000000000..8ef3c8b6e --- /dev/null +++ b/xsdata/utils/click.py @@ -0,0 +1,89 @@ +import enum +from dataclasses import fields +from dataclasses import is_dataclass +from typing import Any +from typing import Callable +from typing import Dict +from typing import get_type_hints +from typing import Iterator +from typing import Type +from typing import TypeVar + +import click +from click import Command + +from xsdata.codegen.writer import CodeWriter +from xsdata.utils import text + +F = TypeVar("F", bound=Callable[..., Any]) +FC = TypeVar("FC", Callable[..., Any], Command) + + +def model_options(obj: Any) -> Callable[[FC], FC]: + def decorator(f: F) -> F: + for option in reversed(list(build_options(obj, ""))): + option(f) + return f + + return decorator + + +def build_options(obj: Any, parent: str) -> Iterator[Callable[[FC], FC]]: + type_hints = get_type_hints(obj) + doc_hints = get_doc_hints(obj) + + for field in fields(obj): + type_hint = type_hints[field.name] + doc_hint = doc_hints[field.name] + qname = f"{parent}.{field.name}".strip(".") + + if is_dataclass(type_hint): + yield from build_options(type_hint, qname) + else: + is_flag = False + opt_type = type_hint + if field.name == "value": + opt_type = click.Choice(CodeWriter.generators.keys()) + names = ["-o", "--output"] + elif type_hint is bool: + is_flag = True + opt_type = None + name = text.kebab_case(field.name) + names = [f"--{name}/--no-{name}"] + else: + if issubclass(type_hint, enum.Enum): + opt_type = EnumChoice(type_hint) + + parts = text.split_words(field.name) + name = "-".join(parts) + name_short = "".join(part[0] for part in parts) + names = [f"--{name}", f"-{name_short}"] + + names.append("__".join(qname.split("."))) + + yield click.option( + *names, + help=doc_hint, + is_flag=is_flag, + type=opt_type, + default=None, + ) + + +def get_doc_hints(obj: Any) -> Dict[str, str]: + result = {} + for line in obj.__doc__.split(":param "): + if line[0].isalpha(): + param, hint = line.split(":", 1) + result[param] = " ".join(hint.split()) + + return result + + +class EnumChoice(click.Choice): + def __init__(self, enumeration: Type[enum.Enum]): + self.enumeration = enumeration + super().__init__([e.value for e in enumeration]) + + def convert(self, value: Any, *args: Any) -> enum.Enum: + return self.enumeration(value) diff --git a/xsdata/utils/objects.py b/xsdata/utils/objects.py new file mode 100644 index 000000000..e46760878 --- /dev/null +++ b/xsdata/utils/objects.py @@ -0,0 +1,17 @@ +from typing import Any + + +def update(obj: Any, **kwargs: Any): + """Update an object from keyword arguments with dotted keys.""" + + for key, value in kwargs.items(): + attrsetter(obj, key, value) + + +def attrsetter(obj: Any, attr: str, value: Any): + names = attr.split(".") + last = names.pop() + for name in names: + obj = getattr(obj, name) + + setattr(obj, last, value)