Skip to content
Snippets Groups Projects
configuration.py 12.1 KiB
Newer Older
Frederik Hennig's avatar
Frederik Hennig committed
# mypy: strict_optional=False

Frederik Hennig's avatar
Frederik Hennig committed
from __future__ import annotations

Frederik Hennig's avatar
Frederik Hennig committed
from typing import Sequence, Any
from os import path
from enum import Enum, auto
from dataclasses import dataclass, replace, fields, InitVar
from argparse import ArgumentParser
from textwrap import indent
Frederik Hennig's avatar
Frederik Hennig committed
from importlib import util as iutil

from .exceptions import SfgException


class SfgConfigSource(Enum):
    DEFAULT = auto()
    PROJECT = auto()
Frederik Hennig's avatar
Frederik Hennig committed
    COMMANDLINE = auto()
    SCRIPT = auto()


class SfgConfigException(Exception):
Frederik Hennig's avatar
Frederik Hennig committed
    def __init__(self, cfg_src: SfgConfigSource | None, message: str):
        super().__init__(cfg_src, message)
        self.message = message
        self.config_source = cfg_src


@dataclass
class SfgCodeStyle:
    indent_width: int = 2

    code_style: str = "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: bool = False
Frederik Hennig's avatar
Frederik Hennig committed
    """If set to True, abort code generation if ``clang-format`` binary cannot be found."""

    skip_clang_format: bool = False
    """If set to True, skip formatting using ``clang-format``."""
    clang_format_binary: str = "clang-format"
    """Path to the clang-format executable"""

    def indent(self, s: str):
        prefix = " " * self.indent_width
        return indent(s, prefix)
Frederik Hennig's avatar
Frederik Hennig committed
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`.
    """


HEADER_FILE_EXTENSIONS = {"h", "hpp", "cuh"}
IMPL_FILE_EXTENSIONS: dict[SfgOutputMode, set[str]] = {
    SfgOutputMode.STANDALONE: {"c", "cpp", "cu"},
    SfgOutputMode.INLINE: {".impl.h", "ipp"},
    SfgOutputMode.HEADER_ONLY: set(),
}


@dataclass
class SfgOutputSpec:
    """Name and path specification for files output by the code generator.

    Filenames are constructed as `<output_directory>/<basename>.<extension>`."""

    output_directory: str
    """Directory to which the generated files should be written."""

    basename: str
    """Base name for output files."""

    header_extension: str
    """File extension for generated header file."""

    impl_extension: str
    """File extension for generated implementation file."""

    def get_header_filename(self):
        return f"{self.basename}.{self.header_extension}"

    def get_impl_filename(self):
        return f"{self.basename}.{self.impl_extension}"

    def get_header_filepath(self):
        return path.join(self.output_directory, self.get_header_filename())

    def get_impl_filepath(self):
        return path.join(self.output_directory, self.get_impl_filename())


@dataclass
class SfgConfiguration:
Frederik Hennig's avatar
Frederik Hennig committed
    Configuration for the `SfgSourceFileGenerator`.

    The source file generator draws configuration from a total of four sources:

Frederik Hennig's avatar
Frederik Hennig committed
    - The default configuration (`pystencilssfg.configuration.DEFAULT_CONFIG`);
    - The project configuration;
    - Command-line arguments;
    - The user configuration passed to the constructor of `SourceFileGenerator`.

    They take precedence in the following way:

Frederik Hennig's avatar
Frederik Hennig committed
    - Project configuration overrides the default configuration
    - Command line arguments override the project configuration
    - User configuration overrides default and project configuration,
      and must not conflict with command-line arguments; otherwise, an error is thrown.
Frederik Hennig's avatar
Frederik Hennig committed
    **Project Configuration via Configurator Script**

    Currently, the only way to define the project configuration is via a configuration module.
    A configurator module is a Python file defining the following function at the top-level:

Frederik Hennig's avatar
Frederik Hennig committed
    .. code-block:: Python

        from pystencilssfg import SfgConfiguration
Frederik Hennig's avatar
Frederik Hennig committed
        def sfg_config() -> SfgConfiguration:
Frederik Hennig's avatar
Frederik Hennig committed
            return SfgConfiguration(
                # ...
            )

    The configuration module is passed to the code generation script via the command-line argument
    `--sfg-config-module`.
    """

    config_source: InitVar[SfgConfigSource | None] = None

Frederik Hennig's avatar
Frederik Hennig committed
    header_extension: str | None = None
    """File extension for generated header file."""
    impl_extension: str | None = None
    """File extension for generated implementation file."""
Frederik Hennig's avatar
Frederik Hennig committed
    output_mode: SfgOutputMode | None = None
    """The generator's output mode; defines which files to generate, and the set of legal file extensions."""
    outer_namespace: str | None = None
    """The outermost namespace in the generated file. May be a valid C++ nested namespace qualifier
Frederik Hennig's avatar
Frederik Hennig committed
    (like ``a::b::c``) or `None` if no outer namespace should be generated."""
Frederik Hennig's avatar
Frederik Hennig committed
    codestyle: SfgCodeStyle | None = None
    """Code style that should be used by the code generator."""

Frederik Hennig's avatar
Frederik Hennig committed
    output_directory: str | None = None
    """Directory to which the generated files should be written."""

    project_info: Any = None
    """Object for managing project-specific information. To be set by the configurator script."""

Frederik Hennig's avatar
Frederik Hennig committed
    def __post_init__(self, cfg_src: SfgConfigSource | None = None):
        if self.header_extension and self.header_extension[0] == ".":
            self.header_extension = self.header_extension[1:]

        if self.impl_extension and self.impl_extension[0] == ".":
            self.impl_extension = self.impl_extension[1:]
Frederik Hennig's avatar
Frederik Hennig committed
    def override(self, other: SfgConfiguration):
        other_dict: dict[str, Any] = {
            k: v for k, v in _shallow_dict(other).items() if v is not None
        }
Frederik Hennig's avatar
Frederik Hennig committed
        return replace(self, **other_dict)

    def get_output_spec(self, basename: str) -> SfgOutputSpec:
        assert self.header_extension is not None
        assert self.impl_extension is not None
        assert self.output_directory is not None

        return SfgOutputSpec(
            self.output_directory, basename, self.header_extension, self.impl_extension

DEFAULT_CONFIG = SfgConfiguration(
    config_source=SfgConfigSource.DEFAULT,
    header_extension="h",
    impl_extension="cpp",
Frederik Hennig's avatar
Frederik Hennig committed
    output_mode=SfgOutputMode.STANDALONE,
    outer_namespace=None,
    codestyle=SfgCodeStyle(),
    output_directory=".",
Frederik Hennig's avatar
Frederik Hennig committed
"""Default configuration for the `SourceFileGenerator`."""


def run_configurator(configurator_script: str):
    cfg_modulename = path.splitext(path.split(configurator_script)[1])[0]

    cfg_spec = iutil.spec_from_file_location(cfg_modulename, configurator_script)
Frederik Hennig's avatar
Frederik Hennig committed

    if cfg_spec is None:
        raise SfgConfigException(
            SfgConfigSource.PROJECT,
            f"Unable to load configurator script {configurator_script}",
        )
Frederik Hennig's avatar
Frederik Hennig committed
    configurator = iutil.module_from_spec(cfg_spec)
    cfg_spec.loader.exec_module(configurator)
Frederik Hennig's avatar
Frederik Hennig committed
    if not hasattr(configurator, "sfg_config"):
        raise SfgConfigException(
            SfgConfigSource.PROJECT,
            "Project configurator does not define function `sfg_config`.",
        )

    project_config = configurator.sfg_config()
    if not isinstance(project_config, SfgConfiguration):
        raise SfgConfigException(
            SfgConfigSource.PROJECT,
            "sfg_config did not return a SfgConfiguration object.",
        )
    return project_config
def add_config_args_to_parser(parser: ArgumentParser):
    config_group = parser.add_argument_group("Configuration")
    config_group.add_argument(
        "--sfg-output-dir", type=str, default=None, dest="output_directory"
    )
    config_group.add_argument(
        "--sfg-file-extensions",
        type=str,
        default=None,
        dest="file_extensions",
        help="Comma-separated list of file extensions",
    )
    config_group.add_argument(
Frederik Hennig's avatar
Frederik Hennig committed
        "--sfg-output-mode",
        type=str,
        default=None,
        choices=("standalone", "inline", "header-only"),
        dest="output_mode",
    )
    config_group.add_argument(
        "--sfg-config-module", type=str, default=None, dest="configurator_script"
    )
def config_from_parser_args(args):
    if args.configurator_script is not None:
        project_config = run_configurator(args.configurator_script)
    else:
        project_config = None

Frederik Hennig's avatar
Frederik Hennig committed
    if args.output_mode is not None:
        match args.output_mode.lower():
            case "standalone":
                output_mode = SfgOutputMode.STANDALONE
            case "inline":
                output_mode = SfgOutputMode.INLINE
            case "header-only":
                output_mode = SfgOutputMode.HEADER_ONLY
            case _:
                assert False, "invalid output mode"
    else:
        output_mode = None

    if args.file_extensions is not None:
        file_extentions = list(args.file_extensions.split(","))
        h_ext, src_ext = _get_file_extensions(
Frederik Hennig's avatar
Frederik Hennig committed
            SfgConfigSource.COMMANDLINE, file_extentions, output_mode
    else:
        h_ext, src_ext = None, None

    cmdline_config = SfgConfiguration(
        config_source=SfgConfigSource.COMMANDLINE,
        header_extension=h_ext,
        impl_extension=src_ext,
Frederik Hennig's avatar
Frederik Hennig committed
        output_mode=output_mode,
        output_directory=args.output_directory,
    return project_config, cmdline_config


Frederik Hennig's avatar
Frederik Hennig committed
def config_from_commandline(argv: list[str]):
    parser = ArgumentParser(
        "pystencilssfg",
        description="pystencils Source File Generator",
        allow_abbrev=False,
    )
    add_config_args_to_parser(parser)

    args, script_args = parser.parse_known_args(argv)
    project_config, cmdline_config = config_from_parser_args(args)

    return project_config, cmdline_config, script_args


def merge_configurations(
    project_config: SfgConfiguration | None,
    cmdline_config: SfgConfiguration | None,
    script_config: SfgConfiguration | None,
):
    #   Project config completely overrides default config
    config = DEFAULT_CONFIG

    if project_config is not None:
Frederik Hennig's avatar
Frederik Hennig committed
        config = config.override(project_config)

    if cmdline_config is not None:
        cmdline_dict = _shallow_dict(cmdline_config)
        #   Commandline config completely overrides project and default config
Frederik Hennig's avatar
Frederik Hennig committed
        config = config.override(cmdline_config)
    else:
        cmdline_dict = {}

    if script_config is not None:
        #   User config may only set values not specified on the command line
        script_dict = _shallow_dict(script_config)
        for key, cmdline_value in cmdline_dict.items():
            if cmdline_value is not None and script_dict[key] is not None:
                raise SfgException(
Frederik Hennig's avatar
Frederik Hennig committed
                    "Conflicting configuration:"
                    + f" Parameter {key} was specified both in the script and on the command line."
                )
Frederik Hennig's avatar
Frederik Hennig committed
        config = config.override(script_config)

    return config
Frederik Hennig's avatar
Frederik Hennig committed
def _get_file_extensions(
    cfgsrc: SfgConfigSource, extensions: Sequence[str], output_mode: SfgOutputMode
):
Frederik Hennig's avatar
Frederik Hennig committed
    h_ext = None
    src_ext = None

    extensions = tuple((ext[1:] if ext[0] == "." else ext) for ext in extensions)
Frederik Hennig's avatar
Frederik Hennig committed

    for ext in extensions:
        if ext in HEADER_FILE_EXTENSIONS:
            if h_ext is not None:
                raise SfgConfigException(
                    cfgsrc, "Multiple header file extensions specified."
                )
Frederik Hennig's avatar
Frederik Hennig committed
            h_ext = ext
Frederik Hennig's avatar
Frederik Hennig committed
        elif ext in IMPL_FILE_EXTENSIONS[output_mode]:
Frederik Hennig's avatar
Frederik Hennig committed
            if src_ext is not None:
                raise SfgConfigException(
                    cfgsrc, "Multiple source file extensions specified."
                )
Frederik Hennig's avatar
Frederik Hennig committed
            src_ext = ext
        else:
            raise SfgConfigException(
Frederik Hennig's avatar
Frederik Hennig committed
                cfgsrc, f"Invalid file extension '.{ext}' for output mode {output_mode}"
Frederik Hennig's avatar
Frederik Hennig committed

    return h_ext, src_ext


def _shallow_dict(obj):
    """Workaround to create a shallow dict of a dataclass object, see
    https://docs.python.org/3/library/dataclasses.html#dataclasses.asdict."""
    return dict((field.name, getattr(obj, field.name)) for field in fields(obj))