Repository: tefra/xsdata
Pull request #793 (2023-05-28 15:13:09)

Add support for custom decorators and base classes
## 📒 Description

Add configuration to extend generated models by adding custom base classes and decorators


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


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=tefra_xsdata&pullRequest=793)

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

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793&metric=new_duplicated_lines_density&view=list)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) Report
Patch coverage: **`97.36`**% and project coverage change: **`-0.03`** :warning:
> Comparison is base [(`5eaf612`)](https://app.codecov.io/gh/tefra/xsdata/commit/5eaf612469fd1c3bcd51c3822b8643468d43675c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) 99.96% compared to head [(`c00e4f8`)](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) 99.94%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #793      +/-   ##
==========================================
- Coverage   99.96%   99.94%   -0.03%     
==========================================
  Files         104      104              
  Lines        9138     9204      +66     
  Branches     2046     2060      +14     
==========================================
+ Hits         9135     9199      +64     
- Misses          0        2       +2     
  Partials        3        3              
```


| [Impacted Files](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou) | Coverage Δ | |
|---|---|---|
| [xsdata/models/config.py](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL21vZGVscy9jb25maWcucHk=) | `99.03% <93.10%> (-0.97%)` | :arrow_down: |
| [xsdata/formats/dataclass/filters.py](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou#diff-eHNkYXRhL2Zvcm1hdHMvZGF0YWNsYXNzL2ZpbHRlcnMucHk=) | `99.75% <100.00%> (+0.02%)` | :arrow_up: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/tefra/xsdata/pull/793?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Chris+Tsou).

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=tefra_xsdata&pullRequest=793)

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

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=tefra_xsdata&pullRequest=793&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/docs/api/codegen.rst b/docs/api/codegen.rst
index 33827196..90f49bec 100644
--- a/docs/api/codegen.rst
+++ b/docs/api/codegen.rst
@@ -32,11 +32,14 @@ like naming conventions and substitutions.
     OutputFormat
     GeneratorConventions
     GeneratorSubstitutions
+    GeneratorExtensions
     StructureStyle
     DocstringStyle
     ClassFilterStrategy
     CompoundFields
     ObjectType
+    ExtensionType
     GeneratorSubstitution
+    GeneratorExtension
     NameConvention
     NameCase
diff --git a/docs/examples.rst b/docs/examples.rst
index e714e049..e60eecc0 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -14,6 +14,7 @@ Code Generation
     examples/dtd-modeling
     examples/compound-fields
     examples/dataclasses-features
+    examples/extending-models
 
 
 Data Binding
diff --git a/docs/examples/extending-models.rst b/docs/examples/extending-models.rst
new file mode 100644
index 00000000..e94522cd
--- /dev/null
+++ b/docs/examples/extending-models.rst
@@ -0,0 +1,49 @@
+================
+Extending Models
+================
+
+
+
+Creating subclasses from the generated models require to repeat the original `Meta`
+class and it's generally discouraged. You can instead apply base classes and
+decorators through the code generation configuration.
+
+
+The following configuration will add a base class and a decorator to all the
+generated classes.
+
+Read :ref:`more <GeneratorExtension>`.
+
+
+.. code-block:: xml
+
+    <?xml version="1.0" encoding="UTF-8"?>
+    <Config xmlns="http://pypi.org/project/xsdata" version="23.6">
+      <Extensions>
+        <Extension type="class" class=".*" import="dataclasses_jsonschema.JsonSchemaMixin" prepend="false" applyIfDerived="false"/>
+        <Extension type="decorator" class=".*" import="typed_dataclass.typed_dataclass" prepend="false" applyIfDerived="false"/>
+      </Extensions>
+    </Config>
+
+
+.. code-block:: python
+
+    from dataclasses import dataclass, field
+    from dataclasses_jsonschema import JsonSchemaMixin
+    from typed_dataclass import typed_dataclass
+    from typing import Optional
+
+
+    @dataclass
+    @typed_dataclass
+    class Cores(JsonSchemaMixin):
+        class Meta:
+            name = "cores"
+
+        core: Optional[str] = field(
+            default=None,
+            metadata={
+                "type": "Element",
+                "required": True,
+            }
+        )
diff --git a/xsdata/formats/dataclass/filters.py b/xsdata/formats/dataclass/filters.py
index 54d2467d..0f44a943 100644
--- a/xsdata/formats/dataclass/filters.py
+++ b/xsdata/formats/dataclass/filters.py
@@ -8,6 +8,7 @@ from typing import Dict
 from typing import Iterable
 from typing import List
 from typing import Optional
+from typing import Set
 from typing import Tuple
 from typing import Type
 
@@ -21,7 +22,9 @@ from xsdata.codegen.models import Class
 from xsdata.formats.converter import converter
 from xsdata.formats.dataclass.models.elements import XmlType
 from xsdata.models.config import DocstringStyle
+from xsdata.models.config import ExtensionType
 from xsdata.models.config import GeneratorConfig
+from xsdata.models.config import GeneratorExtension
 from xsdata.models.config import ObjectType
 from xsdata.models.config import OutputFormat
 from xsdata.utils import collections
@@ -37,6 +40,7 @@ class Filters:
 
     __slots__ = (
         "substitutions",
+        "extensions",
         "class_case",
         "field_case",
         "constant_case",
@@ -53,6 +57,7 @@ class Filters:
         "postponed_annotations",
         "format",
         "import_patterns",
+        "default_class_annotation",
     )
 
     def __init__(self, config: GeneratorConfig):
@@ -60,6 +65,28 @@ class Filters:
         for sub in config.substitutions.substitution:
             self.substitutions[sub.type][sub.search] = sub.replace
 
+        self.import_patterns: Dict[str, Dict[str, Set[str]]] = defaultdict(
+            lambda: defaultdict(set)
+        )
+        self.extensions: Dict[ExtensionType, List[GeneratorExtension]] = defaultdict(
+            list
+        )
+        for ext in config.extensions.extension:
+            self.extensions[ext.type].append(ext)
+
+            is_annotation = ext.type is ExtensionType.DECORATOR
+            patterns = self.import_patterns[ext.module_path][ext.func_name]
+
+            if is_annotation:
+                patterns.add(f"@{ext.func_name}")
+            else:
+                patterns.update(
+                    [
+                        f"({ext.func_name}",
+                        f" {ext.func_name})",
+                    ]
+                )
+
         self.class_case: Callable = config.conventions.class_name.case
         self.field_case: Callable = config.conventions.field_name.case
         self.constant_case: Callable = config.conventions.constant_name.case
@@ -77,13 +104,16 @@ class Filters:
         self.format = config.output.format
 
         # Build things
-        self.import_patterns = self.build_import_patterns()
+        for module, imports in self.build_import_patterns().items():
+            for imp, patterns in imports.items():
+                self.import_patterns[module][imp].update(patterns)
+
+        self.default_class_annotation = self.build_class_annotation(self.format)
 
     def register(self, env: Environment):
         env.globals.update(
             {
                 "docstring_name": self.docstring_style.name.lower(),
-                "class_annotation": self.build_class_annotation(self.format),
             }
         )
         env.filters.update(
@@ -94,6 +124,8 @@ class Filters:
                 "field_metadata": self.field_metadata,
                 "field_definition": self.field_definition,
                 "class_name": self.class_name,
+                "class_bases": self.class_bases,
+                "class_annotations": self.class_annotations,
                 "class_params": self.class_params,
                 "format_string": self.format_string,
                 "format_docstring": self.format_docstring,
@@ -110,21 +142,21 @@ class Filters:
         )
 
     @classmethod
-    def build_class_annotation(cls, format: OutputFormat) -> str:
+    def build_class_annotation(cls, fmt: OutputFormat) -> str:
         args = []
-        if not format.repr:
+        if not fmt.repr:
             args.append("repr=False")
-        if not format.eq:
+        if not fmt.eq:
             args.append("eq=False")
-        if format.order:
+        if fmt.order:
             args.append("order=True")
-        if format.unsafe_hash:
+        if fmt.unsafe_hash:
             args.append("unsafe_hash=True")
-        if format.frozen:
+        if fmt.frozen:
             args.append("frozen=True")
-        if format.slots:
+        if fmt.slots:
             args.append("slots=True")
-        if format.kw_only:
+        if fmt.kw_only:
             args.append("kw_only=True")
 
         return f"@dataclass({', '.join(args)})" if args else "@dataclass"
@@ -146,6 +178,38 @@ class Filters:
         name = self.safe_name(name, self.class_safe_prefix, self.class_case)
         return self.apply_substitutions(name, ObjectType.CLASS)
 
+    def class_bases(self, obj: Class, class_name: str) -> List[str]:
+        """Return a list of base class names."""
+        bases = []
+        for obj_ext in obj.extensions:
+            bases.append(self.type_name(obj_ext.type))
+
+        derived = len(obj.extensions) > 0
+        for ext in self.extensions[ExtensionType.CLASS]:
+            is_valid = not derived or ext.apply_if_derived
+            if is_valid and ext.pattern.match(class_name):
+                if ext.prepend:
+                    bases.insert(0, ext.func_name)
+                else:
+                    bases.append(ext.func_name)
+
+        return collections.unique_sequence(bases)
+
+    def class_annotations(self, obj: Class, class_name: str) -> List[str]:
+        """Return a list of decorator names."""
+        annotations = [self.default_class_annotation]
+
+        derived = len(obj.extensions) > 0
+        for ext in self.extensions[ExtensionType.DECORATOR]:
+            is_valid = not derived or ext.apply_if_derived
+            if is_valid and ext.pattern.match(class_name):
+                if ext.prepend:
+                    annotations.insert(0, f"@{ext.func_name}")
+                else:
+                    annotations.append(f"@{ext.func_name}")
+
+        return collections.unique_sequence(annotations)
+
     def apply_substitutions(self, name: str, obj_type: ObjectType) -> str:
         for search, replace in self.substitutions[obj_type].items():
             name = re.sub(rf"{search}", rf"{replace}", name)
@@ -695,7 +759,7 @@ class Filters:
             elif names:
                 result.append(f"from {library} import {', '.join(names)}")
 
-        return "\n".join(result)
+        return "\n".join(sorted(result))
 
     @classmethod
     def build_import_patterns(cls) -> Dict[str, Dict]:
diff --git a/xsdata/formats/dataclass/templates/class.jinja2 b/xsdata/formats/dataclass/templates/class.jinja2
index de3f8dc2..63b79945 100644
--- a/xsdata/formats/dataclass/templates/class.jinja2
+++ b/xsdata/formats/dataclass/templates/class.jinja2
@@ -5,13 +5,14 @@
 {% set parent_namespace = obj.namespace if obj.namespace is not none else parent_namespace|default(None) -%}
 {% set parents = parents|default([obj.name]) -%}
 {% set class_name =  obj.name|class_name -%}
+{% set class_annotations =  obj | class_annotations(class_name) -%}
 {% set global_type = level == 0 and not obj.local_type -%}
 {% set local_name =  obj.meta_name or obj.name -%}
 {% set local_name = None if class_name == local_name or not global_type else local_name -%}
-{% set base_classes = obj.extensions|map(attribute='type')|map('type_name')|join(', ') -%}
+{% set base_classes = obj | class_bases(class_name) | join(', ')-%}
 {% set target_namespace = obj.target_namespace if global_type and module_namespace != obj.target_namespace else None %}
 
-{{ class_annotation }}
+{{ class_annotations | join('\n') }}
 class {{ class_name }}{{"({})".format(base_classes) if base_classes }}:
 {%- if help %}
 {{ help|indent(4, first=True) }}
diff --git a/xsdata/models/config.py b/xsdata/models/config.py
index 9777f113..5c723a49 100644
--- a/xsdata/models/config.py
+++ b/xsdata/models/config.py
@@ -1,6 +1,8 @@
+import re
 import sys
 import warnings
 from dataclasses import dataclass
+from dataclasses import field
 from enum import Enum
 from pathlib import Path
 from typing import Any
@@ -8,6 +10,7 @@ from typing import Callable
 from typing import Dict
 from typing import List
 from typing import Optional
+from typing import Pattern
 from typing import TextIO
 
 from xsdata import __version__
@@ -165,6 +168,18 @@ class ObjectType(Enum):
     PACKAGE = "package"
 
 
+class ExtensionType(Enum):
+    """
+    Extension type enumeration.
+
+    :cvar CLASS: class
+    :cvar DECORATOR: decorator
+    """
+
+    CLASS = "class"
+    DECORATOR = "decorator"
+
+
 @dataclass
 class OutputFormat:
     """
@@ -375,6 +390,52 @@ class GeneratorSubstitution:
     replace: str = attribute(required=True)
 
 
+@dataclass
+class GeneratorExtension:
+    """
+    Add decorators or base classes on the generated classes that match the
+    class name pattern.
+
+    :param type: The extension type
+    :param class_name: The class name  or a pattern to apply the extension
+    :param import_string: The import string of the extension type
+    :param prepend: Prepend or append decorator or base class
+    :param apply_if_derived: Apply or skip if the class is already a subclass
+    """
+
+    type: ExtensionType = attribute(required=True)
+    class_name: str = attribute(required=True, name="class")
+    import_string: str = attribute(required=True, name="import")
+    prepend: bool = attribute(default=False)
+    apply_if_derived: bool = attribute(default=False, name="applyIfDerived")
+
+    module_path: str = field(
+        init=False,
+        metadata={"type": "Ignore"},
+    )
+    func_name: str = field(
+        init=False,
+        metadata={"type": "Ignore"},
+    )
+    pattern: Pattern = field(
+        init=False,
+        metadata={"type": "Ignore"},
+    )
+
+    def __post_init__(self):
+        try:
+            self.module_path, self.func_name = self.import_string.rsplit(".", 1)
+        except (ValueError, AttributeError):
+            raise GeneratorConfigError(
+                f"Invalid extension import '{self.import_string}'"
+            )
+
+        try:
+            self.pattern = re.compile(self.class_name)
+        except re.error:
+            raise GeneratorConfigError(f"Failed to compile pattern '{self.class_name}'")
+
+
 @dataclass
 class GeneratorSubstitutions:
     """
@@ -390,6 +451,20 @@ class GeneratorSubstitutions:
     substitution: List[GeneratorSubstitution] = array_element()
 
 
+@dataclass
+class GeneratorExtensions:
+    """
+    Generator extensions for classes. The process runs after the default naming
+    conventions.
+
+    .. warning:: The generator doesn't validate imports!
+
+    :param extension: The list of extensions
+    """
+
+    extension: List[GeneratorExtension] = array_element()
+
+
 @dataclass
 class GeneratorConfig:
     """
@@ -401,6 +476,7 @@ class GeneratorConfig:
     :param aliases: Generator aliases, Deprecated since v21.12, use substitutions
     :param substitutions: Generator search and replace substitutions for classes,
         fields, packages and modules names.
+    :param extensions: Generator custom base classes and decorators for classes.
     """
 
     class Meta:
@@ -414,6 +490,7 @@ class GeneratorConfig:
     substitutions: GeneratorSubstitutions = element(
         default_factory=GeneratorSubstitutions
     )
+    extensions: GeneratorExtensions = element(default_factory=GeneratorExtensions)
 
     def __post_init__(self):
         if self.aliases:


Test patch:
diff --git a/tests/fixtures/stripe/.xsdata.xml b/tests/fixtures/stripe/.xsdata.xml
index 9719f738..3dbe9a93 100644
--- a/tests/fixtures/stripe/.xsdata.xml
+++ b/tests/fixtures/stripe/.xsdata.xml
@@ -17,4 +17,5 @@
   </Conventions>
   <Aliases/>
   <Substitutions/>
+  <Extensions />
 </Config>
diff --git a/tests/formats/dataclass/test_filters.py b/tests/formats/dataclass/test_filters.py
index e923fb7d..5058d6ef 100644
--- a/tests/formats/dataclass/test_filters.py
+++ b/tests/formats/dataclass/test_filters.py
@@ -5,7 +5,9 @@ from tests.fixtures.datatypes import Telephone
 from xsdata.codegen.models import Restrictions
 from xsdata.formats.dataclass.filters import Filters
 from xsdata.models.config import DocstringStyle
+from xsdata.models.config import ExtensionType
 from xsdata.models.config import GeneratorConfig
+from xsdata.models.config import GeneratorExtension
 from xsdata.models.config import GeneratorSubstitution
 from xsdata.models.config import NameCase
 from xsdata.models.config import ObjectType
@@ -14,6 +16,8 @@ from xsdata.models.enums import Namespace
 from xsdata.models.enums import Tag
 from xsdata.utils.testing import AttrFactory
 from xsdata.utils.testing import AttrTypeFactory
+from xsdata.utils.testing import ClassFactory
+from xsdata.utils.testing import ExtensionFactory
 from xsdata.utils.testing import FactoryTestCase
 
 type_str = AttrTypeFactory.native(DataType.STRING)
@@ -42,6 +46,102 @@ class FiltersTests(FactoryTestCase):
         self.assertEqual("TypeType", self.filters.class_name(".*"))
         self.assertEqual("Cbad", self.filters.class_name("abcd"))
 
+    def test_class_bases(self):
+        etp = ExtensionType.CLASS
+        self.filters.extensions[etp] = [
+            GeneratorExtension(
+                type=etp,
+                class_name=".*Bar",
+                import_string="a.b",
+                apply_if_derived=True,
+                prepend=False,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.b",
+                apply_if_derived=True,
+                prepend=True,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.c",
+                apply_if_derived=True,
+                prepend=True,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.d",
+                apply_if_derived=False,
+                prepend=True,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Nope.*",
+                import_string="a.e",
+                apply_if_derived=True,
+                prepend=False,
+            ),
+        ]
+        target = ClassFactory.create(extensions=ExtensionFactory.list(1))
+
+        expected = self.filters.class_bases(target, "FooBar")
+        self.assertEqual(["c", "b", "AttrB"], expected)
+
+        target.extensions.clear()
+        expected = self.filters.class_bases(target, "FooBar")
+        self.assertEqual(["d", "c", "b"], expected)
+
+    def test_class_annotations(self):
+        etp = ExtensionType.DECORATOR
+        self.filters.extensions[etp] = [
+            GeneratorExtension(
+                type=etp,
+                class_name=".*Bar",
+                import_string="a.b",
+                apply_if_derived=True,
+                prepend=False,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.b",
+                apply_if_derived=True,
+                prepend=True,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.c",
+                apply_if_derived=True,
+                prepend=True,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Foo.*",
+                import_string="a.d",
+                apply_if_derived=False,
+                prepend=False,
+            ),
+            GeneratorExtension(
+                type=etp,
+                class_name="Nope.*",
+                import_string="a.e",
+                apply_if_derived=True,
+                prepend=False,
+            ),
+        ]
+        target = ClassFactory.create(extensions=ExtensionFactory.list(1))
+
+        expected = self.filters.class_annotations(target, "FooBar")
+        self.assertEqual(["@c", "@b", "@dataclass"], expected)
+
+        target.extensions.clear()
+        expected = self.filters.class_annotations(target, "FooBar")
+        self.assertEqual(["@c", "@b", "@dataclass", "@d"], expected)
+
     def test_field_name(self):
         self.filters.substitutions[ObjectType.FIELD]["abc"] = "cba"
 
@@ -817,6 +917,14 @@ class FiltersTests(FactoryTestCase):
         config.substitutions.substitution.append(
             GeneratorSubstitution(ObjectType.PACKAGE, "m", "n")
         )
+        config.extensions.extension.extend(
+            [
+                GeneratorExtension(ExtensionType.DECORATOR, "a", "a.b"),
+                GeneratorExtension(ExtensionType.DECORATOR, "b", "a.c"),
+                GeneratorExtension(ExtensionType.CLASS, "c", "a.d"),
+                GeneratorExtension(ExtensionType.CLASS, "d", "a.e"),
+            ]
+        )
 
         filters = Filters(config)
 
@@ -839,3 +947,17 @@ class FiltersTests(FactoryTestCase):
             ObjectType.PACKAGE: {"m": "n"},
         }
         self.assertEqual(expected_substitutions, filters.substitutions)
+
+        expected_extensions = {
+            ExtensionType.DECORATOR: config.extensions.extension[0:2],
+            ExtensionType.CLASS: config.extensions.extension[2:4],
+        }
+        self.assertEqual(expected_extensions, filters.extensions)
+
+        expected_imports = {
+            "b": {"@b"},
+            "c": {"@c"},
+            "d": {"(d", " d)"},
+            "e": {"(e", " e)"},
+        }
+        self.assertEqual(expected_imports, filters.import_patterns["a"])
diff --git a/tests/models/test_config.py b/tests/models/test_config.py
index cbf737a8..19d24193 100644
--- a/tests/models/test_config.py
+++ b/tests/models/test_config.py
@@ -7,9 +7,11 @@ from unittest import TestCase
 from xsdata import __version__
 from xsdata.exceptions import GeneratorConfigError
 from xsdata.exceptions import ParserError
+from xsdata.models.config import ExtensionType
 from xsdata.models.config import GeneratorAlias
 from xsdata.models.config import GeneratorAliases
 from xsdata.models.config import GeneratorConfig
+from xsdata.models.config import GeneratorExtension
 from xsdata.models.config import GeneratorOutput
 from xsdata.models.config import ObjectType
 from xsdata.models.config import OutputFormat
@@ -60,6 +62,7 @@ class GeneratorConfigTests(TestCase):
             '    <Substitution type="package" search="http://schemas.xmlsoap.org/soap/envelope/" replace="soapenv"/>\n'
             '    <Substitution type="class" search="(.*)Class$" replace="\\1Type"/>\n'
             "  </Substitutions>\n"
+            "  <Extensions/>\n"
             "</Config>\n"
         )
         self.assertEqual(expected, file_path.read_text())
@@ -111,6 +114,7 @@ class GeneratorConfigTests(TestCase):
             "  </Conventions>\n"
             "  <Aliases/>\n"
             "  <Substitutions/>\n"
+            "  <Extensions/>\n"
             "</Config>\n"
         )
         self.assertEqual(expected, file_path.read_text())
@@ -209,3 +213,19 @@ class GeneratorConfigTests(TestCase):
         config = GeneratorConfig.read(output_path)
         self.assertIsNone(config.aliases)
         self.assertEqual(4, len(config.substitutions.substitution))
+
+    def test_extension_with_invalid_import_string(self):
+        cases = [None, "", "bar"]
+        for case in cases:
+            with self.assertRaises(GeneratorConfigError) as cm:
+                GeneratorExtension(type=ExtensionType.DECORATOR, import_string=case)
+
+            self.assertEqual(f"Invalid extension import '{case}'", str(cm.exception))
+
+    def test_extension_with_invalid_class_name_pattern(self):
+        with self.assertRaises(GeneratorConfigError) as cm:
+            GeneratorExtension(
+                type=ExtensionType.DECORATOR, import_string="a.b", class_name="*Foo"
+            )
+
+        self.assertEqual(f"Failed to compile pattern '*Foo'", str(cm.exception))