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