Repository: tefra/xsdata

Issue:
Ruff in the code writer interferes with plugins that don't produce python code
Linked issue
https://github.com/tefra/xsdata-plantuml/issues/7

Discussion:

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index b42491dc..b76442cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,7 +51,7 @@ cli = [
     "docformatter>=1.7.2",
     "jinja2>=2.10",
     "toposort>=1.5",
-    "ruff>=0.1.9"
+    "ruff>=0.3.0"
 ]
 docs = [
     "mkdocs",
diff --git a/xsdata/codegen/writer.py b/xsdata/codegen/writer.py
index 8c8af536..cdae487a 100644
--- a/xsdata/codegen/writer.py
+++ b/xsdata/codegen/writer.py
@@ -1,6 +1,3 @@
-import subprocess
-from pathlib import Path
-from textwrap import indent
 from typing import ClassVar, Dict, List, Type
 
 from xsdata.codegen.models import Class
@@ -46,7 +43,7 @@ class CodeWriter:
         for result in self.generator.render(classes):
             if result.source.strip():
                 logger.info("Generating package: %s", result.title)
-                src_code = self.ruff_code(header + result.source, result.path)
+                src_code = header + result.source
                 result.path.parent.mkdir(parents=True, exist_ok=True)
                 result.path.write_text(src_code, encoding="utf-8")
 
@@ -60,7 +57,7 @@ class CodeWriter:
         header = self.generator.render_header()
         for result in self.generator.render(classes):
             if result.source.strip():
-                src_code = self.ruff_code(header + result.source, result.path)
+                src_code = header + result.source
                 print(src_code, end="")
 
     @classmethod
@@ -101,39 +98,3 @@ class CodeWriter:
             name: The generator name
         """
         cls.generators.pop(name)
-
-    def ruff_code(self, src_code: str, file_path: Path) -> str:
-        """Run ruff format on the src code.
-
-        Args:
-            src_code: The output source code
-            file_path: The file path the source code will be written to
-
-        Returns:
-            The formatted output source code
-        """
-        commands = [
-            [
-                "ruff",
-                "format",
-                "--stdin-filename",
-                str(file_path),
-                "--line-length",
-                str(self.generator.config.output.max_line_length),
-            ],
-        ]
-        try:
-            src_code_encoded = src_code.encode()
-            for command in commands:
-                result = subprocess.run(
-                    command,
-                    input=src_code_encoded,
-                    capture_output=True,
-                    check=True,
-                )
-                src_code_encoded = result.stdout
-
-            return src_code_encoded.decode()
-        except subprocess.CalledProcessError as e:
-            error = indent(e.stderr.decode(), "  ")
-            raise CodeGenerationError(f"Ruff failed:\n{error}")
diff --git a/xsdata/formats/dataclass/generator.py b/xsdata/formats/dataclass/generator.py
index ffd9ea48..43884944 100644
--- a/xsdata/formats/dataclass/generator.py
+++ b/xsdata/formats/dataclass/generator.py
@@ -1,10 +1,13 @@
+import subprocess
 from pathlib import Path
+from textwrap import indent
 from typing import Iterator, List, Optional
 
 from jinja2 import Environment, FileSystemLoader
 
 from xsdata.codegen.models import Class, Import
 from xsdata.codegen.resolver import DependenciesResolver
+from xsdata.exceptions import CodeGenerationError
 from xsdata.formats.dataclass.filters import Filters
 from xsdata.formats.mixins import AbstractGenerator, GeneratorResult
 from xsdata.models.config import GeneratorConfig
@@ -57,27 +60,34 @@ class DataclassGenerator(AbstractGenerator):
         # Generate packages
         for path, cluster in self.group_by_package(classes).items():
             module = ".".join(path.relative_to(Path.cwd()).parts)
+            package_path = path.joinpath("__init__.py")
+            src_code = self.render_package(cluster, module, package_path)
+
             yield GeneratorResult(
-                path=path.joinpath("__init__.py"),
+                path=package_path,
                 title="init",
-                source=self.render_package(cluster, module),
+                source=src_code,
             )
             yield from self.ensure_packages(path.parent)
 
         # Generate modules
         for path, cluster in self.group_by_module(classes).items():
+            module_path = path.with_suffix(".py")
+            src_code = self.render_module(resolver, cluster, module_path)
+
             yield GeneratorResult(
-                path=path.with_suffix(".py"),
+                path=module_path,
                 title=cluster[0].target_module,
-                source=self.render_module(resolver, cluster),
+                source=src_code,
             )
 
-    def render_package(self, classes: List[Class], module: str) -> str:
+    def render_package(self, classes: List[Class], module: str, filename: Path) -> str:
         """Render the package for the given classes.
 
         Args:
             classes: A list of class instances
             module: The target dot notation path
+            filename: The package path
 
         Returns:
             The rendered package output.
@@ -92,18 +102,20 @@ class DataclassGenerator(AbstractGenerator):
             imports=imports,
             module=module,
         )
-        return f"{output.strip()}\n"
+        return self.ruff_code(output, filename)
 
     def render_module(
         self,
         resolver: DependenciesResolver,
         classes: List[Class],
+        filename: Path,
     ) -> str:
         """Render the module for the given classes.
 
         Args:
             resolver: The dependencies resolver
             classes: A list of class instances
+            filename: The module path
 
         Returns:
             The rendered module output.
@@ -119,7 +131,7 @@ class DataclassGenerator(AbstractGenerator):
         output = self.render_classes(classes, module_namespace)
         module = classes[0].target_module
 
-        return self.env.get_template(self.module_template).render(
+        result = self.env.get_template(self.module_template).render(
             output=output,
             classes=classes,
             module=module,
@@ -127,6 +139,8 @@ class DataclassGenerator(AbstractGenerator):
             namespace=module_namespace,
         )
 
+        return self.ruff_code(result, filename)
+
     def render_classes(
         self,
         classes: List[Class],
@@ -160,7 +174,7 @@ class DataclassGenerator(AbstractGenerator):
                 .strip()
             )
 
-        return "\n\n\n".join(map(render_class, classes)) + "\n"
+        return "\n".join(map(render_class, classes))
 
     def module_name(self, name: str) -> str:
         """Convert the given module name to safe snake case."""
@@ -193,3 +207,39 @@ class DataclassGenerator(AbstractGenerator):
     def init_filters(cls, config: GeneratorConfig) -> Filters:
         """Initialize the filters instance by the generator configuration."""
         return Filters(config)
+
+    def ruff_code(self, src_code: str, file_path: Path) -> str:
+        """Run ruff format on the src code.
+
+        Args:
+            src_code: The output source code
+            file_path: The file path the source code will be written to
+
+        Returns:
+            The formatted output source code
+        """
+        commands = [
+            [
+                "ruff",
+                "format",
+                "--stdin-filename",
+                str(file_path),
+                "--line-length",
+                str(self.config.output.max_line_length),
+            ],
+        ]
+        try:
+            src_code_encoded = src_code.encode()
+            for command in commands:
+                result = subprocess.run(
+                    command,
+                    input=src_code_encoded,
+                    capture_output=True,
+                    check=True,
+                )
+                src_code_encoded = result.stdout
+
+            return src_code_encoded.decode()
+        except subprocess.CalledProcessError as e:
+            error = indent(e.stderr.decode(), "  ")
+            raise CodeGenerationError(f"Ruff failed:\n{error}")


Test patch:
diff --git a/tests/codegen/test_writer.py b/tests/codegen/test_writer.py
index e532740a..38d3a315 100644
--- a/tests/codegen/test_writer.py
+++ b/tests/codegen/test_writer.py
@@ -23,15 +23,11 @@ class CodeWriterTests(FactoryTestCase):
         generator = NoneGenerator(config)
         self.writer = CodeWriter(generator)
 
-    @mock.patch.object(CodeWriter, "ruff_code")
     @mock.patch.object(NoneGenerator, "render_header")
     @mock.patch.object(NoneGenerator, "render")
     @mock.patch.object(NoneGenerator, "normalize_packages")
-    def test_write(
-        self, mock_normalize_packages, mock_render, mock_render_header, mock_ruff_code
-    ):
+    def test_write(self, mock_normalize_packages, mock_render, mock_render_header):
         classes = ClassFactory.list(2)
-        mock_ruff_code.side_effect = lambda x, y: x
         with TemporaryDirectory() as tmpdir:
             mock_render.return_value = [
                 GeneratorResult(Path(f"{tmpdir}/foo/a.py"), "file", "aAa"),
@@ -46,7 +42,6 @@ class CodeWriterTests(FactoryTestCase):
             self.assertFalse(Path(f"{tmpdir}/c.py").exists())
             mock_normalize_packages.assert_called_once_with(classes)
 
-    @mock.patch.object(CodeWriter, "ruff_code")
     @mock.patch.object(NoneGenerator, "render_header")
     @mock.patch.object(NoneGenerator, "render")
     @mock.patch.object(NoneGenerator, "normalize_packages")
@@ -57,7 +52,6 @@ class CodeWriterTests(FactoryTestCase):
         mock_normalize_packages,
         mock_render,
         mock_render_header,
-        mock_ruff_code,
     ):
         classes = ClassFactory.list(2)
         mock_render.return_value = [
@@ -66,7 +60,6 @@ class CodeWriterTests(FactoryTestCase):
             GeneratorResult(Path("c.py"), "file", ""),
         ]
         mock_render_header.return_value = "# H\n"
-        mock_ruff_code.side_effect = lambda x, y: x
 
         self.writer.print(classes)
 
@@ -87,42 +80,3 @@ class CodeWriterTests(FactoryTestCase):
         CodeWriter.register_generator("dataclasses", DataclassGenerator)
         writer = CodeWriter.from_config(config)
         self.assertIsInstance(writer.generator, DataclassGenerator)
-
-    def test_ruff_code(self):
-        src_code = (
-            "\n"
-            "import sys\n"
-            "@dataclass\n"
-            "\n"
-            "class MyType:\n"
-            "\n"
-            '    value: Optional[str] = field(default=None, metadata={"type": "Element", "required": True})\n'
-            "\n"
-            "\n"
-            "        "
-        )
-
-        self.writer.generator.config.output.max_line_length = 55
-        actual = self.writer.ruff_code(src_code, Path(__file__))
-        expected = (
-            "import sys\n"
-            "\n"
-            "\n"
-            "@dataclass\n"
-            "class MyType:\n"
-            "    value: Optional[str] = field(\n"
-            "        default=None,\n"
-            '        metadata={"type": "Element", "required": True},\n'
-            "    )\n"
-        )
-        self.assertEqual(expected, actual)
-
-    def test_format_with_invalid_code(self):
-        src_code = """a = "1"""
-        file_path = Path(__file__)
-
-        self.writer.generator.config.output.max_line_length = 55
-        with self.assertRaises(CodeGenerationError) as cm:
-            self.writer.ruff_code(src_code, file_path)
-
-        self.assertIn("Ruff failed", str(cm.exception))
diff --git a/tests/formats/dataclass/test_generator.py b/tests/formats/dataclass/test_generator.py
index ffcfe4c5..bae7df8d 100644
--- a/tests/formats/dataclass/test_generator.py
+++ b/tests/formats/dataclass/test_generator.py
@@ -3,6 +3,7 @@ from pathlib import Path
 from unittest import mock
 
 from xsdata.codegen.resolver import DependenciesResolver
+from xsdata.exceptions import CodeGenerationError
 from xsdata.formats.dataclass.generator import DataclassGenerator
 from xsdata.models.config import GeneratorConfig
 from xsdata.utils.testing import ClassFactory, FactoryTestCase
@@ -45,12 +46,16 @@ class DataclassGeneratorTests(FactoryTestCase):
         self.assertEqual(expected, actual)
         mock_render_package.assert_has_calls(
             [
-                mock.call([classes[0]], "foo.bar"),
-                mock.call([classes[1]], "bar.foo"),
-                mock.call([classes[2]], "thug.life"),
+                mock.call([classes[0]], "foo.bar", cwd.joinpath("foo/bar/__init__.py")),
+                mock.call([classes[1]], "bar.foo", cwd.joinpath("bar/foo/__init__.py")),
+                mock.call(
+                    [classes[2]], "thug.life", cwd.joinpath("thug/life/__init__.py")
+                ),
             ]
         )
-        mock_render_module.assert_has_calls([mock.call(mock.ANY, [x]) for x in classes])
+        mock_render_module.assert_has_calls(
+            [mock.call(mock.ANY, [x], mock.ANY) for x in classes]
+        )
 
     def test_render_package(self):
         classes = [
@@ -62,7 +67,7 @@ class DataclassGeneratorTests(FactoryTestCase):
 
         random.shuffle(classes)
 
-        actual = self.generator.render_package(classes, "foo.tests")
+        actual = self.generator.render_package(classes, "foo.tests", Path.cwd())
         expected = "\n".join(
             [
                 "from foo.bar import A as BarA",
@@ -94,7 +99,7 @@ class DataclassGeneratorTests(FactoryTestCase):
 
         resolver = DependenciesResolver({})
 
-        actual = self.generator.render_module(resolver, classes)
+        actual = self.generator.render_module(resolver, classes, Path.cwd())
         expected = (
             "from dataclasses import dataclass, field\n"
             "from enum import Enum\n"
@@ -110,6 +115,7 @@ class DataclassGeneratorTests(FactoryTestCase):
             "    :cvar ATTR_B: I am a member\n"
             "    :cvar ATTR_C:\n"
             '    """\n'
+            "\n"
             "    ATTR_B = None\n"
             "    ATTR_C = None\n"
             "\n"
@@ -120,6 +126,7 @@ class DataclassGeneratorTests(FactoryTestCase):
             "    :ivar attr_d: I am a field\n"
             "    :ivar attr_e:\n"
             '    """\n'
+            "\n"
             "    class Meta:\n"
             '        name = "class_C"\n'
             "\n"
@@ -128,14 +135,14 @@ class DataclassGeneratorTests(FactoryTestCase):
             "        metadata={\n"
             '            "name": "attr_D",\n'
             '            "type": "Element",\n'
-            "        }\n"
+            "        },\n"
             "    )\n"
             "    attr_e: Optional[str] = field(\n"
             "        default=None,\n"
             "        metadata={\n"
             '            "name": "attr_E",\n'
             '            "type": "Element",\n'
-            "        }\n"
+            "        },\n"
             "    )\n"
             "\n"
             "\n"
@@ -153,7 +160,7 @@ class DataclassGeneratorTests(FactoryTestCase):
         ]
         resolver = DependenciesResolver({})
 
-        actual = self.generator.render_module(resolver, classes)
+        actual = self.generator.render_module(resolver, classes, Path.cwd())
         expected = (
             "from dataclasses import dataclass, field\n"
             "from typing import Optional\n"
@@ -170,7 +177,7 @@ class DataclassGeneratorTests(FactoryTestCase):
             "        metadata={\n"
             '            "name": "attr_C",\n'
             '            "type": "Element",\n'
-            "        }\n"
+            "        },\n"
             "    )\n"
             "\n"
             "\n"
@@ -185,7 +192,7 @@ class DataclassGeneratorTests(FactoryTestCase):
             "        metadata={\n"
             '            "name": "attr_B",\n'
             '            "type": "Element",\n'
-            "        }\n"
+            "        },\n"
             "    )\n"
         )
 
@@ -205,3 +212,13 @@ class DataclassGeneratorTests(FactoryTestCase):
         )
 
         self.assertEqual("", self.generator.package_name(""))
+
+    def test_format_with_invalid_code(self):
+        src_code = """a = "1"""
+        file_path = Path(__file__)
+
+        self.generator.config.output.max_line_length = 55
+        with self.assertRaises(CodeGenerationError) as cm:
+            self.generator.ruff_code(src_code, file_path)
+
+        self.assertIn("Ruff failed", str(cm.exception))