Repository: mirumee/ariadne-codegen
Pull request #89 (2023-03-06 16:09:24)

Base plugin
This pr introduces BasePlugin, mechanisms to find classes that inherit from BasePlugin, and execution of 1st  hook. Not sure how to approach documentation of "not completed" feature

rafalp: Let's add `PLUGINS.md` file already that documents how to write a plugin and document this hook. Otherwise we will have to document plugins and hooks at later time, and that's impossible to do good.
mat-sop: > Let's add `PLUGINS.md` file already that documents how to write a plugin and document this hook. Otherwise we will have to document plugins and hooks at later time, and that's impossible to do good.

Done, but I'm not sure if this should be already included in readme? The same with documenting new parameter `plugins`.  

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c035d21..cc78c4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - Fixed generating models from interfaces with inline fragments.
 - Added default `None` values for generated methods optional arguments.
+- Added basic plugin system.
 
 
 ## 0.3.0 (2023-02-21)
diff --git a/PLUGINS.md b/PLUGINS.md
new file mode 100644
index 0000000..2e9d00a
--- /dev/null
+++ b/PLUGINS.md
@@ -0,0 +1,66 @@
+# Plugins
+
+## How to implement plugin
+
+Plugin is a class extending `ariadne_codegen.plugins.base.Plugin`.
+
+Plugins are instantiated once, at the beginning of `ariadne-codegen` command, with following arguments:
+- `schema: GraphQLSchema`: parsed graphql schema
+- `config_dict: dict`: parsed `pyproject.toml` file represented as a dictionary
+
+To handle specific events custom plugins need to override [hook methods](#hooks) from default `Plugin`. Default hook methods from `Plugin` don't implement any logic on their own.
+
+
+## Enabling plugins
+
+Plugins can be enabled in `ariadne-codegen` by assigning list of strings to `plugins` key in config file.
+Every element of the list can enable plugins in 2 ways:
+
+1. Providing full import path to class, eg. `my_package.my_plugins.MyPlugin`.
+
+2. Providing only package name, eg. `my_package`. In this case all plugins from selected package will be used. For class to be accessible this way, it needs to be in package's public api. Plugin has to be importable from package, eg. `from my_package import MyPlugin` has to work.
+
+
+## Hooks
+
+### generate_init_module
+
+```py
+def generate_init_module(self, module: ast.Module) -> ast.Module:
+```
+
+Hook executed on generation of init module. Module has list of public, generated classes and reimports them all. Later this module will be saved as `__init__.py`.
+
+
+## Example
+
+This example plugin adds `__version__ = "..."` to generated `__init__.py` file.
+
+```py
+import ast
+
+from ariadne_codegen.plugins.base import Plugin
+
+
+class VersionPlugin(Plugin):
+    def generate_init_module(self, module: ast.Module) -> ast.Module:
+        version = (
+            self.config_dict.get("tools", {})
+            .get("version_plugin", {})
+            .get("version", "0.1")
+        )
+        assign = ast.Assign(
+            targets=[ast.Name(id="__version__")],
+            value=ast.Constant(value=version),
+            lineno=len(module.body) + 1,
+        )
+        module.body.append(assign)
+        return module
+```
+
+`VersionPlugin` reads version from parsed `pyproject.toml`, eg. following entry will produce `__version__ = "0.21"`.
+
+```toml
+[tools.version_plugin]
+version = 0.21
+``` 
diff --git a/README.md b/README.md
index 6bc4868..facbf5a 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,12 @@ Optional settings:
 - `convert_to_snake_case` (defaults to `true`) - a flag that specifies whether to convert fields and arguments names to snake case
 - `async_client` (defaults to `true`) - default generated client is `async`, change this to option `false` to generate synchronous client instead
 - `files_to_include` (defaults to `[]`) - list of files which will be copied into generated package
+- `plugins` (defaults to `[]`) - list of plugins to use during generation
+
+
+## Plugins
+
+Ariadne Codegen implements a plugin system that enables further customization and fine-tuning of generated Python code. It’s documentation is available separately in the [PLUGINS.md](https://github.com/mirumee/ariadne-codegen/blob/main/PLUGINS.md) file.
 
 
 ## Using generated client
diff --git a/ariadne_codegen/__init__.py b/ariadne_codegen/__init__.py
index e69de29..bb09064 100644
--- a/ariadne_codegen/__init__.py
+++ b/ariadne_codegen/__init__.py
@@ -0,0 +1,3 @@
+from .plugins.base import Plugin
+
+__all__ = ["Plugin"]
diff --git a/ariadne_codegen/config.py b/ariadne_codegen/config.py
index 134428e..6794853 100644
--- a/ariadne_codegen/config.py
+++ b/ariadne_codegen/config.py
@@ -28,6 +28,7 @@ class Settings:
     convert_to_snake_case: bool = True
     async_client: bool = True
     files_to_include: List[str] = field(default_factory=list)
+    plugins: List[str] = field(default_factory=list)
     scalars: Dict[str, ScalarData] = field(default_factory=dict)
 
     def __post_init__(self):
@@ -108,16 +109,21 @@ def get_config_file_path(file_name: str = "pyproject.toml") -> Path:
     return directory.joinpath(file_name).resolve()
 
 
-def parse_config_file(
-    file_path: Path, section_key: str = "ariadne-codegen"
+def get_config_dict() -> Dict:
+    """Get config dict."""
+    config_path = get_config_file_path()
+    return toml.load(config_path)
+
+
+def parse_config_dict(
+    config_dict: Dict, section_key: str = "ariadne-codegen"
 ) -> Settings:
     """
     Parse configuration from toml file. Raise exception if section or key was not found.
     """
-    config = toml.load(file_path)
-    if section_key not in config:
-        raise MissingConfiguration(f"File {file_path} has no [{section_key}] section.")
-    section = config[section_key]
+    if section_key not in config_dict:
+        raise MissingConfiguration(f"Config has no [{section_key}] section.")
+    section = config_dict[section_key]
 
     try:
         section["scalars"] = {
@@ -138,7 +144,7 @@ def parse_config_file(
         return Settings(**section)
     except TypeError as exc:
         expected_fields = {f.name for f in fields(Settings)}
-        missing_fields = expected_fields.difference(config[section_key])
+        missing_fields = expected_fields.difference(config_dict[section_key])
         raise MissingConfiguration(
             f"Missing configuration fields: {', '.join(missing_fields)}"
         ) from exc
@@ -178,7 +184,3 @@ def get_used_settings_message(settings: Settings) -> str:
         Coping following files into package: {files_to_include_list}
         """
     )
-
-
-def get_settings() -> Settings:
-    return parse_config_file(get_config_file_path())
diff --git a/ariadne_codegen/exceptions.py b/ariadne_codegen/exceptions.py
index 9bd77e7..5a07b46 100644
--- a/ariadne_codegen/exceptions.py
+++ b/ariadne_codegen/exceptions.py
@@ -28,3 +28,7 @@ class ParsingError(CodeGenException):
 
 class IntrospectionError(CodeGenException):
     """Introspection error."""
+
+
+class PluginImportError(CodeGenException):
+    """Error occurred during the plugin lookup."""
diff --git a/ariadne_codegen/generators/init_file.py b/ariadne_codegen/generators/init_file.py
index 6bac5de..c5cd4dd 100644
--- a/ariadne_codegen/generators/init_file.py
+++ b/ariadne_codegen/generators/init_file.py
@@ -1,12 +1,14 @@
 import ast
-from typing import List
+from typing import List, Optional
 
+from ..plugins.manager import PluginManager
 from .codegen import generate_import_from
 
 
 class InitFileGenerator:
-    def __init__(self) -> None:
+    def __init__(self, plugin_manager: Optional[PluginManager] = None) -> None:
         self.imports: list = []
+        self.plugin_manager = plugin_manager
 
     def add_import(self, names: List[str], from_: str, level: int = 0) -> None:
         """Add import to be included in init file."""
@@ -34,4 +36,6 @@ class InitFileGenerator:
                     lineno=len(self.imports) + 1,
                 )
             )
+        if self.plugin_manager:
+            module = self.plugin_manager.generate_init_module(module)
         return module
diff --git a/ariadne_codegen/generators/package.py b/ariadne_codegen/generators/package.py
index 3d9df6a..7cc727c 100644
--- a/ariadne_codegen/generators/package.py
+++ b/ariadne_codegen/generators/package.py
@@ -6,6 +6,7 @@ from typing import Dict, List, Optional
 from graphql import FragmentDefinitionNode, GraphQLSchema, OperationDefinitionNode
 
 from ..exceptions import ParsingError
+from ..plugins.manager import PluginManager
 from .arguments import ArgumentsGenerator
 from .client import ClientGenerator
 from .codegen import generate_import_from
@@ -51,6 +52,7 @@ class PackageGenerator:
         input_types_generator: Optional[InputTypesGenerator] = None,
         files_to_include: Optional[List[str]] = None,
         custom_scalars: Optional[Dict[str, ScalarData]] = None,
+        plugin_manager: Optional[PluginManager] = None,
     ) -> None:
         self.package_name = package_name
         self.target_path = target_path
@@ -60,6 +62,8 @@ class PackageGenerator:
         self.base_client_name = base_client_name
         self.custom_scalars = custom_scalars if custom_scalars else {}
 
+        self.plugin_manager = plugin_manager
+
         self.base_model_file_path = (
             Path(__file__).parent / "dependencies" / "base_model.py"
         )
@@ -84,7 +88,11 @@ class PackageGenerator:
         self.convert_to_snake_case = convert_to_snake_case
         self.async_client = async_client
 
-        self.init_generator = init_generator if init_generator else InitFileGenerator()
+        self.init_generator = (
+            init_generator
+            if init_generator
+            else InitFileGenerator(plugin_manager=self.plugin_manager)
+        )
         self.client_generator = (
             client_generator
             if client_generator
diff --git a/ariadne_codegen/main.py b/ariadne_codegen/main.py
index d7398af..0e04346 100644
--- a/ariadne_codegen/main.py
+++ b/ariadne_codegen/main.py
@@ -2,8 +2,10 @@ import sys
 
 import click
 
-from .config import get_settings, get_used_settings_message
+from .config import get_config_dict, get_used_settings_message, parse_config_dict
 from .generators.package import PackageGenerator
+from .plugins.explorer import get_plugins_types
+from .plugins.manager import PluginManager
 from .schema import (
     filter_fragments_definitions,
     filter_operations_definitions,
@@ -16,7 +18,8 @@ from .schema import (
 @click.command()
 @click.version_option()
 def main():
-    settings = get_settings()
+    config_dict = get_config_dict()
+    settings = parse_config_dict(config_dict)
     if settings.schema_path:
         schema = get_graphql_schema_from_path(settings.schema_path)
         schema_source = settings.schema_path
@@ -49,6 +52,11 @@ def main():
         async_client=settings.async_client,
         files_to_include=settings.files_to_include,
         custom_scalars=settings.scalars,
+        plugin_manager=PluginManager(
+            schema=schema,
+            config_dict=config_dict,
+            plugins_types=get_plugins_types(settings.plugins),
+        ),
     )
     for query in queries:
         package_generator.add_operation(query)
diff --git a/ariadne_codegen/plugins/__init__.py b/ariadne_codegen/plugins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ariadne_codegen/plugins/base.py b/ariadne_codegen/plugins/base.py
new file mode 100644
index 0000000..eb347a6
--- /dev/null
+++ b/ariadne_codegen/plugins/base.py
@@ -0,0 +1,13 @@
+import ast
+from typing import Dict
+
+from graphql import GraphQLSchema
+
+
+class Plugin:
+    def __init__(self, schema: GraphQLSchema, config_dict: Dict) -> None:
+        self.schema = schema
+        self.config_dict = config_dict
+
+    def generate_init_module(self, module: ast.Module) -> ast.Module:
+        return module
diff --git a/ariadne_codegen/plugins/explorer.py b/ariadne_codegen/plugins/explorer.py
new file mode 100644
index 0000000..02ce341
--- /dev/null
+++ b/ariadne_codegen/plugins/explorer.py
@@ -0,0 +1,61 @@
+import importlib
+import importlib.util
+import inspect
+from typing import Any, List, Type
+
+from ..exceptions import PluginImportError
+from .base import Plugin
+
+
+def get_plugins_types(plugins_strs: List[str]) -> List[Type[Plugin]]:
+    classes = []
+    for plugin_str in plugins_strs:
+        if is_module_str(plugin_str):
+            classes.extend(get_plugins_types_from_module(module_str=plugin_str))
+        else:
+            classes.append(get_plugin_type(plugin_str))
+    return classes
+
+
+def is_module_str(plugin_str: str) -> bool:
+    try:
+        spec = importlib.util.find_spec(plugin_str)
+        return spec is not None
+    except ModuleNotFoundError:
+        return False
+
+
+def get_plugins_types_from_module(module_str: str) -> List[Type[Plugin]]:
+    module = importlib.import_module(module_str)
+    return [obj for _, obj in inspect.getmembers(module) if is_plugin_type(obj)]
+
+
+def is_plugin_type(obj: Any) -> bool:
+    return inspect.isclass(obj) and obj is not Plugin and issubclass(obj, Plugin)
+
+
+def get_plugin_type(class_str: str) -> Type[Plugin]:
+    last_dot_index = class_str.rfind(".")
+    if last_dot_index < 0:
+        raise PluginImportError("Incorrect plugin path. Use an absolute import path.")
+    module_str, class_name = (
+        class_str[:last_dot_index],
+        class_str[last_dot_index + 1 :],
+    )
+
+    try:
+        module = importlib.import_module(module_str)
+        class_obj = getattr(module, class_name)
+    except ModuleNotFoundError as exc:
+        raise PluginImportError(
+            f"Incorrect plugin module. Cannot import from {module_str}"
+        ) from exc
+    except AttributeError as exc:
+        raise PluginImportError(
+            f"Class {class_name} not found in module {module_str}"
+        ) from exc
+
+    if not is_plugin_type(class_obj):
+        raise PluginImportError(f"Selected object {class_str} is not a plugin class.")
+
+    return class_obj
diff --git a/ariadne_codegen/plugins/manager.py b/ariadne_codegen/plugins/manager.py
new file mode 100644
index 0000000..f5304b7
--- /dev/null
+++ b/ariadne_codegen/plugins/manager.py
@@ -0,0 +1,26 @@
+import ast
+from typing import Dict, List, Optional, Type
+
+from graphql import GraphQLSchema
+
+from .base import Plugin
+
+
+class PluginManager:
+    def __init__(
+        self,
+        schema: GraphQLSchema,
+        config_dict: Optional[Dict] = None,
+        plugins_types: Optional[List[Type[Plugin]]] = None,
+    ) -> None:
+
+        self.plugins: List[Plugin] = [
+            cls(schema=schema, config_dict=config_dict or {})
+            for cls in plugins_types or []
+        ]
+
+    def generate_init_module(self, module: ast.Module) -> ast.Module:
+        modified_module = module
+        for plugin in self.plugins:
+            modified_module = plugin.generate_init_module(modified_module)
+        return modified_module


Test patch:
diff --git a/tests/generators/test_init_file_generator.py b/tests/generators/test_init_file_generator.py
index 3299998..45f2023 100644
--- a/tests/generators/test_init_file_generator.py
+++ b/tests/generators/test_init_file_generator.py
@@ -41,3 +41,12 @@ def test_generate_with_added_imports_returns_module():
     assert [n.id for n in assign_stmt.targets] == ["__all__"]
     assert isinstance(assign_stmt.value, ast.List)
     assert [c.value for c in assign_stmt.value.elts] == [name1, name2]
+
+
+def test_generate_triggers_generate_init_module_from_plugin_manager(mocker):
+    mocked_plugin_manager = mocker.MagicMock()
+    generator = InitFileGenerator(plugin_manager=mocked_plugin_manager)
+
+    generator.generate()
+
+    assert mocked_plugin_manager.generate_init_module.called
diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/plugins/test_explorer.py b/tests/plugins/test_explorer.py
new file mode 100644
index 0000000..3e825a8
--- /dev/null
+++ b/tests/plugins/test_explorer.py
@@ -0,0 +1,223 @@
+import os
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+
+from ariadne_codegen.exceptions import PluginImportError
+from ariadne_codegen.plugins.base import Plugin
+from ariadne_codegen.plugins.explorer import (
+    get_plugin_type,
+    get_plugins_types,
+    get_plugins_types_from_module,
+    is_module_str,
+    is_plugin_type,
+)
+
+
+@pytest.fixture
+def tmp_path_cwd(tmp_path):
+    old_cwd = Path.cwd()
+    os.chdir(tmp_path)
+    sys.path.append(tmp_path.as_posix())
+
+    yield tmp_path
+
+    os.chdir(old_cwd)
+    sys.path.remove(tmp_path.as_posix())
+
+
+def create_module_dir(base: Path, module_str: str) -> Path:
+    path = base
+    for dir_name in module_str.split("."):
+        path = path / dir_name
+        path.mkdir()
+        path.joinpath("__init__.py").touch()
+    return path
+
+
+def test_get_plugins_types_returns_plugin_classes_from_all_modules(tmp_path_cwd):
+    module_xyz_path = create_module_dir(tmp_path_cwd, "module_xyz")
+    init_xyz_content = """
+    from .x import PluginX
+    from .y import PluginY
+
+    __all__ = ["PluginX", "PluginY"]
+    """
+    x_content = """
+    from ariadne_codegen import Plugin
+
+    class PluginX(Plugin):
+        pass
+    """
+    y_content = """
+    from ariadne_codegen.plugins.base import Plugin
+
+    class PluginY(Plugin):
+        pass
+    """
+    module_xyz_path.joinpath("__init__.py").write_text(dedent(init_xyz_content))
+    module_xyz_path.joinpath("x.py").write_text(dedent(x_content))
+    module_xyz_path.joinpath("y.py").write_text(dedent(y_content))
+
+    module_abc_path = create_module_dir(tmp_path_cwd, "module_abc")
+    a_content = """
+    from ariadne_codegen import Plugin
+
+    class PluginA(Plugin):
+        pass
+    """
+    b_content = """
+    from ariadne_codegen.plugins.base import Plugin
+
+    class PluginB(Plugin):
+        pass
+    """
+    module_abc_path.joinpath("a.py").write_text(dedent(a_content))
+    module_abc_path.joinpath("b.py").write_text(dedent(b_content))
+
+    types = get_plugins_types(
+        ["module_xyz", "module_abc.a.PluginA", "module_abc.b.PluginB"]
+    )
+
+    assert len(types) == 4
+    assert isinstance(types[0], type)
+    assert isinstance(types[1], type)
+    assert isinstance(types[2], type)
+    assert isinstance(types[3], type)
+    assert {
+        "module_abc.a.PluginA",
+        "module_abc.b.PluginB",
+        "module_xyz.x.PluginX",
+        "module_xyz.y.PluginY",
+    } == {f"{c.__module__}.{c.__name__}" for c in types}
+
+
+def test_is_module_str_returns_true_if_string_points_to_existing_module(tmp_path_cwd):
+    module_str = "test_a.sub_b.sub_c"
+    create_module_dir(tmp_path_cwd, module_str)
+
+    assert is_module_str(module_str)
+
+
+def test_is_module_str_returns_false_if_string_points_to_specific_file(tmp_path_cwd):
+    module_str = "test_a.sub_b.sub_c"
+    module_path = create_module_dir(tmp_path_cwd, module_str)
+    module_path.joinpath("abcd.py").touch()
+
+    assert not is_module_str(module_str + ".abcd.py")
+
+
+def test_get_plugins_types_from_module_returns_all_public_plugins(tmp_path_cwd):
+    module_str = "module_ab"
+    module_path = create_module_dir(tmp_path_cwd, module_str)
+    init_content = """
+    from .a import PluginA
+    from .b import PluginB
+
+    __all__ = ["PluginA", "PluginB"]
+    """
+    a_content = """
+    from ariadne_codegen import Plugin
+
+    class PluginA(Plugin):
+        pass
+    """
+    b_content = """
+    from ariadne_codegen.plugins.base import Plugin
+
+    class PluginB(Plugin):
+        pass
+    """
+    module_path.joinpath("__init__.py").write_text(dedent(init_content))
+    module_path.joinpath("a.py").write_text(dedent(a_content))
+    module_path.joinpath("b.py").write_text(dedent(b_content))
+
+    types = get_plugins_types_from_module(module_str)
+
+    assert len(types) == 2
+    assert isinstance(types[0], type)
+    assert isinstance(types[1], type)
+    assert {
+        f"{module_str}.a.PluginA",
+        f"{module_str}.b.PluginB",
+    } == {f"{c.__module__}.{c.__name__}" for c in types}
+
+
+def test_is_plugin_type_returns_false_for_not_class():
+    assert not is_plugin_type(lambda: None)
+
+
+def test_is_plugin_type_returns_false_for_base_plugin_class():
+    assert not is_plugin_type(Plugin)
+
+
+def test_is_plugin_type_returns_false_for_class_not_inheriting_from_base_plugin():
+    class InvalidPlugin:
+        pass
+
+    assert not is_plugin_type(InvalidPlugin)
+
+
+def test_is_plugin_type_returns_true_for_class_inheriting_from_base_plugin():
+    class ValidPlugin(Plugin):
+        pass
+
+    assert is_plugin_type(ValidPlugin)
+
+
+def test_get_plugin_type_returns_plugin_class(tmp_path_cwd):
+    module_path = create_module_dir(tmp_path_cwd, "test_module")
+    file_content = """
+    from ariadne_codegen import Plugin
+
+    class TestPlugin(Plugin):
+        pass
+    """
+    module_path.joinpath("xyz.py").write_text(dedent(file_content))
+
+    test_plugin = get_plugin_type("test_module.xyz.TestPlugin")
+
+    assert isinstance(test_plugin, type)
+    assert test_plugin.__name__ == "TestPlugin"
+    assert test_plugin.__module__ == "test_module.xyz"
+
+
+def test_get_plugin_type_raises_plugin_import_error_for_class_without_module():
+    with pytest.raises(PluginImportError):
+        get_plugin_type("TestPlugin")
+
+
+def test_get_plugin_type_raises_plugin_import_error_for_not_existing_module():
+    with pytest.raises(PluginImportError):
+        get_plugin_type("test_module.TestPlugin")
+
+
+def test_get_plugin_type_raises_plugin_import_error_for_not_existing_class(
+    tmp_path_cwd,
+):
+    module_str = "test_module"
+    create_module_dir(tmp_path_cwd, module_str)
+
+    with pytest.raises(PluginImportError):
+        get_plugin_type(f"{module_str}.TestPlugin")
+
+
+@pytest.mark.parametrize(
+    "file_content,object_name",
+    [
+        ("class Test:\n    pass", "Test"),
+        ("def test():\n    pass", "test"),
+        ("from ariadne_codegen import Plugin", "Plugin"),
+    ],
+)
+def test_get_plugin_type_raises_plugin_import_error_for_not_plugin(
+    tmp_path_cwd, file_content, object_name
+):
+    module_str = "test_module"
+    module_path = create_module_dir(tmp_path_cwd, module_str)
+    module_path.joinpath("xyz.py").write_text(file_content)
+
+    with pytest.raises(PluginImportError):
+        get_plugin_type(f"{module_str}.xyz.{object_name}")
diff --git a/tests/plugins/test_manager.py b/tests/plugins/test_manager.py
new file mode 100644
index 0000000..51d644b
--- /dev/null
+++ b/tests/plugins/test_manager.py
@@ -0,0 +1,30 @@
+import ast
+
+from graphql import GraphQLSchema
+
+from ariadne_codegen.plugins.base import Plugin
+from ariadne_codegen.plugins.manager import PluginManager
+
+
+def test_init_creates_plugins_instances():
+    class TestPlugin(Plugin):
+        pass
+
+    manager = PluginManager(schema=GraphQLSchema(), plugins_types=[TestPlugin])
+
+    assert len(manager.plugins) == 1
+    assert isinstance(manager.plugins[0], TestPlugin)
+
+
+def test_generate_init_module_calls_all_plugins(mocker):
+    mocked_plugin_class1 = mocker.MagicMock()
+    mocked_plugin_class2 = mocker.MagicMock()
+    manager = PluginManager(
+        schema=GraphQLSchema(),
+        plugins_types=[mocked_plugin_class1, mocked_plugin_class2],
+    )
+
+    manager.generate_init_module(ast.Module(body=[]))
+
+    assert manager.plugins[0].generate_init_module.called
+    assert manager.plugins[1].generate_init_module.called
diff --git a/tests/test_config.py b/tests/test_config.py
index de9dbbe..4ca672f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,68 +1,30 @@
+from textwrap import dedent
+
 import pytest
 
-from ariadne_codegen.config import Settings, get_config_file_path, parse_config_file
+from ariadne_codegen.config import (
+    Settings,
+    get_config_dict,
+    get_config_file_path,
+    parse_config_dict,
+)
 from ariadne_codegen.exceptions import ConfigFileNotFound, MissingConfiguration
-
-SCHEMA_FILENAME = "schema.graphql"
-QUERIES_DIR = "queries"
+from ariadne_codegen.generators.scalars import ScalarData
 
 
 @pytest.fixture
 def config_file(tmp_path_factory):
     file_ = tmp_path_factory.mktemp("project").joinpath("pyproject.toml")
-    schema_path = file_.parent.joinpath(SCHEMA_FILENAME)
+    schema_path = file_.parent.joinpath("schema.graphql")
     schema_path.touch()
-    queries_path = file_.parent.joinpath(QUERIES_DIR)
+    queries_path = file_.parent.joinpath("queries")
     queries_path.mkdir()
-    config = "\n".join(
-        [
-            "[ariadne-codegen]",
-            f'schema_path = "{schema_path.as_posix()}"',
-            f'queries_path = "{queries_path.as_posix()}"',
-            "[ariadne-codegen.scalars.ID]",
-            'type = "str"',
-            'parse = "parse_id"',
-            'serialize = "serialize_id"',
-            'import = ".custom_scalars"',
-        ]
-    )
-    file_.write_text(config, encoding="utf-8")
-    return file_
-
-
-@pytest.fixture
-def config_file_invalid_section(tmp_path_factory):
-    file_ = tmp_path_factory.mktemp("project").joinpath("pyproject.toml")
-    config = '[invalid-section]\nschema_path = "."'
-    file_.write_text(config, encoding="utf-8")
-    return file_
-
-
-@pytest.fixture
-def config_file_without_field(tmp_path_factory):
-    file_ = tmp_path_factory.mktemp("project").joinpath("pyproject.toml")
-    config = '[ariadne-codegen]\ninvalid_field = "."'
-    file_.write_text(config, encoding="utf-8")
-    return file_
-
-
-@pytest.fixture
-def config_file_invalid_scalar(tmp_path_factory):
-    file_ = tmp_path_factory.mktemp("project").joinpath("pyproject.toml")
-    schema_path = file_.parent.joinpath(SCHEMA_FILENAME)
-    schema_path.touch()
-    queries_path = file_.parent.joinpath(QUERIES_DIR)
-    queries_path.mkdir()
-    config = "\n".join(
-        [
-            "[ariadne-codegen]",
-            f'schema_path = "{schema_path.as_posix()}"',
-            f'queries_path = "{queries_path.as_posix()}"',
-            "[ariadne-codegen.scalars.ID]",
-            'invalid_key = "str"',
-        ]
-    )
-    file_.write_text(config, encoding="utf-8")
+    config = """
+        [ariadne-codegen]
+        schema_path = "schema.graphql"
+        queries_path = "queries"
+    """
+    file_.write_text(dedent(config), encoding="utf-8")
     return file_
 
 
@@ -89,32 +51,76 @@ def test_get_config_file_path_raises_config_file_not_found_exception_if_file_not
         get_config_file_path("invalid.toml")
 
 
-def test_parse_config_file_returns_settings_object(config_file, mocker):
+def test_get_config_dict_returns_file_content_as_dict(config_file, mocker):
     mock_cwd(mocker, config_file.parent)
-    settings = parse_config_file(config_file)
+
+    config_dict = get_config_dict()
+
+    assert isinstance(config_dict, dict)
+
+
+def test_parse_config_dict_returns_settings_object(tmp_path):
+    schema_path = tmp_path / "schema.graphql"
+    schema_path.touch()
+    queries_path = tmp_path / "queries.graphql"
+    queries_path.touch()
+    config_dict = {
+        "ariadne-codegen": {
+            "schema_path": schema_path.as_posix(),
+            "queries_path": queries_path.as_posix(),
+            "scalars": {
+                "ID": {
+                    "type": "str",
+                    "parse": "parse_id",
+                    "serialize": "serialize_id",
+                    "import": ".custom_scalars",
+                }
+            },
+        }
+    }
+    settings = parse_config_dict(config_dict)
+
     assert isinstance(settings, Settings)
-    assert settings.schema_path.endswith(SCHEMA_FILENAME)
+    assert settings.schema_path == schema_path.as_posix()
+    assert settings.queries_path == queries_path.as_posix()
+    assert settings.scalars == {
+        "ID": ScalarData(
+            type_="str",
+            serialize="serialize_id",
+            parse="parse_id",
+            import_=".custom_scalars",
+        )
+    }
 
 
-def test_parse_config_file_without_section_raises_missing_configuration_exception(
-    config_file_invalid_section, mocker
-):
-    mock_cwd(mocker, config_file_invalid_section.parent)
+def test_parse_config_dict_without_section_raises_missing_configuration_exception():
+    config_dict = {"invalid-section": {"schema_path": "."}}
+
     with pytest.raises(MissingConfiguration):
-        parse_config_file(config_file_invalid_section)
+        parse_config_dict(config_dict)
 
 
-def test_parse_config_file_without_needed_field_raises_missing_configuration_exception(
-    config_file_without_field, mocker
-):
-    mock_cwd(mocker, config_file_without_field.parent)
+def test_parse_config_dict_without_field_raises_missing_configuration_exception():
+    config_dict = {"ariadne-codegen": {"invalid_field": "."}}
+
     with pytest.raises(MissingConfiguration):
-        parse_config_file(config_file_without_field)
+        parse_config_dict(config_dict)
 
 
-def test_parse_config_file_with_invalid_scalar_section_raises_missing_configuration(
-    config_file_invalid_scalar, mocker
+def test_parse_config_dict_with_invalid_scalar_section_raises_missing_configuration(
+    tmp_path,
 ):
-    mock_cwd(mocker, config_file_invalid_scalar.parent)
+    schema_path = tmp_path / "schema.graphql"
+    schema_path.touch()
+    queries_path = tmp_path / "queries.graphql"
+    queries_path.touch()
+    config_dict = {
+        "ariadne-codegen": {
+            "schema_path": schema_path.as_posix(),
+            "queries_path": queries_path.as_posix(),
+            "scalars": {"ID": {"invalid_key": "str"}},
+        }
+    }
+
     with pytest.raises(MissingConfiguration):
-        parse_config_file(config_file_invalid_scalar)
+        parse_config_dict(config_dict)