From 9c00a71177114f15976dcc62d9817be043215d63 Mon Sep 17 00:00:00 2001
From: Frederik Hennig <frederik.hennig@fau.de>
Date: Thu, 28 Nov 2024 16:56:36 +0100
Subject: [PATCH] start rebuilding configuration system

---
 .gitlab-ci.yml                 |   2 +-
 pytest.ini                     |   2 +-
 src/pystencilssfg/config.py    | 158 +++++++++++++++++++++++++++++++++
 tests/generator/test_config.py |  37 ++++++++
 4 files changed, 197 insertions(+), 2 deletions(-)
 create mode 100644 src/pystencilssfg/config.py
 create mode 100644 tests/generator/test_config.py

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 5ca7ae3..5331eca 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -39,7 +39,7 @@ testsuite:
     - pip install "git+https://i10git.cs.fau.de/pycodegen/pystencils.git@v2.0-dev"
     - pip install -e .
   script:
-    - pytest -v
+    - pytest -v --cov=src/pystencilssfg --cov-report=term
     - coverage html
     - coverage xml
   coverage: '/TOTAL.*\s+(\d+%)$/'
diff --git a/pytest.ini b/pytest.ini
index 8eb047c..94a3a6c 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -3,6 +3,6 @@ testpaths = src/pystencilssfg tests/
 python_files = "test_*.py"
 #   Need to ignore the generator scripts, otherwise they would be executed
 #   during test collection
-addopts = --doctest-modules --ignore=tests/generator_scripts/scripts --cov=src/pystencilssfg --cov-report=term
+addopts = --doctest-modules --ignore=tests/generator_scripts/scripts
 
 doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
diff --git a/src/pystencilssfg/config.py b/src/pystencilssfg/config.py
new file mode 100644
index 0000000..2090ef8
--- /dev/null
+++ b/src/pystencilssfg/config.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+from typing import Generic, TypeVar, Callable, Any
+from abc import ABC
+from dataclasses import dataclass, fields
+from enum import Enum, auto
+
+
+Option_T = TypeVar("Option_T")
+
+
+class Option(Generic[Option_T]):
+    """Option descriptor.
+
+    This descriptor is used to model configuration options.
+    It maintains a default value for the option that is used when no value
+    was specified by the user.
+
+    In configuration options, the value `None` stands for `unset`.
+    It can therefore not be used to set an option to the meaning "not any", or "empty"
+    - for these, special values need to be used.
+    """
+
+    def __init__(
+        self,
+        default: Option_T,
+        validator: Callable[[Any, Option_T | None], Option_T | None] | None = None,
+    ) -> None:
+        self._default = default
+        self._validator = validator
+        self._name: str
+        self._lookup: str
+
+    def validate(self, validator: Callable[[Any, Any], Any] | None):
+        self._validator = validator
+        return validator
+
+    @property
+    def default(self) -> Option_T:
+        return self._default
+
+    def get(self, obj) -> Option_T:
+        print("get called")
+        val = getattr(obj, self._lookup, None)
+        if val is None:
+            return self._default
+        else:
+            return val
+
+    def __set_name__(self, owner, name: str):
+        self._name = name
+        self._lookup = f"_{name}"
+
+    def __get__(self, obj, objtype=None) -> Option_T | None:
+        if obj is None:
+            return None
+
+        return getattr(obj, self._lookup, None)
+
+    def __set__(self, obj, value: Option_T | None):
+        if self._validator is not None:
+            value = self._validator(obj, value)
+        setattr(obj, self._lookup, value)
+
+
+class ConfigBase(ABC):
+    def get_option(self, name: str) -> Any:
+        """Get the value set for the specified option, or the option's default value if none has been set."""
+        descr: Option = type(self).__dict__[name]
+        return descr.get(self)
+
+    def override(self, other: ConfigBase):
+        for field in fields(self):  # type: ignore
+            fvalue = getattr(self, field.name)
+            if isinstance(fvalue, ConfigBase):  # type: ignore
+                fvalue.override(getattr(other, field.name))
+            else:
+                setattr(self, field.name, getattr(other, field.name))
+
+
+@dataclass
+class FileExtensions(ConfigBase):
+    header: Option[str] = Option("hpp")
+    """File extension for generated header file."""
+
+    impl: Option[str | None] = Option(None)
+    """File extension for generated implementation file."""
+
+    @header.validate
+    @impl.validate
+    def _validate_extension(self, ext: str | None) -> str | None:
+        if ext is not None and ext[0] == ".":
+            return ext[1:]
+
+        return ext
+
+
+class SfgOutputMode(Enum):
+    STANDALONE = auto()
+    """Generate a header/implementation file pair (e.g. ``.hpp/.cpp``) where the implementation file will
+    be compiled to a standalone object."""
+
+    INLINE = auto()
+    """Generate a header/inline implementation file pair (e.g. ``.hpp/.ipp``) where all implementations
+    are inlined by including the implementation file at the end of the header file."""
+
+    HEADER_ONLY = auto()
+    """Generate only a header file.
+
+    At the moment, header-only mode does not support generation of kernels and requires that all functions
+    and methods are marked `inline`.
+    """
+
+
+@dataclass
+class SfgCodeStyle(ConfigBase):
+    indent_width: Option[int] = Option(2)
+
+    code_style: Option[str] = Option("file")
+    """Code style to be used by clang-format. Passed verbatim to `--style` argument of the clang-format CLI.
+
+    Similar to clang-format itself, the default value is `file`, such that a `.clang-format` file found in the build
+    tree will automatically be used.
+    """
+
+    force_clang_format: Option[bool] = Option(False)
+    """If set to True, abort code generation if ``clang-format`` binary cannot be found."""
+
+    skip_clang_format: Option[bool] = Option(False)
+    """If set to True, skip formatting using ``clang-format``."""
+
+    clang_format_binary: Option[str] = Option("clang-format")
+    """Path to the clang-format executable"""
+
+
+class _GlobalNamespace: ...  # noqa: E701
+
+
+GLOBAL_NAMESPACE = _GlobalNamespace()
+
+
+@dataclass
+class SfgConfig(ConfigBase):
+    extensions: FileExtensions = FileExtensions()
+    """File extensions of the generated files"""
+
+    output_mode: Option[SfgOutputMode] = Option(SfgOutputMode.STANDALONE)
+    """The generator's output mode; defines which files to generate, and the set of legal file extensions."""
+
+    outer_namespace: Option[str | _GlobalNamespace] = Option(GLOBAL_NAMESPACE)
+    """The outermost namespace in the generated file. May be a valid C++ nested namespace qualifier
+    (like ``a::b::c``) or `GLOBAL_NAMESPACE` if no outer namespace should be generated."""
+
+    codestyle: SfgCodeStyle = SfgCodeStyle()
+    """Options governing the code style used by the code generator"""
+
+    output_directory: Option[str] = Option(".")
+    """Directory to which the generated files should be written."""
diff --git a/tests/generator/test_config.py b/tests/generator/test_config.py
new file mode 100644
index 0000000..1bc9d0e
--- /dev/null
+++ b/tests/generator/test_config.py
@@ -0,0 +1,37 @@
+from pystencilssfg.config import SfgConfig, SfgOutputMode, GLOBAL_NAMESPACE
+
+
+def test_defaults():
+    cfg = SfgConfig()
+
+    assert cfg.get_option("output_mode") == SfgOutputMode.STANDALONE
+    assert cfg.extensions.get_option("header") == "hpp"
+    assert cfg.codestyle.get_option("indent_width") == 2
+    assert cfg.get_option("outer_namespace") is GLOBAL_NAMESPACE
+
+    cfg.extensions.impl = ".cu"
+    assert cfg.extensions.get_option("impl") == "cu"
+
+
+def test_override():
+    cfg1 = SfgConfig()
+    cfg1.outer_namespace = "test"
+    cfg1.extensions.header = "h"
+    cfg1.extensions.impl = "c"
+    cfg1.codestyle.force_clang_format = True
+
+    cfg2 = SfgConfig()
+    cfg2.outer_namespace = GLOBAL_NAMESPACE
+    cfg2.extensions.header = "hpp"
+    cfg2.extensions.impl = "cpp"
+    cfg2.codestyle.clang_format_binary = "bogus"
+
+    cfg1.override(cfg2)
+
+    assert cfg1.outer_namespace is GLOBAL_NAMESPACE
+    assert cfg1.extensions.header == "hpp"
+    assert cfg1.extensions.impl == "cpp"
+    assert cfg1.codestyle.force_clang_format is True
+    assert cfg1.codestyle.indent_width is None
+    assert cfg1.codestyle.code_style is None
+    assert cfg1.codestyle.clang_format_binary == "bogus"
-- 
GitLab