Repository: tefra/xsdata
Pull request #538 (2021-06-23 14:37:58)

Guard against multiple substitution group runs

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=538&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=538&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=tefra_xsdata&pullRequest=538&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=tefra_xsdata&pullRequest=538&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=538) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3.png' alt='0.0%' width='16' height='16' />](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=538&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=538&metric=new_duplicated_lines_density&view=list)


codecov[bot]: # [Codecov](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) Report
> Merging [#538](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) (5361381) into [master](https://codecov.io/gh/tefra/xsdata/commit/e83e202a27b16a59e3ce61eeefc191e6a434b8b1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) (e83e202) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tefra/xsdata/pull/538/graphs/tree.svg?width=650&height=150&src=pr&token=YzDDLtywvl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)

```diff
@@            Coverage Diff            @@
##            master      #538   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           94        94           
  Lines         7685      7689    +4     
  Branches      1327      1328    +1     
=========================================
+ Hits          7685      7689    +4     
```


| [Impacted Files](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) | Coverage Δ | |
|---|---|---|
| [xsdata/codegen/handlers/attribute\_substitution.py](https://codecov.io/gh/tefra/xsdata/pull/538/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2NvZGVnZW4vaGFuZGxlcnMvYXR0cmlidXRlX3N1YnN0aXR1dGlvbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [xsdata/codegen/models.py](https://codecov.io/gh/tefra/xsdata/pull/538/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2NvZGVnZW4vbW9kZWxzLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou). Last update [e83e202...5361381](https://codecov.io/gh/tefra/xsdata/pull/538?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).


Patch:
diff --git a/xsdata/codegen/handlers/attribute_substitution.py b/xsdata/codegen/handlers/attribute_substitution.py
index e049561d..4a2ddb86 100644
--- a/xsdata/codegen/handlers/attribute_substitution.py
+++ b/xsdata/codegen/handlers/attribute_substitution.py
@@ -15,7 +15,7 @@ from xsdata.utils import collections
 class AttributeSubstitutionHandler(RelativeHandlerInterface):
     """Apply substitution attributes to the given class recursively."""
 
-    __slots__ = ("substitutions",)
+    __slots__ = "substitutions"
 
     def __init__(self, container: ContainerInterface):
         super().__init__(container)
@@ -43,11 +43,17 @@ class AttributeSubstitutionHandler(RelativeHandlerInterface):
 
         The cloned attributes are placed below the attribute the are
         supposed to substitute.
+
+        Guard against multiple substitutions in case of xs:groups.
         """
         index = target.attrs.index(attr)
         assert self.substitutions is not None
 
         for attr_type in attr.types:
+            if attr_type.substituted:
+                continue
+
+            attr_type.substituted = True
             for substitution in self.substitutions.get(attr_type.qname, []):
                 clone = ClassUtils.clone_attribute(substitution, attr.restrictions)
 
diff --git a/xsdata/codegen/models.py b/xsdata/codegen/models.py
index c3adefab..f654fb34 100644
--- a/xsdata/codegen/models.py
+++ b/xsdata/codegen/models.py
@@ -190,6 +190,7 @@ class AttrType:
     :param native:
     :param forward:
     :param circular:
+    :param substituted:
     """
 
     qname: str
@@ -198,6 +199,7 @@ class AttrType:
     native: bool = field(default=False)
     forward: bool = field(default=False)
     circular: bool = field(default=False)
+    substituted: bool = field(default=False, compare=False)
 
     @property
     def datatype(self) -> Optional[DataType]:


Test patch:
diff --git a/tests/codegen/handlers/test_attribute_substitution.py b/tests/codegen/handlers/test_attribute_substitution.py
index 9c022896..cb25bcbb 100644
--- a/tests/codegen/handlers/test_attribute_substitution.py
+++ b/tests/codegen/handlers/test_attribute_substitution.py
@@ -58,6 +58,10 @@ class AttributeSubstitutionHandlerTests(FactoryTestCase):
 
         self.assertEqual(4, len(target.attrs))
 
+        # Guard against multiple runs in case of xs:groups
+        self.processor.process_attribute(target, first_attr)
+        self.assertEqual(4, len(target.attrs))
+
         self.assertEqual(reference_attrs[0], target.attrs[0])
         self.assertIsNot(reference_attrs[0], target.attrs[0])
         self.assertEqual(reference_attrs[1], target.attrs[3])