Repository: tefra/xsdata
Pull request #909 (2024-01-03 14:36:35)

Enhance compatibility layer
## 📒 Description

- Allow to override templates
- Add class helper to check if class has forward references


Resolves #xxxx

## 🔗 What I've Done

> Write a description of the steps taken to resolve the issue


## 💬 Comments

> A place to write any comments to the reviewer.
>

## 🛫 Checklist

- [ ] Updated docs
- [ ] Added unit-tests
- [ ] [Sample tests](https://github.com/tefra/xsdata-samples) pass
- [ ] [W3C tests](https://github.com/tefra/xsdata-w3c-tests) pass



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7fed841e..a96dfdb8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,7 +8,7 @@ repos:
       - id: end-of-file-fixer
       - id: debug-statements
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.1.8
+    rev: v0.1.11
     hooks:
       - id: ruff
         args: [ --fix ]
@@ -20,7 +20,7 @@ repos:
       - id: docformatter
         args: [ "--in-place", "--pre-summary-newline" ]
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.7.1
+    rev: v1.8.0
     hooks:
       - id: mypy
         files: ^(xsdata/)
diff --git a/xsdata/codegen/models.py b/xsdata/codegen/models.py
index 5b39b25a..0a702674 100644
--- a/xsdata/codegen/models.py
+++ b/xsdata/codegen/models.py
@@ -240,6 +240,10 @@ class Attr:
         """Return whether this attribute is a list of items or a mapping."""
         return self.is_list or self.is_dict or self.is_tokens
 
+    @property
+    def is_forward_ref(self) -> bool:
+        return any(tp.circular or tp.forward for tp in self.types)
+
     @property
     def is_group(self) -> bool:
         """Return whether this attribute is derived from a xs:group or
@@ -550,6 +554,17 @@ class Class:
         for inner in self.inner:
             yield from inner.dependencies(allow_circular)
 
+    def has_forward_ref(self) -> bool:
+        """Return whether this class has any forward references."""
+        for attr in self.attrs:
+            if attr.is_forward_ref:
+                return True
+
+            if any(choice for choice in attr.choices if choice.is_forward_ref):
+                return True
+
+        return any(inner.has_forward_ref() for inner in self.inner)
+
 
 @dataclass
 class Import:
diff --git a/xsdata/formats/dataclass/generator.py b/xsdata/formats/dataclass/generator.py
index c8d6ca31..c566202d 100644
--- a/xsdata/formats/dataclass/generator.py
+++ b/xsdata/formats/dataclass/generator.py
@@ -15,17 +15,27 @@ class DataclassGenerator(AbstractGenerator):
 
     __slots__ = ("env", "filters")
 
+    package_template = "package.jinja2"
+    module_template = "module.jinja2"
+    enum_template = "enum.jinja2"
+    service_template = "service.jinja2"
+    class_template = "class.jinja2"
+
     def __init__(self, config: GeneratorConfig):
         """Override generator constructor to set templates directory and
         environment filters."""
 
         super().__init__(config)
-
-        tpl_dir = Path(__file__).parent.joinpath("templates")
-        self.env = Environment(loader=FileSystemLoader(str(tpl_dir)), autoescape=False)
+        template_paths = self.get_template_paths()
+        loader = FileSystemLoader(template_paths)
+        self.env = Environment(loader=loader, autoescape=False)
         self.filters = self.init_filters(config)
         self.filters.register(self.env)
 
+    @classmethod
+    def get_template_paths(cls) -> List[str]:
+        return [str(Path(__file__).parent.joinpath("templates"))]
+
     def render(self, classes: List[Class]) -> Iterator[GeneratorResult]:
         """
         Return an iterator of the generated results.
@@ -63,7 +73,7 @@ class DataclassGenerator(AbstractGenerator):
         ]
         DependenciesResolver.resolve_conflicts(imports, set())
 
-        output = self.env.get_template("package.jinja2").render(
+        output = self.env.get_template(self.package_template).render(
             imports=imports,
             module=module,
         )
@@ -82,11 +92,13 @@ class DataclassGenerator(AbstractGenerator):
 
         resolver.process(classes)
         imports = resolver.sorted_imports()
-        output = self.render_classes(resolver.sorted_classes(), module_namespace)
+        classes = resolver.sorted_classes()
+        output = self.render_classes(classes, module_namespace)
         module = classes[0].target_module
 
-        return self.env.get_template("module.jinja2").render(
+        return self.env.get_template(self.module_template).render(
             output=output,
+            classes=classes,
             module=module,
             imports=imports,
             namespace=module_namespace,
@@ -96,21 +108,24 @@ class DataclassGenerator(AbstractGenerator):
         self, classes: List[Class], module_namespace: Optional[str]
     ) -> str:
         """Render the source code of the classes."""
-        load = self.env.get_template
 
         def render_class(obj: Class) -> str:
             """Render class or enumeration."""
             if obj.is_enumeration:
-                template = load("enum.jinja2")
+                template = self.enum_template
             elif obj.is_service:
-                template = load("service.jinja2")
+                template = self.service_template
             else:
-                template = load("class.jinja2")
+                template = self.class_template
 
-            return template.render(
-                obj=obj,
-                module_namespace=module_namespace,
-            ).strip()
+            return (
+                self.env.get_template(template)
+                .render(
+                    obj=obj,
+                    module_namespace=module_namespace,
+                )
+                .strip()
+            )
 
         return "\n\n\n".join(map(render_class, classes)) + "\n"
 


Test patch:
diff --git a/tests/codegen/models/test_attr.py b/tests/codegen/models/test_attr.py
index 3cfe7675..e570dca3 100644
--- a/tests/codegen/models/test_attr.py
+++ b/tests/codegen/models/test_attr.py
@@ -53,6 +53,18 @@ class AttrTests(FactoryTestCase):
         element.restrictions.max_occurs = 2
         self.assertTrue(element.is_factory)
 
+    def test_property_is_forward_ref(self):
+        attr = AttrFactory.create()
+        self.assertFalse(attr.is_forward_ref)
+
+        attr.types.append(AttrTypeFactory.create("foo", circular=True))
+        self.assertTrue(attr.is_forward_ref)
+
+        attr.types.clear()
+        self.assertFalse(attr.is_forward_ref)
+        attr.types.append(AttrTypeFactory.create("foo", forward=True))
+        self.assertTrue(attr.is_forward_ref)
+
     def test_property_is_group(self):
         self.assertTrue(AttrFactory.group().is_group)
         self.assertTrue(AttrFactory.attribute_group().is_group)
diff --git a/tests/codegen/models/test_class.py b/tests/codegen/models/test_class.py
index 7aa3cc98..2fb2f43f 100644
--- a/tests/codegen/models/test_class.py
+++ b/tests/codegen/models/test_class.py
@@ -221,3 +221,26 @@ class ClassTests(FactoryTestCase):
 
         obj = ClassFactory.create(mixed=True)
         self.assertTrue(obj.is_mixed)
+
+    def test_has_forward_ref(self):
+        forward_type = AttrTypeFactory.create("foo", forward=True)
+        circular_type = AttrTypeFactory.create("foo", circular=True)
+
+        obj = ClassFactory.elements(1)
+        self.assertFalse(obj.has_forward_ref())
+
+        obj.attrs[0].types.append(forward_type)
+        self.assertTrue(obj.has_forward_ref())
+
+        obj.attrs[0].types.pop()
+        obj.attrs[0].choices.append(AttrFactory.create())
+        obj.attrs[0].choices.append(AttrFactory.create(types=[circular_type]))
+        self.assertTrue(obj.has_forward_ref())
+
+        obj.attrs.clear()
+        self.assertFalse(obj.has_forward_ref())
+
+        inner = ClassFactory.elements(1)
+        inner.attrs[0].types.append(circular_type)
+        obj.inner.append(inner)
+        self.assertTrue(obj.has_forward_ref())