Repository: altwalker/altwalker (language: python)

Issue:
The init command should generate a gitignore file

The init command should generate a requirements.txt file 



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 9fa7c48..3f28f10 100644
--- a/README.md
+++ b/README.md
@@ -277,4 +277,4 @@ Join our [Gitter chat room](https://gitter.im/altwalker/community) or our [Googl
 
 ## License
 
-This project is licensed under the [GNU General Public License v3.0](LICENSE).
+This project is licensed under the [GNU General Public License v3.0](https://github.com/altwalker/altwalker/blob/main/LICENSE).
diff --git a/altwalker/__init__.py b/altwalker/__init__.py
index e88bcb4..e69de29 100644
--- a/altwalker/__init__.py
+++ b/altwalker/__init__.py
@@ -1,1 +0,0 @@
-# Test for GitHub Actions
diff --git a/altwalker/_cli.py b/altwalker/_cli.py
index ee242b0..813aa7c 100644
--- a/altwalker/_cli.py
+++ b/altwalker/_cli.py
@@ -102,10 +102,10 @@ def _echo_suggestions(language, methods, missing_methods):
             break
         elif elements == methods[model]:
             click.secho("# Append the following class to your test file.\n")
-            click.secho(generate_class(language, model, elements), fg="cyan")
+            click.secho(generate_class(model, elements, language=language), fg="cyan")
         else:
             click.secho("# Append the following methods to your '{}' class.\n".format(model))
-            click.secho(generate_methods(language, elements), fg="cyan")
+            click.secho(generate_methods(elements, language=language), fg="cyan")
 
 
 @handle_errors
diff --git a/altwalker/_utils.py b/altwalker/_utils.py
index 75ff1a7..589bae4 100644
--- a/altwalker/_utils.py
+++ b/altwalker/_utils.py
@@ -3,9 +3,16 @@
 import platform
 import subprocess
 
+import pkg_resources
 import psutil
 
 
+def get_resource(path):
+    """Return the content of a file from that is included in the package resources."""
+
+    return pkg_resources.resource_string(__name__, path).decode("utf-8")
+
+
 def url_join(base, url):
     """Join a base with an url."""
 
@@ -31,7 +38,7 @@ def execute_command(command, timeout=None):
     command = prefix_command(command)
 
     process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    output, error = process.communicate(timeout=None)
+    output, error = process.communicate(timeout=timeout)
 
     process.terminate()
     process.wait()
@@ -43,7 +50,7 @@ def has_command(command, timeout=None):
     """Returns True if it can run the command, otherwise returns False."""
 
     try:
-        response = execute_command(command, timeout=None)
+        response = execute_command(command, timeout=timeout)
         return response[1] == b""
     except (FileNotFoundError, subprocess.TimeoutExpired):
         return False
@@ -52,7 +59,21 @@ def has_command(command, timeout=None):
 def has_git(timeout=None):
     """Returns True if it can run ``git --version``, otherwise returns False."""
 
-    return has_command(["git", "--version"], timeout=None)
+    return has_command(["git", "--version"], timeout=timeout)
+
+
+class Factory:
+    """Factory class for creating other objects without having to specify the exact class."""
+
+    def __init__(self, map, default=None):
+        self.map = map
+        self.default = default
+
+    def keys(self):
+        return self.map.keys()
+
+    def get(self, key):
+        return self.map.get(key, self.default)
 
 
 class Command:
diff --git a/altwalker/cli.py b/altwalker/cli.py
index 5b44da8..d775746 100644
--- a/altwalker/cli.py
+++ b/altwalker/cli.py
@@ -5,7 +5,7 @@
 import click
 from click_help_colors import HelpColorsGroup, HelpColorsCommand
 
-from altwalker.generate import SUPPORTED_LANGUAGES
+from altwalker.generate import get_supported_languages
 from altwalker.executor import SUPPORTED_EXECUTORS
 from altwalker._cli import click_formatwarning, cli_init, cli_generate, cli_check, cli_verify, \
     cli_offline, cli_online, cli_walk
@@ -52,7 +52,7 @@
     help="Will filter out elements with the blocked property.")
 
 language_option = click.option(
-    "--language", "-l", type=click.Choice(SUPPORTED_LANGUAGES, case_sensitive=False),
+    "--language", "-l", type=click.Choice(get_supported_languages(), case_sensitive=False),
     help="Configure the programming language of the tests.")
 
 executor_option = click.option(
diff --git a/altwalker/data/models/default.json b/altwalker/data/models/default.json
new file mode 100644
index 0000000..5938829
--- /dev/null
+++ b/altwalker/data/models/default.json
@@ -0,0 +1,28 @@
+{
+    "name": "Default model",
+    "models": [
+        {
+            "name": "ModelName",
+            "generator": "random(edge_coverage(100) && vertex_coverage(100))",
+            "startElementId": "v0",
+            "vertices": [
+                {
+                    "id": "v0",
+                    "name": "vertex_A"
+                },
+                {
+                    "id": "v1",
+                    "name": "vertex_B"
+                }
+            ],
+            "edges": [
+                {
+                    "id": "e0",
+                    "name": "edge_A",
+                    "sourceVertexId": "v0",
+                    "targetVertexId": "v1"
+                }
+            ]
+        }
+    ]
+}
diff --git a/altwalker/data/templates/generate/dotnet/class.jinja b/altwalker/data/templates/generate/dotnet/class.jinja
new file mode 100644
index 0000000..5043a6c
--- /dev/null
+++ b/altwalker/data/templates/generate/dotnet/class.jinja
@@ -0,0 +1,10 @@
+    public class {{ class_name }}
+    {
+
+{% for method in methods %}
+        public void {{ method }}()
+        {
+        }
+
+{% endfor %}
+    }
diff --git a/altwalker/data/templates/generate/dotnet/code.jinja b/altwalker/data/templates/generate/dotnet/code.jinja
new file mode 100644
index 0000000..d183ae7
--- /dev/null
+++ b/altwalker/data/templates/generate/dotnet/code.jinja
@@ -0,0 +1,30 @@
+using Altom.AltWalker;
+
+namespace {{ namespace }}
+{
+
+{% for class_name, methods in classes %}
+    public class {{ class_name }}
+    {
+
+{% for method in methods %}
+        public void {{ method }}()
+        {
+        }
+
+{% endfor %}
+    }
+
+{% endfor %}
+    public class Program
+    {
+        public static void Main(string[] args)
+        {
+            ExecutorService service = new ExecutorService();
+{% for class_name, methods in classes %}
+            service.RegisterModel<{{ class_name }}>();
+{% endfor %}
+            service.Run(args);
+        }
+    }
+}
diff --git a/altwalker/data/templates/generate/dotnet/csproj.jinja b/altwalker/data/templates/generate/dotnet/csproj.jinja
new file mode 100644
index 0000000..4a12054
--- /dev/null
+++ b/altwalker/data/templates/generate/dotnet/csproj.jinja
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>netcoreapp2.1</TargetFramework>
+    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
+    <RootNamespace>{}</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="AltWalker.Executor" Version="{{ version }}" />
+  </ItemGroup>
+</Project>
diff --git a/altwalker/data/templates/generate/dotnet/methods.jinja b/altwalker/data/templates/generate/dotnet/methods.jinja
new file mode 100644
index 0000000..a6f9a71
--- /dev/null
+++ b/altwalker/data/templates/generate/dotnet/methods.jinja
@@ -0,0 +1,6 @@
+{% for method in methods %}
+
+        public void {{ method }}()
+        {
+        }
+{% endfor %}
diff --git a/altwalker/data/templates/generate/gitignore/base.txt b/altwalker/data/templates/generate/gitignore/base.txt
new file mode 100644
index 0000000..694d050
--- /dev/null
+++ b/altwalker/data/templates/generate/gitignore/base.txt
@@ -0,0 +1,10 @@
+### AltWalker ###
+
+# Logs
+altwalker.log
+graphwalker-service.log
+report.xml
+
+# Operating system-related files
+*.DS_Store
+Thumbs.db
diff --git a/altwalker/data/templates/generate/gitignore/dotnet.txt b/altwalker/data/templates/generate/gitignore/dotnet.txt
new file mode 100644
index 0000000..68179b6
--- /dev/null
+++ b/altwalker/data/templates/generate/gitignore/dotnet.txt
@@ -0,0 +1,8 @@
+### Dotnet ###
+
+# Build results
+[Dd]ebug/
+[Rr]elease/
+x64/
+[Bb]in/
+[Oo]bj/
diff --git a/altwalker/data/templates/generate/gitignore/python.txt b/altwalker/data/templates/generate/gitignore/python.txt
new file mode 100644
index 0000000..cea30ab
--- /dev/null
+++ b/altwalker/data/templates/generate/gitignore/python.txt
@@ -0,0 +1,38 @@
+### Python ###
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# Environments
+.env/
+.venv/
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
diff --git a/altwalker/data/templates/generate/python/class.jinja b/altwalker/data/templates/generate/python/class.jinja
new file mode 100644
index 0000000..278d43e
--- /dev/null
+++ b/altwalker/data/templates/generate/python/class.jinja
@@ -0,0 +1,7 @@
+class {{ class_name }}:
+
+{% for method in methods %}
+    def {{ method }}(self):
+        pass
+
+{% endfor %}
diff --git a/altwalker/data/templates/generate/python/code.jinja b/altwalker/data/templates/generate/python/code.jinja
new file mode 100644
index 0000000..58bbb6f
--- /dev/null
+++ b/altwalker/data/templates/generate/python/code.jinja
@@ -0,0 +1,10 @@
+{% for class_name, methods in classes %}
+class {{ class_name }}:
+
+{% for method in methods %}
+    def {{ method }}(self):
+        pass
+
+{% endfor %}
+
+{% endfor %}
diff --git a/altwalker/data/templates/generate/python/methods.jinja b/altwalker/data/templates/generate/python/methods.jinja
new file mode 100644
index 0000000..eeb4c75
--- /dev/null
+++ b/altwalker/data/templates/generate/python/methods.jinja
@@ -0,0 +1,4 @@
+{% for method in methods %}
+def {{ method }}(self):
+    pass
+{% endfor %}
diff --git a/altwalker/generate.py b/altwalker/generate.py
index 0fe56a1..188ae2a 100644
--- a/altwalker/generate.py
+++ b/altwalker/generate.py
@@ -1,114 +1,21 @@
-"""A collection of util functions for generating code form model(s)."""
+"""A collection of util classes and functions for generating code form model(s)."""
 
 import os
 import re
+import abc
 import shutil
 import logging
 import warnings
 
+from jinja2 import Environment
+
 from altwalker.__version__ import VERSION
-from altwalker._utils import has_git
-from altwalker.exceptions import AltWalkerException
+from altwalker._utils import get_resource, has_git, Factory
 from altwalker.code import get_methods
 
 
 logger = logging.getLogger(__name__)
 
-MINOR_VERSION = ".".join(VERSION.split(".")[:2]) + ".*"
-
-_PYTHON_CLASS_TEMPLATE = """\
-class {}:
-
-{}
-"""
-
-_PYTHON_METHOD_TEMPLATE = """\
-    def {}(self):
-        pass
-"""
-
-_DOTNET_NAMESPACE_TEMPLATE = """\
-using Altom.AltWalker;
-
-namespace {}
-{{
-{}
-}}
-"""
-
-_DOTNET_MAIN_TEMPLATE = """\
-    public class Program
-    {{
-
-        public static void Main(string[] args)
-        {{
-            ExecutorService service = new ExecutorService();
-{}
-            service.Run(args);
-        }}
-    }}
-"""
-
-_DOTNET_CLASS_TEMPLATE = """\
-    public class {}
-    {{
-
-{}
-    }}
-"""
-
-_DOTNET_METHOD_TEMPLATE = """\
-        public void {}()
-        {{
-        }}
-"""
-
-_DOTNET_CSPROJ = """\
-<Project Sdk="Microsoft.NET.Sdk">
-  <PropertyGroup>
-    <OutputType>Exe</OutputType>
-    <TargetFramework>netcoreapp2.1</TargetFramework>
-    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
-    <RootNamespace>{}</RootNamespace>
-  </PropertyGroup>
-
-  <ItemGroup>
-    <PackageReference Include="AltWalker.Executor" Version="{}" />
-  </ItemGroup>
-</Project>
-"""
-
-_DEFAULT_MODEL = """\
-{
-    "name": "Default model",
-    "models": [
-        {
-            "name": "ModelName",
-            "generator": "random(edge_coverage(100) && vertex_coverage(100))",
-            "startElementId": "v0",
-            "vertices": [
-                {
-                    "id": "v0",
-                    "name": "vertex_A"
-                },
-                {
-                    "id": "v1",
-                    "name": "vertex_B"
-                }
-            ],
-            "edges": [
-                {
-                    "id": "e0",
-                    "name": "edge_A",
-                    "sourceVertexId": "v0",
-                    "targetVertexId": "v1"
-                }
-            ]
-        }
-    ]
-}
-"""
-
 
 def _normalize_namespace(string):
     """Normalize string for a namespace."""
@@ -119,25 +26,6 @@ def _normalize_namespace(string):
     return namespace.title()
 
 
-def _copy_models(output_dir, model_paths):
-    """Copy all models in ``output_dir``."""
-
-    os.makedirs(output_dir)
-
-    for model_path in model_paths:
-        _, file_name = os.path.split(model_path)
-        shutil.copyfile(model_path, os.path.join(output_dir, file_name))
-
-
-def _create_default_model(output_dir):
-    """Save the default model in ``output_dir``."""
-
-    os.makedirs(output_dir)
-
-    with open(os.path.join(output_dir, "default.json"), "w") as fp:
-        fp.write(_DEFAULT_MODEL)
-
-
 def _git_init(path):
     """Create a local repository and commit all files."""
 
@@ -145,206 +33,301 @@ def _git_init(path):
         from git import Repo
 
         repo = Repo.init(path)
-
         repo.git.add("--all")
-        repo.index.commit("Initial commit")
+        repo.index.commit("Initial commit from AltWalker CLI {}".format(VERSION))
     else:
         warnings.warn("Git is not installed.")
 
 
-def generate_empty_tests(output_dir, methods=None, package_name="tests"):
-    """Generate an empty tests directory."""
+def _render_jinja_template(template, data=None, trim_blocks=True, keep_trailing_newline=False):
+    if not data:
+        data = dict()
 
-    os.makedirs(os.path.join(output_dir, package_name))
+    env = Environment(trim_blocks=trim_blocks, keep_trailing_newline=keep_trailing_newline)
+    template = env.from_string(template)
 
+    return template.render(**data)
 
-def generate_python_methods(methods_list):
-    methods_code = [_PYTHON_METHOD_TEMPLATE.format(name) for name in methods_list]
-    return "\n".join(methods_code)
 
+class Generator(metaclass=abc.ABCMeta):
+    """Abstract base class for generating a new AltWalker project.
 
-def generate_python_class(class_name, methods_list):
-    return _PYTHON_CLASS_TEMPLATE.format(class_name, generate_python_methods(methods_list))
+    Args:
+        output_path (:obj:`str`): The path to the project root.
+        models_path (:obj:`str`): A sequence of paths to model files.
+        git (:obj:`bool`): If set to ``True`` will initialize a git repository and commit the files.
 
+    """
 
-def generate_python_code(methods):
-    """Generate python source code from the ``dict`` of methods.
+    BASE_GITIGNORE = get_resource("data/templates/generate/gitignore/base.txt")
+    DEFAULT_MODEL = get_resource("data/models/default.json")
 
-    Args:
-        methods (:obj:`dict`): The keys are class names and the values are all
-            methods for that class.
+    def __init__(self, output_path, model_paths=None, git=False):
+        self.output_path = output_path
+        self.model_paths = model_paths or []
+        self.git = git
 
-    Returns:
-        str: The python source code.
-    """
+    def __repr__(self):
+        return '{}({!r}, {!r}, {!r})'.format(
+           self.__class__.__name__,
+           self.output_path, self.model_paths, self.git
+        )
+
+    @property
+    def project_name(self):
+        return os.path.basename(self.output_path)
+
+    @property
+    def gitignore(self):
+        return self.BASE_GITIGNORE
+
+    def copy_default_model(self):
+        self.model_paths = [os.path.join(self.output_path, "models", "default.json")]
+        os.makedirs(os.path.join(self.output_path, "models"))
 
-    classes_code = [generate_python_class(class_name, methods_list) for class_name, methods_list in methods.items()]
-    return "\n{}".format("\n\n".join(classes_code))
+        with open(os.path.join(self.output_path, "models/default.json"), "w") as fp:
+            fp.write(self.DEFAULT_MODEL)
 
+    def copy_models(self):
+        os.makedirs(os.path.join(self.output_path, "models"))
 
-def generate_python_tests(output_dir, methods, package_name="tests"):
-    """Generate a python test package from the models."""
+        for model_path in self.model_paths:
+            file_name = os.path.basename(model_path)
+            shutil.copyfile(model_path, os.path.join(self.output_path, "models", file_name))
+
+    def git_init(self):
+        self.generate_gitignore()
+        _git_init(self.output_path)
+
+    def generate_models(self):
+        if self.model_paths:
+            self.copy_models()
+        else:
+            self.copy_default_model()
+
+    def generate_gitignore(self):
+        with open(os.path.join(self.output_path, ".gitignore"), "w") as fp:
+            fp.write(self.gitignore)
+
+    @abc.abstractclassmethod
+    def generate_methods(cls, *args, **kwargs):
+        pass
+
+    @abc.abstractclassmethod
+    def generate_class(cls, *args, **kwargs):
+        pass
 
-    os.makedirs(os.path.join(output_dir, package_name))
+    @abc.abstractclassmethod
+    def generate_code(cls, *args, **kwargs):
+        pass
 
-    with open(os.path.join(output_dir, package_name, "__init__.py"), "w"):
+    @abc.abstractmethod
+    def generate_tests(self, *args, **kwargs):
         pass
 
-    with open(os.path.join(output_dir, package_name, "test.py"), "w") as fp:
-        fp.write(generate_python_code(methods))
+    def init_package(self):
+        self.generate_tests(get_methods(self.model_paths))
+
+    def init_project(self):
+        if os.path.exists(self.output_path):
+            raise FileExistsError("The '{}' directory already exists.".format(self.output_path))
+
+        os.makedirs(self.output_path)
+
+        try:
+            self.generate_models()
+            self.generate_tests(get_methods(self.model_paths))
+
+            if self.git:
+                self.git_init()
+        except Exception as error:
+            logger.exception(error)
+            shutil.rmtree(self.output_path)
+            raise
+
+
+class EmptyGenerator(Generator):
+    """A class for generating an empty AltWalker project."""
+
+    @classmethod
+    def generate_methods(cls, *args, **kwargs):
+        return ""
 
+    @classmethod
+    def generate_class(cls, *args, **kwargs):
+        return ""
 
-def generate_dotnet_methods(methods_list):
-    methods_code = [_DOTNET_METHOD_TEMPLATE.format(name) for name in methods_list]
-    return "\n".join(methods_code)
+    @classmethod
+    def generate_code(cls, *args, **kwargs):
+        return ""
 
+    def generate_tests(self, classes, package_name="tests"):
+        os.makedirs(os.path.join(self.output_path, package_name))
 
-def generate_dotnet_class(class_name, methods_list):
-    return _DOTNET_CLASS_TEMPLATE.format(class_name, generate_dotnet_methods(methods_list))
 
+class PythonGenerator(Generator):
+    """A class for generating an AltWalker project for python."""
 
-def generate_dotnet_code(methods, namespace="Tests"):
-    classes_code = [generate_dotnet_class(class_name, methods_list) for class_name, methods_list in methods.items()]
-    classes_code = "\n{}\n".format("\n\n".join(classes_code))
+    METHODS_TEMPLATE = get_resource("data/templates/generate/python/methods.jinja")
+    CLASS_TEMPLATE = get_resource("data/templates/generate/python/class.jinja")
+    CODE_TEMPLATE = get_resource("data/templates/generate/python/code.jinja")
+    PYTHON_GITIGNORE = get_resource("data/templates/generate/gitignore/python.txt")
 
-    classes_code += _DOTNET_MAIN_TEMPLATE.format(
-        "\n".join(["            service.RegisterModel<{}>();".format(name) for name in methods.keys()])
-    )
+    REQUIREMENTS = [
+        "altwalker"
+    ]
 
-    return _DOTNET_NAMESPACE_TEMPLATE.format(namespace, classes_code)
+    @property
+    def gitignore(self):
+        return "{}\n{}".format(super().gitignore, self.PYTHON_GITIGNORE)
 
+    @classmethod
+    def generate_methods(cls, methods):
+        return _render_jinja_template(
+            cls.METHODS_TEMPLATE,
+            data={"methods": methods},
+            trim_blocks=False
+        )
 
-def generate_dotnet_tests(output_dir, methods, package_name="tests"):
-    """Generate a .NET/C# test package from the models."""
+    @classmethod
+    def generate_class(cls, class_name, methods):
+        return _render_jinja_template(
+            cls.CLASS_TEMPLATE,
+            data={"class_name": class_name, "methods": methods},
+            trim_blocks=True
+        )
 
-    _, project_name = os.path.split(output_dir)
-    namespace = _normalize_namespace(project_name + "." + package_name)
-    project_path = os.path.join(output_dir, package_name)
+    @classmethod
+    def generate_code(cls, classes):
+        return _render_jinja_template(
+            cls.CODE_TEMPLATE,
+            data={"classes": classes.items()},
+            trim_blocks=True
+        )
 
-    os.makedirs(project_path)
+    def generate_requirements(self):
+        with open(os.path.join(self.output_path, "requirements.txt"), "w") as fp:
+            fp.write("\n".join(self.REQUIREMENTS))
 
-    with open(os.path.join(project_path, "{}.csproj".format(package_name)), "w") as fp:
-        fp.write(_DOTNET_CSPROJ.format(namespace, MINOR_VERSION))
+    def generate_tests(self, classes, package_name="tests"):
+        self.generate_requirements()
 
-    with open(os.path.join(project_path, "Program.cs"), "w") as fp:
-        fp.write(generate_dotnet_code(methods, namespace=namespace))
+        base_path = os.path.join(self.output_path, package_name)
+        os.makedirs(base_path)
 
+        open(os.path.join(base_path, "__init__.py"), "w").close()
 
-_GENERATE_METHODS_FUNCTIONS = {
-    "python": generate_python_methods,
-    "dotnet": generate_dotnet_methods,
-    "c#": generate_dotnet_methods,
-}
+        with open(os.path.join(base_path, "test.py"), "w") as fp:
+            fp.write(self.generate_code(classes))
 
-_GENERATE_CLASS_FUNCTIONS = {
-    "python": generate_python_class,
-    "dotnet": generate_dotnet_class,
-    "c#": generate_dotnet_class,
-}
 
-_GENERATE_CODE_FUNCTIONS = {
-    "python": generate_python_code,
-    "dotnet": generate_dotnet_code,
-    "c#": generate_dotnet_code,
-}
+class DotnetGenerator(Generator):
+    """A class for generating an AltWalker project for dotnet."""
 
-_DEFAULT_GENERATE_TEST_FUNCTION = generate_empty_tests
+    VERSION_WILDCARD = ".".join(VERSION.split(".")[:2]) + ".*"
+    CSPROJ_TEMPLATE = get_resource("data/templates/generate/dotnet/csproj.jinja")
 
-_GENERATE_TESTS_FUNCTIONS = {
-    "python": generate_python_tests,
-    "dotnet": generate_dotnet_tests,
-    "c#": generate_dotnet_tests,
-}
+    METHODS_TEMPLATE = get_resource("data/templates/generate/dotnet/methods.jinja")
+    CLASS_TEMPLATE = get_resource("data/templates/generate/dotnet/class.jinja")
+    CODE_TEMPLATE = get_resource("data/templates/generate/dotnet/code.jinja")
 
-SUPPORTED_LANGUAGES = _GENERATE_TESTS_FUNCTIONS.keys()
+    DOTNET_GITIGNORE = get_resource("data/templates/generate/gitignore/dotnet.txt")
 
+    @property
+    def gitignore(self):
+        return "{}\n{}".format(super().gitignore, self.DOTNET_GITIGNORE)
 
-def _call_generate_function(language, functions, *args, **kwargs):
-    try:
-        generate_func = functions[language.lower()]
-    except KeyError:
-        raise AltWalkerException(
-            "Language '{}' is not supported. Supported languages are: {}."
-            .format(language, ", ".join(functions.keys()))
+    @classmethod
+    def generate_methods(cls, methods):
+        return _render_jinja_template(
+            cls.METHODS_TEMPLATE,
+            data={"methods": methods},
+            trim_blocks=True
         )
 
-    return generate_func(*args, **kwargs)
+    @classmethod
+    def generate_class(cls, class_name, methods):
+        return _render_jinja_template(
+            cls.CLASS_TEMPLATE,
+            data={"class_name": class_name, "methods": methods},
+            trim_blocks=True
+        )
 
+    @classmethod
+    def generate_csproj(cls):
+        return _render_jinja_template(
+            cls.CSPROJ_TEMPLATE,
+            data={"version": cls.VERSION_WILDCARD},
+            trim_blocks=True
+        )
 
-def generate_methods(language, *args, **kwargs):
-    return _call_generate_function(language, _GENERATE_METHODS_FUNCTIONS, *args, **kwargs)
+    @classmethod
+    def generate_code(cls, classes, namespace="Tests"):
+        return _render_jinja_template(
+            cls.CODE_TEMPLATE,
+            data={"classes": classes.items(), "namespace": namespace},
+            trim_blocks=True
+        )
 
+    def generate_tests(self, classes, package_name="tests"):
+        base_path = os.path.join(self.output_path, package_name)
+        namespace = _normalize_namespace("{}.{}".format(self.project_name, package_name))
 
-def generate_class(language, *args, **kwargs):
-    return _call_generate_function(language, _GENERATE_CLASS_FUNCTIONS, *args, **kwargs)
+        os.makedirs(base_path)
 
+        with open(os.path.join(base_path, "{}.csproj".format(package_name)), "w") as fp:
+            fp.write(self.generate_csproj())
 
-def generate_code(language, *args, **kwargs):
-    return _call_generate_function(language, _GENERATE_CODE_FUNCTIONS, *args, **kwargs)
+        with open(os.path.join(base_path, "Program.cs"), "w") as fp:
+            fp.write(self.generate_code(classes, namespace=namespace))
 
 
-def generate_tests(output_dir, model_paths, *args, language=None, package_name="tests", **kwargs):
-    """Generate tests for an language.
+GeneratorFactory = Factory({
+    "python": PythonGenerator,
+    "py": PythonGenerator,
+    "dotnet": DotnetGenerator,
+    "c#": DotnetGenerator,
+}, default=EmptyGenerator)
 
-    Args:
-        output_dir: The path to the project root.
-        model_paths: A sequence of path to model files.
-        package_name: The name of the test package (e.g. tests).
-        language: The language of the project (e.g. python).
 
-    Raises:
-        FileExistsError: If the path ``output_dir/package_name`` already exists.
-    """
+def get_supported_languages():
+    return GeneratorFactory.keys()
 
-    package_path = os.path.join(output_dir, package_name)
 
-    if os.path.exists(package_path):
-        raise FileExistsError("The '{}' directory already exists.".format(package_path))
+def generate_methods(methods, language=None):
+    cls = GeneratorFactory.get(language)
+    return cls.generate_methods(methods)
 
-    methods = get_methods(model_paths)
 
-    try:
-        if not language:
-            _DEFAULT_GENERATE_TEST_FUNCTION(output_dir, methods, package_name=package_name)
-        else:
-            _call_generate_function(language, _GENERATE_TESTS_FUNCTIONS, output_dir, methods, package_name=package_name)
-    except Exception as ex:
-        logger.exception(ex)
-        if os.path.isdir(package_path):
-            shutil.rmtree(package_path)
-        raise
+def generate_class(class_name, methods, language=None):
+    cls = GeneratorFactory.get(language)
+    return cls.generate_class(class_name, methods)
+
+
+def generate_code(model_paths=None, language=None):
+    cls = GeneratorFactory.get(language)
+    return cls.generate_code(model_paths=model_paths)
+
 
+def generate_tests(output_path, model_paths=None, language=None):
+    cls = GeneratorFactory.get(language)
+    generator = cls(output_path, model_paths=model_paths)
+    return generator.init_package()
 
-def init_project(output_dir, model_paths=None, language=None, git=True):
+
+def init_project(output_path, model_paths=None, language=None, git=True):
     """Initiates a new project.
 
     Args:
-        output_dir: The path to the project root.
-        language: The language of the project (e.g. python).
+        output_path: The path to the project root.
         model_paths: A sequence of path to model files.
+        language: The language of the project (e.g. python).
         git: If set to ``True`` will initialize a git repository and commit the files.
 
     Raises:
-        FileExistsError: If the path ``output_dir`` already exists.
+        FileExistsError: If the path ``output_path`` already exists.
     """
 
-    if os.path.exists(output_dir):
-        raise FileExistsError("The '{}' directory already exists.".format(output_dir))
-
-    os.makedirs(output_dir)
-
-    try:
-        if model_paths:
-            _copy_models(os.path.join(output_dir, "models"), model_paths)
-        else:
-            _create_default_model(os.path.join(output_dir, "models"))
-            model_paths = [os.path.join(output_dir, "models", "default.json")]
-
-        generate_tests(output_dir, model_paths, language=language)
-
-        if git:
-            _git_init(output_dir)
-    except Exception as ex:
-        logger.exception(ex)
-        shutil.rmtree(output_dir)
-        raise
+    cls = GeneratorFactory.get(language)
+    generator = cls(output_path, model_paths=model_paths, git=git)
+    return generator.init_project()
diff --git a/requirements.txt b/requirements.txt
index 42967df..ffd3af8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,10 @@
 requests
+jinja2~=3.1; python_version >= "3.6"
+jinja2==3.0.3; python_version == "3.6"
+jinja2==2.11.3; python_version < "3.6"
 psutil~=5.9
-click~=8.1; python_version >= "3.6"
+click~=8.1; python_version > "3.6"
+click==8.4; python_version == "3.6"
 click~=7.0; python_version < "3.6"
 click-help-colors>=0.9.1
 GitPython==3.1.13; python_version < "3.6"


Test patch:
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index 91b82f6..68fe9bc 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -71,17 +71,17 @@ def _assert_dotnet_file_structure(self, repo_path):
 
     def _assert_git_repo(self, repo_path):
         repo = Repo(repo_path)
-        commits = list(repo.iter_commits('master'))
+        commits = list(repo.iter_commits(repo.active_branch))
 
-        self.assertEqual(len(commits), 1, "Tests repo should have one commit")
-        self.assertEqual(commits[0].summary, "Initial commit", "Commit summary should be 'Initial commit'")
+        assert len(commits) == 1, "Tests repo should have one commit"
+        assert "Initial commit" in commits[0].summary, "Commit summary should be 'Initial commit'"
 
     def test_git(self):
         with run_isolation(self.runner, self.files):
             packagename = "example"
             result = self.runner.invoke(init, [packagename, "--git"])
 
-            self.assertIsNone(result.exception, msg=result.exception)
+            self.assertIsNone(result.exception, msg=str(result.exception) + "\n" + result.output)
             self.assertEqual(result.exit_code, 0, msg=result.output)
 
             self._assert_git_repo(packagename)
@@ -107,18 +107,22 @@ def test_python(self):
             self._assert_python_file_structure(self.packagename)
 
             expected_code = (
-                "\n"
                 "class ModelName:\n\n"
                 "    def vertex_A(self):\n"
                 "        pass\n\n"
                 "    def vertex_B(self):\n"
                 "        pass\n\n"
                 "    def edge_A(self):\n"
-                "        pass\n\n"
+                "        pass\n\n\n"
             )
 
             with open("{}/tests/test.py".format(self.packagename), "r") as fp:
-                self.assertEqual(fp.read(), expected_code)
+                code = fp.read()
+                print()
+                print(code)
+                print()
+
+                assert code == expected_code
 
     def test_dotnet(self):
         with run_isolation(self.runner, self.files):
@@ -147,15 +151,15 @@ def test_dotnet(self):
                 "        }\n\n"
                 "    }\n\n"
                 "    public class Program\n"
-                "    {\n\n"
+                "    {\n"
                 "        public static void Main(string[] args)\n"
                 "        {\n"
                 "            ExecutorService service = new ExecutorService();\n"
                 "            service.RegisterModel<ModelName>();\n"
                 "            service.Run(args);\n"
                 "        }\n"
-                "    }\n\n"
-                "}\n"
+                "    }\n"
+                "}"
             )
 
             with open("{}/tests/Program.cs".format(self.packagename), "r") as fp:
@@ -175,7 +179,7 @@ def test_model(self):
             packagename = "example"
             result = self.runner.invoke(init, ["-m", "simple.json", packagename])
 
-            self.assertIsNone(result.exception, msg=result.exception)
+            self.assertIsNone(result.exception, msg=str(result.exception) + "\n" + result.output)
             self.assertEqual(result.exit_code, 0, msg=result.output)
 
             self._assert_models_files(packagename, ["simple.json"])
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 0edc3be..feb9dce 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -5,11 +5,13 @@
 from click.testing import CliRunner
 
 from tests.common.utils import run_isolation
-from altwalker.generate import SUPPORTED_LANGUAGES
+from altwalker.generate import get_supported_languages
 from altwalker.executor import SUPPORTED_EXECUTORS
 from altwalker.cli import check, verify, init, generate, online, offline, walk
 
 
+SUPPORTED_LANGUAGES = get_supported_languages()
+
 MODELS_OPTIONS = ["--model", "-m"]
 EXECUTOR_TYPE_OPTIONS = ["--executor", "-x", "--language", "-l"]
 LANGUAGE_OPTIONS = ["--language", "-l"]
diff --git a/tests/unit/test_cli_utils.py b/tests/unit/test_cli_utils.py
index be41d7b..34dd431 100644
--- a/tests/unit/test_cli_utils.py
+++ b/tests/unit/test_cli_utils.py
@@ -233,8 +233,8 @@ def test_missing_class(self, secho_mock, generate_methods_mock, generate_class_m
 
         _echo_suggestions(self.language, methods, missing_methods)
         generate_class_mock.assert_called_once_with(
-            self.language,
-            "ModelA", {"vertex_A", "edge_A", "vertex_B", "edge_B"}
+            "ModelA", {"vertex_A", "edge_A", "vertex_B", "edge_B"},
+            language=self.language
         )
 
     def test_missing_methods(self, secho_mock, generate_methods_mock, generate_class_mock):
@@ -250,7 +250,7 @@ def test_missing_methods(self, secho_mock, generate_methods_mock, generate_class
         }
 
         _echo_suggestions(self.language, methods, missing_methods)
-        generate_methods_mock.assert_called_once_with(self.language, {"vertex_A", "edge_A"})
+        generate_methods_mock.assert_called_once_with({"vertex_A", "edge_A"}, language=self.language)
 
     def test_no_missing_methods(self, secho_mock, generate_methods_mock, generate_class_mock):
         generate_methods_mock.return_value = ""
diff --git a/tests/unit/test_generate.py b/tests/unit/test_generate.py
index 67359ca..a48abc4 100644
--- a/tests/unit/test_generate.py
+++ b/tests/unit/test_generate.py
@@ -1,496 +1,277 @@
 import os
-import shutil
-import random
-import warnings
-import unittest
-import unittest.mock as mock
 
-from altwalker.exceptions import AltWalkerException
-from altwalker.generate import _normalize_namespace, _copy_models, \
-    _call_generate_function, _create_default_model, _git_init, generate_empty_tests, \
-    generate_python_methods, generate_python_class, generate_python_code, generate_python_tests, \
-    generate_dotnet_methods, generate_dotnet_class, generate_dotnet_code, generate_dotnet_tests, \
-    generate_methods, generate_class, generate_code, generate_tests, init_project
+import pytest
 
+from altwalker.generate import EmptyGenerator, PythonGenerator, DotnetGenerator
 
-PYTHON_METHOD_REGEX_TEMPLATE = r"def {}\(self\):\n\s*pass"
-DOTNET_METHOD_REGEX_TEMPLATE = r"public void {}\(\)\n\s*{{\s*\n\s*}}\s*"
 
+class TestGenerator:
+    pass
 
-def scramble_case(word):
-    result = ""
 
-    for letter in word:
-        result += random.choice([letter.upper(), letter.lower()])
+class TestEmptyGenerator:
 
-    return result
-
-
-class GenerateTestsTestCase(unittest.TestCase):
-
-    def setUp(self):
-        self.project_name = "test-project"
-        self.methods = {
-            "ModelName": ["vertex_A", "vertex_B", "edge_A"]
-        }
-
-    def tearDown(self):
-        if os.path.exists(self.project_name):
-            shutil.rmtree(self.project_name)
-
-
-class _TestScrambleCase(unittest.TestCase):
-
-    def test_scramble(self):
-        for word in ["random", "worlds", "for", "tests"]:
-            scrambled_word = scramble_case(word)
-            self.assertEqual(word.lower(), scrambled_word.lower())
-
-
-class _TestNormalizeNamespace(unittest.TestCase):
-
-    def test_dash(self):
-        self.assertEqual("A.B", _normalize_namespace("A-B"))
-
-    def test_title_case(self):
-        self.assertEqual("A.B.C", _normalize_namespace("a-b-c"))
-        self.assertEqual("A.B.C", _normalize_namespace("A-B-C"))
-
-
-class _TestCopyModels(unittest.TestCase):
-
-    def setUp(self):
-        self.output_dir = "output_dir"
-
-    def tearDown(self):
-        if os.path.exists(self.output_dir):
-            shutil.rmtree(self.output_dir)
-
-    def test_with_no_model(self):
-        _copy_models(self.output_dir, [])
-        self.assertTrue(os.path.isdir(self.output_dir))
-
-    def test_with_models(self):
-        _copy_models(self.output_dir, ["tests/common/models/simple.json", "tests/common/models/complex.json"])
-
-        self.assertTrue(os.path.isfile(self.output_dir + "/simple.json"))
-        self.assertTrue(os.path.isfile(self.output_dir + "/complex.json"))
-
-
-class _TestCreateDefaultModel(unittest.TestCase):
-
-    def setUp(self):
-        self.output_dir = "output_dir"
-
-    def tearDown(self):
-        if os.path.exists(self.output_dir):
-            shutil.rmtree(self.output_dir)
-
-    def test_create_default(self):
-        _create_default_model(self.output_dir)
-        self.assertTrue(os.path.isfile(self.output_dir + "/default.json"))
-
-
-class _TestGitInit(unittest.TestCase):
-
-    @mock.patch("altwalker.generate.has_git")
-    def test_warning(self, has_git):
-        has_git.return_value = False
-
-        with warnings.catch_warnings(record=True) as w:
-            _git_init("path/to/project")
-
-            self.assertEqual(len(w), 1)
-            self.assertTrue(issubclass(w[-1].category, UserWarning))
-
-
-class TestGenerateEmptyTests(GenerateTestsTestCase):
-
-    def test_code(self):
-        tests_directory = "tests"
-
-        generate_empty_tests(self.project_name, package_name=tests_directory)
-
-        tests_path = os.path.join(self.project_name, tests_directory)
-        self.assertTrue(os.path.exists(tests_path))
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_get_methods(self, get_methods_mock):
-        tests_directory = "tests"
-
-        generate_tests(self.project_name, model_paths=mock.sentinel.models, language=None, package_name=tests_directory)
-
-        get_methods_mock.assert_called_once_with(mock.sentinel.models)
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests(self, get_methods_mock):
-        tests_directory = "tests"
-
-        generate_tests(self.project_name, model_paths=mock.sentinel.models, language=None, package_name=tests_directory)
-
-        tests_path = os.path.join(self.project_name, tests_directory)
-        self.assertTrue(os.path.exists(tests_path))
-
-
-class TestGeneratePythonMethods(unittest.TestCase):
-
-    def setUp(self):
-        self.methods = ["method_a", "method_b", "method_c"]
-
-    def test_methods(self):
-        methods_code = generate_python_methods(self.methods)
-
-        for method in self.methods:
-            expresion = PYTHON_METHOD_REGEX_TEMPLATE.format(method)
-            self.assertRegex(methods_code, expresion)
+    @pytest.fixture(autouse=True)
+    def setup(self, tmpdir):
+        self.tmpdir = tmpdir
+        self.generator = EmptyGenerator(tmpdir, model_paths=[], git=False)
 
     def test_generate_methods(self):
-        self.assertEqual(
-            generate_methods("python", self.methods),
-            generate_python_methods(self.methods)
-        )
-
-
-class TestGeneratePythonClass(unittest.TestCase):
-
-    def setUp(self):
-        self.class_name = "ClassA"
-        self.methods = ["method_a", "method_b", "method_c"]
-
-    def test_class(self):
-        class_code = generate_python_class(self.class_name, self.methods)
-
-        self.assertIn("class {}:".format(self.class_name), class_code)
-
-    def test_methods(self):
-        class_code = generate_python_class(self.class_name, self.methods)
+        code = self.generator.generate_methods(methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = ""
 
-        for method in self.methods:
-            expresion = PYTHON_METHOD_REGEX_TEMPLATE.format(method)
-            self.assertRegex(class_code, expresion)
+        assert code == expected
 
     def test_generate_class(self):
-        self.assertEqual(
-            generate_class("python", self.class_name, self.methods),
-            generate_python_class(self.class_name, self.methods)
-        )
-
-
-class TestGeneratePythonCode(unittest.TestCase):
-
-    def setUp(self):
-        self.methods = {
-            "ClassA": ["method_a", "method_b", "method_c"],
-            "ClassB": ["method_d", "method_e", "method_f"],
-        }
+        code = self.generator.generate_class(class_name="DefaultModel", methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = ""
 
-    def test_clases(self):
-        code = generate_python_code(self.methods)
-
-        for class_name, methods_list in self.methods.items():
-            class_code = generate_python_class(class_name, methods_list)
-            self.assertIn(class_code, code)
+        assert code == expected
 
     def test_generate_code(self):
-        self.assertEqual(
-            generate_code("python", self.methods),
-            generate_python_code(self.methods)
-        )
-
-
-class TestGeneratePythonTests(GenerateTestsTestCase):
-
-    def test_code(self):
-        generate_python_tests(self.project_name, self.methods)
-
-        expected_code = (
-            "\n"
-            "class ModelName:\n\n"
-            "    def vertex_A(self):\n"
-            "        pass\n\n"
-            "    def vertex_B(self):\n"
-            "        pass\n\n"
-            "    def edge_A(self):\n"
-            "        pass\n\n"
+        code = self.generator.generate_code(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
         )
+        expected = ""
 
-        with open(self.project_name + "/tests/test.py", "r") as fp:
-            code = fp.read()
-            self.assertEqual(code, expected_code)
-
-    def test_init(self):
-        generate_python_tests(self.project_name, self.methods)
-        self.assertTrue(os.path.exists(self.project_name + "/tests/__init__.py"))
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_get_methods(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
-
-        generate_tests(self.project_name, mock.sentinel.models, language="python")
-
-        get_methods_mock.assert_called_once_with(mock.sentinel.models)
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_code(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
-
-        generate_tests(self.project_name, mock.sentinel.models, language="python")
+        assert code == expected
 
-        expected_code = (
-            "\n"
-            "class ModelName:\n\n"
-            "    def vertex_A(self):\n"
-            "        pass\n\n"
-            "    def vertex_B(self):\n"
-            "        pass\n\n"
-            "    def edge_A(self):\n"
-            "        pass\n\n"
+    def test_generate_tests(self):
+        self.generator.generate_tests(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
         )
 
-        with open(self.project_name + "/tests/test.py", "r") as fp:
-            code = fp.read()
-            self.assertEqual(code, expected_code)
+        package_path = os.path.join(self.tmpdir, "tests")
+        assert os.path.isdir(package_path)
 
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_init(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
 
-        generate_tests(self.project_name, mock.sentinel.models, language="python")
+class TestPythonGenerator:
 
-        self.assertTrue(os.path.exists(self.project_name + "/tests/__init__.py"))
-
-
-class TestGenerateDotnetMethods(unittest.TestCase):
-
-    def setUp(self):
-        self.methods = ["method_a", "method_b", "method_c"]
-
-    def test_methods(self):
-        methods_code = generate_dotnet_methods(self.methods)
-
-        for method in self.methods:
-            expresion = DOTNET_METHOD_REGEX_TEMPLATE.format(method)
-            self.assertRegex(methods_code, expresion)
+    @pytest.fixture(autouse=True)
+    def setup(self, tmpdir):
+        self.tmpdir = tmpdir
+        self.generator = PythonGenerator(tmpdir, model_paths=[], git=False)
 
     def test_generate_methods(self):
-        self.assertEqual(
-            generate_methods("dotnet", self.methods),
-            generate_dotnet_methods(self.methods)
-        )
-
-        self.assertEqual(
-            generate_methods("c#", self.methods),
-            generate_dotnet_methods(self.methods)
-        )
-
-
-class TestGenerateDotnetClass(unittest.TestCase):
-
-    def setUp(self):
-        self.class_name = "ClassA"
-        self.methods = ["method_a", "method_b", "method_c"]
-
-    def test_class(self):
-        class_code = generate_dotnet_class(self.class_name, self.methods)
-
-        self.assertIn("public class {}".format(self.class_name), class_code)
-
-    def test_methods(self):
-        class_code = generate_dotnet_class(self.class_name, self.methods)
-
-        for method in self.methods:
-            expresion = DOTNET_METHOD_REGEX_TEMPLATE.format(method)
-            self.assertRegex(class_code, expresion)
+        code = self.generator.generate_methods(methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = [
+            "",
+            "def vertex_A(self):",
+            "    pass",
+            "",
+            "def edge_A(self):",
+            "    pass",
+            "",
+            "def vertex_B(self):",
+            "    pass",
+            "",
+        ]
+
+        assert code == "\n".join(expected)
 
     def test_generate_class(self):
-        self.assertEqual(
-            generate_class("dotnet", self.class_name, self.methods),
-            generate_dotnet_class(self.class_name, self.methods)
-        )
+        code = self.generator.generate_class(class_name="DefaultModel", methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = [
+            "class DefaultModel:",
+            "",
+            "    def vertex_A(self):",
+            "        pass",
+            "",
+            "    def edge_A(self):",
+            "        pass",
+            "",
+            "    def vertex_B(self):",
+            "        pass",
+            "",
+            ""
+        ]
+
+        assert code == "\n".join(expected)
 
-        self.assertEqual(
-            generate_class("c#", self.class_name, self.methods),
-            generate_dotnet_class(self.class_name, self.methods)
+    def test_generate_code(self):
+        code = self.generator.generate_code(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
+        )
+        expected = [
+            "class ModelA:",
+            "",
+            "    def vertex_A(self):",
+            "        pass",
+            "",
+            "    def edge_A(self):",
+            "        pass",
+            "",
+            "    def vertex_B(self):",
+            "        pass",
+            "",
+            "",
+            "class ModelB:",
+            "",
+            "    def vertex_C(self):",
+            "        pass",
+            "",
+            "    def edge_D(self):",
+            "        pass",
+            "",
+            "    def vertex_E(self):",
+            "        pass",
+            "",
+            "",
+            ""
+        ]
+
+        assert code == "\n".join(expected)
+
+    def test_generate_requirements(self):
+        self.generator.generate_requirements()
+        assert os.path.isfile(os.path.join(self.tmpdir, "requirements.txt"))
+
+    def test_generate_tests(self):
+        self.generator.generate_tests(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
         )
 
+        package_path = os.path.join(self.tmpdir, "tests")
 
-class TestGenerateDotnetCode(unittest.TestCase):
-
-    def setUp(self):
-        self.methods = {
-            "ClassA": ["method_a", "method_b", "method_c"],
-            "ClassB": ["method_d", "method_e", "method_f"],
-        }
-
-    def test_clases(self):
-        code = generate_dotnet_code(self.methods)
+        assert os.path.isfile(os.path.join(self.tmpdir, "requirements.txt"))
+        assert os.path.isdir(package_path)
+        assert os.path.isfile(os.path.join(package_path, "__init__.py"))
+        assert os.path.isfile(os.path.join(package_path, "test.py"))
 
-        for class_name, methods_list in self.methods.items():
-            class_code = generate_dotnet_class(class_name, methods_list)
-            self.assertIn(class_code, code)
 
-    def test_program_class(self):
-        code = generate_dotnet_code(self.methods)
-        self.assertIn("public class Program", code)
+class TestDotnetGenerator:
 
-    def test_main(self):
-        code = generate_dotnet_code(self.methods)
+    @pytest.fixture(autouse=True)
+    def setup(self, tmpdir):
+        self.tmpdir = tmpdir
+        self.generator = DotnetGenerator(tmpdir, model_paths=[], git=False)
 
-        self.assertIn("public static void Main(string[] args)", code)
+    def test_generate_methods(self):
+        code = self.generator.generate_methods(methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = [
+            "",
+            "        public void vertex_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void edge_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void vertex_B()",
+            "        {",
+            "        }",
+            ""
+        ]
+
+        assert code == "\n".join(expected)
 
-        for class_name in self.methods.keys():
-            self.assertIn("service.RegisterModel<{}>();".format(class_name), code)
+    def test_generate_class(self):
+        code = self.generator.generate_class(class_name="DefaultModel", methods=["vertex_A", "edge_A", "vertex_B"])
+        expected = [
+            "    public class DefaultModel",
+            "    {",
+            "",
+            "        public void vertex_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void edge_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void vertex_B()",
+            "        {",
+            "        }",
+            "",
+            "    }",
+        ]
+
+        assert code == "\n".join(expected)
 
     def test_generate_code(self):
-        self.assertEqual(
-            generate_code("dotnet", self.methods),
-            generate_dotnet_code(self.methods)
+        code = self.generator.generate_code(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
         )
-
-        self.assertEqual(
-            generate_code("c#", self.methods),
-            generate_dotnet_code(self.methods)
+        expected = [
+            "using Altom.AltWalker;",
+            "",
+            "namespace Tests",
+            "{",
+            "",
+            "    public class ModelA",
+            "    {",
+            "",
+            "        public void vertex_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void edge_A()",
+            "        {",
+            "        }",
+            "",
+            "        public void vertex_B()",
+            "        {",
+            "        }",
+            "",
+            "    }",
+            "",
+            "    public class ModelB",
+            "    {",
+            "",
+            "        public void vertex_C()",
+            "        {",
+            "        }",
+            "",
+            "        public void edge_D()",
+            "        {",
+            "        }",
+            "",
+            "        public void vertex_E()",
+            "        {",
+            "        }",
+            "",
+            "    }",
+            "",
+            "    public class Program",
+            "    {",
+            "        public static void Main(string[] args)",
+            "        {",
+            "            ExecutorService service = new ExecutorService();",
+            "            service.RegisterModel<ModelA>();",
+            "            service.RegisterModel<ModelB>();",
+            "            service.Run(args);",
+            "        }",
+            "    }",
+            "}",
+        ]
+
+        assert code == "\n".join(expected)
+
+    def test_generate_tests(self):
+        self.generator.generate_tests(
+            {
+                "ModelA": ["vertex_A", "edge_A", "vertex_B"],
+                "ModelB": ["vertex_C", "edge_D", "vertex_E"]
+            }
         )
 
+        package_path = os.path.join(self.tmpdir, "tests")
 
-class TestGenerateDotnetTests(GenerateTestsTestCase):
-
-    def test_csproj(self):
-        generate_dotnet_tests(self.project_name, self.methods)
-        self.assertTrue(os.path.exists("{}/tests/tests.csproj".format(self.project_name)))
-
-    def test_program(self):
-        generate_dotnet_tests(self.project_name, self.methods)
-        self.assertTrue(os.path.exists("{}/tests/Program.cs".format(self.project_name)))
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_get_methods(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
-
-        generate_tests(self.project_name, mock.sentinel.models, language="dotnet")
-        get_methods_mock.assert_called_once_with(mock.sentinel.models)
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_csproj(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
-
-        generate_tests(self.project_name, mock.sentinel.models, language="dotnet")
-        self.assertTrue(os.path.exists("{}/tests/tests.csproj".format(self.project_name)))
-
-    @mock.patch("altwalker.generate.get_methods")
-    def test_generate_tests_program(self, get_methods_mock):
-        get_methods_mock.return_value = self.methods
-
-        generate_tests(self.project_name, mock.sentinel.models, language="dotnet")
-        self.assertTrue(os.path.exists("{}/tests/Program.cs".format(self.project_name)))
-
-
-class TestsCallGenerateFunction(unittest.TestCase):
-
-    def setUp(self):
-        self.call_args = None
-
-        self.functions = {
-            "python": mock.Mock(),
-            "dotnet": mock.Mock(),
-        }
-
-    def test_call(self):
-        for language in self.functions.keys():
-            _call_generate_function(language, self.functions)
-
-            self.functions[language].assert_called_once_with()
-
-    def test_args_and_kwargs(self):
-        for language in self.functions.keys():
-            _call_generate_function(language, self.functions, language, x=language)
-
-            self.functions[language].assert_called_once_with(language, x=language)
-
-    def test_case_sensitivity(self):
-        for language in self.functions.keys():
-            scrambled_language = scramble_case(language)
-            _call_generate_function(scrambled_language, self.functions)
-
-            self.functions[language].assert_called_once_with()
-
-    def test_invalid_language(self):
-        expected_error_message = "Language 'not-found' is not supported. Supported languages are: *."
-        with self.assertRaisesRegex(AltWalkerException, expected_error_message):
-            _call_generate_function("not-found", self.functions)
-
-
-@mock.patch("altwalker.generate.get_methods", return_value={})
-class TestGenerateTests(GenerateTestsTestCase):
-
-    def test_directory_already_exists(self, get_methods):
-        test_package_path = "{}/{}".format(self.project_name, "tests")
-        os.makedirs(test_package_path)
-
-        with self.assertRaisesRegex(FileExistsError, "The .* directory already exists."):
-            generate_tests(self.project_name, ["model.json"], language="python")
-
-        self.assertTrue(os.path.exists(test_package_path))
-
-    def test_unsupported_language(self, get_methods):
-        with self.assertRaisesRegex(AltWalkerException, "Language 'unsupported-language' is not supported. *."):
-            generate_tests(self.project_name, ["model.json"], language="unsupported-language")
-
-    @mock.patch("altwalker.generate._call_generate_function")
-    def test_error(self, call_generate_function_mock, get_methods):
-        error_message = "Unknown error"
-
-        def side_effect(*args, **kwargs):
-            os.makedirs("{}/tests/".format(self.project_name))
-            raise AltWalkerException(error_message)
-
-        call_generate_function_mock.side_effect = side_effect
-
-        with self.assertRaisesRegex(AltWalkerException, error_message):
-            generate_tests(self.project_name, ["model.json"], language="python")
-
-        self.assertFalse(os.path.exists("{}/tests/".format(self.project_name)))
-
-
-@mock.patch("altwalker.generate._git_init")
-@mock.patch("altwalker.generate.generate_tests")
-class TestInitProject(unittest.TestCase):
-
-    def setUp(self):
-        self.output_dir = "output_dir"
-
-    def tearDown(self):
-        if os.path.exists(self.output_dir):
-            shutil.rmtree(self.output_dir)
-
-    def test_cleanup(self, generate_tests_mock, git_init_mock):
-        message = "Mocked exception message"
-        generate_tests_mock.side_effect = Exception(message)
-
-        with self.assertRaisesRegex(Exception, message):
-            init_project("output_dir", None)
-
-        self.assertEqual(False, os.path.isdir(self.output_dir))
-
-    def test_directory_already_exisits(self, generate_tests_mock, git_init_mock):
-        os.makedirs(self.output_dir)
-
-        with self.assertRaisesRegex(FileExistsError, "The .* directory already exists."):
-            init_project(self.output_dir)
-
-    def test_create_dir(self, generate_tests_mock, git_init_mock):
-        init_project(self.output_dir)
-        self.assertEqual(True, os.path.isdir(self.output_dir))
-
-    @mock.patch("altwalker.generate._copy_models")
-    def test_copy_models(self, copy_mock, generate_tests_mock, git_init_mock):
-        init_project(self.output_dir, model_paths=["first.json", "second.json"], language="python")
-        copy_mock.assert_called_once_with(self.output_dir + os.path.sep + "models", ["first.json", "second.json"])
-
-    def test_git_init(self, generate_tests_mock, git_init_mock):
-        init_project(self.output_dir)
-        git_init_mock.assert_called_once_with(self.output_dir)
-
-    def test_no_git_init(self, generate_tests_mock, git_init_mock):
-        init_project(self.output_dir, git=False)
-        git_init_mock.assert_not_called()
+        assert os.path.isdir(package_path)
+        assert os.path.isfile(os.path.join(package_path, "tests.csproj"))
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 3743a5a..1b9b419 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -6,21 +6,19 @@
 from altwalker._utils import prefix_command, url_join, has_command, has_git
 
 
-class TestUrlJoin:
-
-    @pytest.mark.parametrize(
-        "base, url, expected",
-        [
-            ("http://localhost:5000", "altwalker", "http://localhost:5000/altwalker"),
-            ("http://localhost:5000", "/altwalker", "http://localhost:5000/altwalker"),
-            ("http://localhost:5000", "/altwalker/", "http://localhost:5000/altwalker"),
-            ("http://localhost:5000/", "altwalker", "http://localhost:5000/altwalker"),
-            ("http://localhost:5000/", "/altwalker", "http://localhost:5000/altwalker"),
-            ("http://localhost:5000/", "/altwalker/", "http://localhost:5000/altwalker")
-        ]
-    )
-    def test_url_join(self, base, url, expected):
-        assert url_join(base, url) == expected
+@pytest.mark.parametrize(
+    "base, url, expected",
+    [
+        ("http://localhost:5000", "altwalker", "http://localhost:5000/altwalker"),
+        ("http://localhost:5000", "/altwalker", "http://localhost:5000/altwalker"),
+        ("http://localhost:5000", "/altwalker/", "http://localhost:5000/altwalker"),
+        ("http://localhost:5000/", "altwalker", "http://localhost:5000/altwalker"),
+        ("http://localhost:5000/", "/altwalker", "http://localhost:5000/altwalker"),
+        ("http://localhost:5000/", "/altwalker/", "http://localhost:5000/altwalker")
+    ]
+)
+def test_url_join(base, url, expected):
+    assert url_join(base, url) == expected
 
 
 class TestPrefixCommand: