Repository: tefra/xsdata

Issue:
Unresolvable circular dependencies
Schema source: [`https://gitlab.com/crossref/schema`](https://gitlab.com/crossref/schema) (checked out locally at `other_schema/crossref`).

There are a couple of different schemas in the repo that I'm looking at, but anything that includes the [`mathml3.xsd`](https://gitlab.com/crossref/schema/-/tree/master/schemas/standard-modules/mathml3) fails to generate the appropriate models.

e.g.
```
> xsdata other_schema/crossref/schemas/standard-modules/mathml3/mathml3.xsd --package crossref --structure-style namespaces
```
generates the error (stack trace edited for brevity)
```
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/toposort.py", line 84, in toposort
    raise CircularDependencyError(data)
toposort.CircularDependencyError: Circular dependencies exist among these items: {'{http://www.w3.org/1998/Math/MathML}ImpliedMrow':{'{http://www.w3.org/1998/Math/MathML}mrow', ... '{http://www.w3.org/1998/Math/MathML}mover'}}
```
(same for `single-package` and `filenames`)

```
> xsdata other_schema/crossref/schemas/standard-modules/mathml3/mathml3.xsd --package crossref --structure-style namespace-clusters
```
error (stack trace edited for brevity):
```
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/xsdata/utils/graphs.py", line 43, in strongly_connected_components
    yield from dfs(vertex)
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/xsdata/utils/graphs.py", line 29, in dfs
    yield from dfs(w)
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/xsdata/utils/graphs.py", line 29, in dfs
    yield from dfs(w)
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/xsdata/utils/graphs.py", line 29, in dfs
    yield from dfs(w)
  [Previous line repeated 1 more time]
  File "/Users/gwg/code/kbase/credit_engine/.venv/lib/python3.10/site-packages/xsdata/utils/graphs.py", line 27, in dfs
    for w in edges[v]:
KeyError: '{http://www.w3.org/1998/Math/MathML}product.class'
```
(same for `clusters`)

Can you suggest any way around this problem?

Discussion:

Patch:
diff --git a/xsdata/codegen/handlers/vacuum_inner_classes.py b/xsdata/codegen/handlers/vacuum_inner_classes.py
index 43234267..5f5671b9 100644
--- a/xsdata/codegen/handlers/vacuum_inner_classes.py
+++ b/xsdata/codegen/handlers/vacuum_inner_classes.py
@@ -4,6 +4,7 @@ from xsdata.codegen.mixins import HandlerInterface
 from xsdata.codegen.models import AttrType
 from xsdata.codegen.models import Class
 from xsdata.models.enums import DataType
+from xsdata.utils import collections
 from xsdata.utils.namespaces import build_qname
 
 
@@ -15,16 +16,18 @@ class VacuumInnerClasses(HandlerInterface):
     rename inner classes that have the same name as the outer/parent class.
 
     Cases:
-        1. Removing identical overriding fields can some times leave a class
+        1. Filter duplicate inner classes
+        2. Removing identical overriding fields can some times leave a class
            bare with just an extension. For inner classes we can safely
            replace the forward reference with the inner extension reference.
-        2. Empty nested complexContent with no restrictions or extensions,
+        3. Empty nested complexContent with no restrictions or extensions,
            we can replace these references with xs:anySimpleType
     """
 
     __slots__ = ()
 
     def process(self, target: Class):
+        target.inner = collections.unique_sequence(target.inner, key="qname")
         for inner in list(target.inner):
             if not inner.attrs and len(inner.extensions) < 2:
                 self.remove_inner(target, inner)
diff --git a/xsdata/codegen/models.py b/xsdata/codegen/models.py
index ae129e3a..e06cedd5 100644
--- a/xsdata/codegen/models.py
+++ b/xsdata/codegen/models.py
@@ -461,6 +461,10 @@ class Class:
         """Shortcut for qname local name."""
         return namespaces.local_name(self.qname)
 
+    @property
+    def slug(self) -> str:
+        return text.alnum(self.name)
+
     @property
     def ref(self) -> int:
         return id(self)
@@ -607,15 +611,24 @@ class Import:
     """
     Model representation of a python import statement.
 
-    :param name:
+    :param qname:
     :param source:
     :param alias:
     """
 
-    name: str
+    qname: str
     source: str
     alias: Optional[str] = field(default=None)
 
+    @property
+    def name(self) -> str:
+        """Shortcut for qname local name."""
+        return namespaces.local_name(self.qname)
+
+    @property
+    def slug(self) -> str:
+        return text.alnum(self.name)
+
 
 # Getters used all over the codegen process
 get_location = operator.attrgetter("location")
diff --git a/xsdata/codegen/resolver.py b/xsdata/codegen/resolver.py
index 419199e1..9ae55c71 100644
--- a/xsdata/codegen/resolver.py
+++ b/xsdata/codegen/resolver.py
@@ -1,15 +1,15 @@
 import logging
+import re
 from typing import Dict
 from typing import List
 
 from toposort import toposort_flatten
 
 from xsdata.codegen.models import Class
+from xsdata.codegen.models import get_slug
 from xsdata.codegen.models import Import
 from xsdata.exceptions import ResolverValueError
 from xsdata.utils import collections
-from xsdata.utils.namespaces import local_name
-from xsdata.utils.text import alnum
 
 logger = logging.getLogger(__name__)
 
@@ -75,25 +75,34 @@ class DependenciesResolver:
     def resolve_imports(self):
         """Walk the import qualified names, check for naming collisions and add
         the necessary code generator import instance."""
-        existing = {alnum(local_name(qname)) for qname in self.class_map.keys()}
-        for qname in self.import_classes():
-            package = self.find_package(qname)
-            name = alnum(local_name(qname))
-            exists = name in existing
-            existing.add(name)
-            self.add_import(qname=qname, package=package, exists=exists)
-
-    def add_import(self, qname: str, package: str, exists: bool = False):
-        """Append an import package to the list of imports with any if
-        necessary aliases if the import name exists in the local module."""
-        alias = None
-        name = local_name(qname)
-        if exists:
-            module = package.split(".")[-1]
-            alias = f"{module}:{name}"
-            self.aliases[qname] = alias
-
-        self.imports.append(Import(name=name, source=package, alias=alias))
+        self.imports = [
+            Import(qname=qname, source=self.find_package(qname))
+            for qname in self.import_classes()
+        ]
+        protected = {obj.slug for obj in self.class_map.values()}
+        self.resolve_conflicts(self.imports, protected)
+        self.set_aliases()
+
+    def set_aliases(self):
+        self.aliases = {imp.qname: imp.alias for imp in self.imports if imp.alias}
+
+    @classmethod
+    def resolve_conflicts(cls, imports: List[Import], protected: set):
+        for slug, group in collections.group_by(imports, key=get_slug).items():
+            if len(group) == 1:
+                if slug in protected:
+                    imp = group[0]
+                    module = imp.source.split(".")[-1]
+                    imp.alias = f"{module}:{imp.name}"
+                continue
+
+            for index, cur in enumerate(group):
+                cmp = group[index + 1] if index == 0 else group[index - 1]
+                parts = re.split("[_.]", cur.source)
+                diff = set(parts) - set(re.split("[_.]", cmp.source))
+
+                add = "_".join(part for part in parts if part in diff)
+                cur.alias = f"{add}:{cur.name}"
 
     def find_package(self, qname: str) -> str:
         """
diff --git a/xsdata/codegen/utils.py b/xsdata/codegen/utils.py
index 7bd498f7..27c1a464 100644
--- a/xsdata/codegen/utils.py
+++ b/xsdata/codegen/utils.py
@@ -11,6 +11,7 @@ from xsdata.codegen.models import Extension
 from xsdata.codegen.models import get_qname
 from xsdata.codegen.models import get_slug
 from xsdata.codegen.models import Restrictions
+from xsdata.codegen.models import Status
 from xsdata.exceptions import CodeGenerationError
 from xsdata.models.enums import DataType
 from xsdata.utils import collections
@@ -140,15 +141,15 @@ class ClassUtils:
         if not attr_type.forward:
             return
 
-        # This will fail if no inner class is found, too strict???
         inner = ClassUtils.find_inner(source, attr_type.qname)
-
         if inner is target:
             attr_type.circular = True
         else:
+            # In extreme cases this adds duplicate inner classes
             clone = inner.clone()
             clone.package = target.package
             clone.module = target.module
+            clone.status = Status.RAW
             target.inner.append(clone)
 
     @classmethod
diff --git a/xsdata/formats/dataclass/generator.py b/xsdata/formats/dataclass/generator.py
index 4e0d2fc4..c98bd24d 100644
--- a/xsdata/formats/dataclass/generator.py
+++ b/xsdata/formats/dataclass/generator.py
@@ -1,4 +1,3 @@
-import re
 from pathlib import Path
 from typing import Iterator
 from typing import List
@@ -14,7 +13,6 @@ from xsdata.formats.dataclass.filters import Filters
 from xsdata.formats.mixins import AbstractGenerator
 from xsdata.formats.mixins import GeneratorResult
 from xsdata.models.config import GeneratorConfig
-from xsdata.utils.collections import group_by
 
 
 class DataclassGenerator(AbstractGenerator):
@@ -35,7 +33,7 @@ class DataclassGenerator(AbstractGenerator):
 
     def render(self, classes: List[Class]) -> Iterator[GeneratorResult]:
         """
-        Return a iterator of the generated results.
+        Return an iterator of the generated results.
 
         Group classes into modules and yield an output per module and
         per path __init__.py file.
@@ -64,23 +62,11 @@ class DataclassGenerator(AbstractGenerator):
     def render_package(self, classes: List[Class], module: str) -> str:
         """Render the source code for the __init__.py with all the imports of
         the generated class names."""
-
         imports = [
-            Import(name=obj.name, source=obj.target_module)
+            Import(qname=obj.qname, source=obj.target_module)
             for obj in sorted(classes, key=lambda x: x.name)
         ]
-
-        for group in group_by(imports, key=lambda x: x.name).values():
-            if len(group) == 1:
-                continue
-
-            for index, cur in enumerate(group):
-                cmp = group[index + 1] if index == 0 else group[index - 1]
-                parts = re.split("[_.]", cur.source)
-                diff = set(parts) - set(re.split("[_.]", cmp.source))
-
-                add = "_".join(part for part in parts if part in diff)
-                cur.alias = f"{add}:{cur.name}"
+        DependenciesResolver.resolve_conflicts(imports, set())
 
         output = self.env.get_template("package.jinja2").render(
             imports=imports,


Test patch:
diff --git a/tests/codegen/handlers/test_vacuum_inner_classes.py b/tests/codegen/handlers/test_vacuum_inner_classes.py
index 1a4dc640..a7fb35cd 100644
--- a/tests/codegen/handlers/test_vacuum_inner_classes.py
+++ b/tests/codegen/handlers/test_vacuum_inner_classes.py
@@ -14,6 +14,26 @@ class VacuumInnerClassesTests(FactoryTestCase):
         super().setUp()
         self.processor = VacuumInnerClasses()
 
+    def test_remove_duplicate_inners(self):
+        target = ClassFactory.elements(3)
+
+        inner_1 = ClassFactory.elements(2)
+        inner_2 = inner_1.clone()
+
+        target.inner.extend([inner_1, inner_2])
+
+        target.attrs[1].types[0].qname = inner_1.qname
+        target.attrs[1].types[0].forward = True
+        target.attrs[1].types[0].reference = 4
+
+        target.attrs[2].types[0].qname = inner_2.qname
+        target.attrs[2].types[0].forward = True
+        target.attrs[2].types[0].circular = True
+        target.attrs[2].types[0].reference = 5
+
+        self.processor.process(target)
+        self.assertEqual(1, len(target.inner))
+
     def test_remove_inner(self):
         target = ClassFactory.elements(3)
 
diff --git a/tests/codegen/test_resolver.py b/tests/codegen/test_resolver.py
index 007be9b6..11b23a12 100644
--- a/tests/codegen/test_resolver.py
+++ b/tests/codegen/test_resolver.py
@@ -127,11 +127,16 @@ class DependenciesResolverTest(FactoryTestCase):
         self.assertIsNone(obj.inner[0].attrs[0].types[0].alias)
         self.assertEqual("IamD", obj.inner[0].attrs[1].types[0].alias)
 
-    @mock.patch.object(DependenciesResolver, "add_import")
+    @mock.patch.object(DependenciesResolver, "set_aliases")
+    @mock.patch.object(DependenciesResolver, "resolve_conflicts")
     @mock.patch.object(DependenciesResolver, "find_package")
     @mock.patch.object(DependenciesResolver, "import_classes")
     def test_resolve_imports(
-        self, mock_import_classes, mock_find_package, mock_add_import
+        self,
+        mock_import_classes,
+        mock_find_package,
+        mock_resolve_conflicts,
+        mock_set_aliases,
     ):
         class_life = ClassFactory.create(qname="life")
         import_names = [
@@ -146,33 +151,35 @@ class DependenciesResolverTest(FactoryTestCase):
         mock_find_package.side_effect = ["first", "second", "third", "forth", "fifth"]
 
         self.resolver.resolve_imports()
-        mock_add_import.assert_has_calls(
-            [
-                mock.call(qname=import_names[0], package="first", exists=False),
-                mock.call(qname=import_names[1], package="second", exists=False),
-                mock.call(qname=import_names[2], package="third", exists=True),
-                mock.call(qname=import_names[3], package="forth", exists=True),
-                mock.call(qname=import_names[4], package="fifth", exists=False),
-            ]
+        mock_resolve_conflicts.assert_called_once_with(
+            self.resolver.imports, {class_life.slug}
         )
+        mock_set_aliases.assert_called_once_with()
+
+    def test_resolve_conflicts(self):
+        imports = [
+            PackageFactory.create(qname="{a}a", source="models.aa"),
+            PackageFactory.create(qname="{b}a", source="models.ba"),
+            PackageFactory.create(qname="{c}root", source="models.common"),
+            PackageFactory.create(qname="{c}penalty", source="models.common"),
+        ]
 
-    def test_add_import(self):
-        self.assertEqual(0, len(self.resolver.imports))
-
-        package = "here.there"
-        foo_qname = "foo"
-        bar_qname = "bar"
-
-        self.resolver.add_import(foo_qname, package, False)
-        self.resolver.add_import(bar_qname, package, True)
+        protected = {"penalty"}
+        self.resolver.resolve_conflicts(imports, protected)
 
-        first = PackageFactory.create(name="foo", source=package, alias=None)
-        second = PackageFactory.create(name="bar", source=package, alias="there:bar")
+        self.assertEqual("aa:a", imports[0].alias)
+        self.assertEqual("ba:a", imports[1].alias)
+        self.assertEqual(None, imports[2].alias)
+        self.assertEqual("common:penalty", imports[3].alias)
 
-        self.assertEqual(2, len(self.resolver.imports))
-        self.assertEqual(first, self.resolver.imports[0])
-        self.assertEqual(second, self.resolver.imports[1])
-        self.assertEqual({bar_qname: "there:bar"}, self.resolver.aliases)
+    def test_set_aliases(self):
+        self.resolver.imports = [
+            PackageFactory.create(qname="{a}a", alias="aa"),
+            PackageFactory.create(qname="{b}a", alias="ba"),
+            PackageFactory.create(qname="{c}a"),
+        ]
+        self.resolver.set_aliases()
+        self.assertEqual({"{a}a": "aa", "{b}a": "ba"}, self.resolver.aliases)
 
     def test_find_package(self):
         class_a = ClassFactory.create()
diff --git a/tests/codegen/test_utils.py b/tests/codegen/test_utils.py
index 9a9a8d69..c0ea2c01 100644
--- a/tests/codegen/test_utils.py
+++ b/tests/codegen/test_utils.py
@@ -3,6 +3,7 @@ from typing import Generator
 from unittest import mock
 
 from xsdata.codegen.models import Restrictions
+from xsdata.codegen.models import Status
 from xsdata.codegen.utils import ClassUtils
 from xsdata.exceptions import CodeGenerationError
 from xsdata.models.enums import DataType
@@ -179,7 +180,9 @@ class ClassUtilsTests(FactoryTestCase):
 
     def test_copy_inner_class(self):
         source = ClassFactory.create()
-        inner = ClassFactory.create(qname="a", module="b", package="c")
+        inner = ClassFactory.create(
+            qname="a", module="b", package="c", status=Status.FLATTENED
+        )
         target = ClassFactory.create()
         attr = AttrFactory.create()
         attr_type = AttrTypeFactory.create(forward=True, qname=inner.qname)
@@ -192,16 +195,8 @@ class ClassUtilsTests(FactoryTestCase):
         self.assertEqual(target.package, target.inner[0].package)
         self.assertEqual(target.module, target.inner[0].module)
         self.assertEqual(inner.qname, target.inner[0].qname)
-
-    def test_copy_inner_class_skip_non_forward_reference(self):
-        source = ClassFactory.create()
-        target = ClassFactory.create()
-        attr = AttrFactory.create()
-        attr_type = AttrTypeFactory.create()
-        ClassUtils.copy_inner_class(source, target, attr, attr_type)
-
-        self.assertFalse(attr_type.circular)
-        self.assertEqual(0, len(target.inner))
+        self.assertIs(Status.RAW, target.inner[0].status)
+        self.assertIs(Status.FLATTENED, inner.status)
 
     def test_copy_inner_class_check_circular_reference(self):
         source = ClassFactory.create()
diff --git a/xsdata/utils/testing.py b/xsdata/utils/testing.py
index dcbf4304..4fbde1b9 100644
--- a/xsdata/utils/testing.py
+++ b/xsdata/utils/testing.py
@@ -347,12 +347,12 @@ class PackageFactory(Factory):
     @classmethod
     def create(
         cls,
-        name: str = "package",
-        source: str = "target",
+        qname: str = "{xsdata}Root",
+        source: str = "generated.models",
         alias: Optional[str] = None,
         **kwargs: Any,
     ) -> Import:
-        return Import(name=name, source=source, alias=alias)
+        return Import(qname=qname, source=source, alias=alias)
 
 
 class XmlVarFactory(Factory):