diff --git a/src/pystencilssfg/__init__.py b/src/pystencilssfg/__init__.py index b5ac38f9c9487c6b8caf77a72cda55ea7fc1e792..a888fc45ce84f318dfd51e92d74c04fda18d265a 100644 --- a/src/pystencilssfg/__init__.py +++ b/src/pystencilssfg/__init__.py @@ -1,4 +1,5 @@ from .configuration import SfgConfiguration, SfgOutputMode, SfgCodeStyle +from .config import SfgConfig from .generator import SourceFileGenerator from .composer import SfgComposer from .context import SfgContext @@ -6,6 +7,7 @@ from .lang import SfgVar, AugExpr from .exceptions import SfgException __all__ = [ + "SfgConfig", "SourceFileGenerator", "SfgComposer", "SfgConfiguration", diff --git a/src/pystencilssfg/config.py b/src/pystencilssfg/config.py index 051cf8dcedbd378851ad1f6cdfb70e8131f94a31..992233999a033b3596f722f950caba26206dacc4 100644 --- a/src/pystencilssfg/config.py +++ b/src/pystencilssfg/config.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Generic, TypeVar, Callable, Any +from argparse import ArgumentParser + +from types import ModuleType +from typing import Generic, TypeVar, Callable, Any, Sequence from abc import ABC from dataclasses import dataclass, fields, field from enum import Enum, auto @@ -104,7 +107,7 @@ class FileExtensions(ConfigBase): return ext -class SfgOutputMode(Enum): +class OutputMode(Enum): STANDALONE = auto() """Generate a header/implementation file pair (e.g. ``.hpp/.cpp``) where the implementation file will be compiled to a standalone object.""" @@ -124,6 +127,7 @@ class SfgOutputMode(Enum): @dataclass class CodeStyle(ConfigBase): indent_width: Option[int] = Option(2) + """The number of spaces successively nested blocks should be indented with""" code_style: Option[str] = Option("file") """Code style to be used by clang-format. Passed verbatim to `--style` argument of the clang-format CLI. @@ -153,7 +157,7 @@ class SfgConfig(ConfigBase): extensions: FileExtensions = field(default_factory=FileExtensions) """File extensions of the generated files""" - output_mode: Option[SfgOutputMode] = Option(SfgOutputMode.STANDALONE) + output_mode: Option[OutputMode] = Option(OutputMode.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) @@ -167,6 +171,158 @@ class SfgConfig(ConfigBase): """Directory to which the generated files should be written.""" +class CommandLineParameters: + @staticmethod + def add_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( + "--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="config_module_path" + ) + + return parser + + def __init__(self, args): + self._cl_config_module_path: str | None = args.config_module_path + + if args.output_mode is not None: + match args.output_mode.lower(): + case "standalone": + output_mode = OutputMode.STANDALONE + case "inline": + output_mode = OutputMode.INLINE + case "header-only": + output_mode = OutputMode.HEADER_ONLY + case _: + assert False, "invalid output mode" + else: + output_mode = None + + self._cl_output_mode = output_mode + + self._cl_output_dir: str | None = args.output_directory + + if args.file_extensions is not None: + file_extentions = list(args.file_extensions.split(",")) + h_ext, impl_ext = self._get_file_extensions(file_extentions) + self._cl_header_ext = h_ext + self._cl_impl_ext = impl_ext + else: + self._cl_header_ext = None + self._cl_impl_ext = None + + if self._cl_config_module_path is not None: + self._config_module = self._import_config_module( + self._cl_config_module_path + ) + else: + self._config_module = None + + @property + def configuration_module(self) -> ModuleType | None: + return self._config_module + + def get_config(self) -> SfgConfig: + cfg = SfgConfig() + if self._config_module is not None and hasattr( + self._config_module, "configure_sfg" + ): + self._config_module.configure_sfg(cfg) + + if self._cl_output_mode is not None: + cfg.output_mode = self._cl_output_mode + if self._cl_header_ext is not None: + cfg.extensions.header = self._cl_header_ext + if self._cl_impl_ext is not None: + cfg.extensions.impl = self._cl_impl_ext + if self._cl_output_dir is not None: + cfg.output_directory = self._cl_output_dir + + return cfg + + def find_conflicts(self, cfg: SfgConfig): + for name, mine, theirs in ( + ("output_mode", self._cl_output_mode, cfg.output_mode), + ("extensions.header", self._cl_header_ext, cfg.extensions.header), + ("extensions.impl", self._cl_impl_ext, cfg.extensions.impl), + ("output_directory", self._cl_output_dir, cfg.output_directory), + ): + if mine is not None and theirs is not None and mine != theirs: + raise SfgConfigException( + f"Conflicting values given for option {name} on command line and inside generator script.\n" + f" Value on command-line: {name}", + f" Value in script: {name}", + ) + + def get_project_info(self) -> Any: + if self._config_module is not None and hasattr( + self._config_module, "project_info" + ): + return self._config_module.project_info() + else: + return None + + def _get_file_extensions(self, extensions: Sequence[str]): + h_ext = None + src_ext = None + + extensions = tuple((ext[1:] if ext[0] == "." else ext) for ext in extensions) + + HEADER_FILE_EXTENSIONS = {"h", "hpp", "hxx", "h++", "cuh"} + IMPL_FILE_EXTENSIONS = {"c", "cpp", "cxx", "c++", "cu", ".impl.h", "ipp"} + + for ext in extensions: + if ext in HEADER_FILE_EXTENSIONS: + if h_ext is not None: + raise SfgConfigException( + "Multiple header file extensions specified." + ) + h_ext = ext + elif ext in IMPL_FILE_EXTENSIONS: + if src_ext is not None: + raise SfgConfigException( + "Multiple source file extensions specified." + ) + src_ext = ext + else: + raise SfgConfigException( + f"Invalid file extension: Don't know what to do with '.{ext}'" + ) + + return h_ext, src_ext + + def _import_config_module(self, module_path: str) -> ModuleType: + cfg_modulename = path.splitext(path.split(module_path)[1])[0] + + cfg_spec = iutil.spec_from_file_location(cfg_modulename, module_path) + + if cfg_spec is None: + raise SfgConfigException( + f"Unable to import configuration module {module_path}", + ) + + config_module = iutil.module_from_spec(cfg_spec) + cfg_spec.loader.exec_module(config_module) + return config_module + + def run_configuration_module(configurator_script: str) -> SfgConfig: """Run a configuration module. diff --git a/src/pystencilssfg/context.py b/src/pystencilssfg/context.py index bd3591889cbed6ab2a4bc023787944857585a601..17537a26706be5e5a9c93bc9cc5c09bb13c48dff 100644 --- a/src/pystencilssfg/context.py +++ b/src/pystencilssfg/context.py @@ -1,6 +1,6 @@ from typing import Generator, Sequence, Any -from .configuration import SfgCodeStyle +from .config import CodeStyle from .ir.source_components import ( SfgHeaderInclude, SfgKernelNamespace, @@ -45,7 +45,7 @@ class SfgContext: def __init__( self, outer_namespace: str | None = None, - codestyle: SfgCodeStyle = SfgCodeStyle(), + codestyle: CodeStyle | None = None, argv: Sequence[str] | None = None, project_info: Any = None, ): @@ -65,7 +65,7 @@ class SfgContext: self._outer_namespace = outer_namespace self._inner_namespace: str | None = None - self._codestyle = codestyle + self._codestyle = codestyle if codestyle is not None else CodeStyle() # Source Components self._prelude: str = "" @@ -121,7 +121,7 @@ class SfgContext: assert False @property - def codestyle(self) -> SfgCodeStyle: + def codestyle(self) -> CodeStyle: """The code style object for this generation context.""" return self._codestyle diff --git a/src/pystencilssfg/emission/__init__.py b/src/pystencilssfg/emission/__init__.py index 3280e459ff72a23bea06e711e5fb0aa73989cc74..fd666283edd12d49c02eb42eee90a5ae8f9756dd 100644 --- a/src/pystencilssfg/emission/__init__.py +++ b/src/pystencilssfg/emission/__init__.py @@ -1,5 +1,5 @@ -from .emitter import AbstractEmitter +from .emitter import AbstractEmitter, OutputSpec from .header_impl_pair import HeaderImplPairEmitter from .header_only import HeaderOnlyEmitter -__all__ = ["AbstractEmitter", "HeaderImplPairEmitter", "HeaderOnlyEmitter"] +__all__ = ["AbstractEmitter", "OutputSpec", "HeaderImplPairEmitter", "HeaderOnlyEmitter"] diff --git a/src/pystencilssfg/emission/emitter.py b/src/pystencilssfg/emission/emitter.py index 55fe43c337069e49d38ed27a85e2f33929dfdc54..e96e1e187bb55fcaca892c60f751649a90e4c6a7 100644 --- a/src/pystencilssfg/emission/emitter.py +++ b/src/pystencilssfg/emission/emitter.py @@ -1,9 +1,42 @@ from typing import Sequence from abc import ABC, abstractmethod +from dataclasses import dataclass +from os import path from ..context import SfgContext +@dataclass +class OutputSpec: + """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()) + + class AbstractEmitter(ABC): @property @abstractmethod diff --git a/src/pystencilssfg/emission/header_impl_pair.py b/src/pystencilssfg/emission/header_impl_pair.py index 8d2cd2cf437e0dfbfa8527d6f288d291c27e0a83..9e1e69729418240844f192777bdd609a054f3614 100644 --- a/src/pystencilssfg/emission/header_impl_pair.py +++ b/src/pystencilssfg/emission/header_impl_pair.py @@ -1,19 +1,18 @@ from typing import Sequence from os import path, makedirs -from ..configuration import SfgOutputSpec from ..context import SfgContext from .prepare import prepare_context from .printers import SfgHeaderPrinter, SfgImplPrinter from .clang_format import invoke_clang_format -from .emitter import AbstractEmitter +from .emitter import AbstractEmitter, OutputSpec class HeaderImplPairEmitter(AbstractEmitter): """Emits a header-implementation file pair.""" - def __init__(self, output_spec: SfgOutputSpec, inline_impl: bool = False): + def __init__(self, output_spec: OutputSpec, inline_impl: bool = False): """Create a `HeaderImplPairEmitter` from an [SfgOutputSpec][pystencilssfg.configuration.SfgOutputSpec].""" self._basename = output_spec.basename self._output_directory = output_spec.output_directory diff --git a/src/pystencilssfg/emission/header_only.py b/src/pystencilssfg/emission/header_only.py index 7347a61059ac48ad2f5b7c320aa3a2965c5b8452..169d2bc00240ac2898234aa657614e45ebe58857 100644 --- a/src/pystencilssfg/emission/header_only.py +++ b/src/pystencilssfg/emission/header_only.py @@ -1,17 +1,16 @@ from typing import Sequence from os import path, makedirs -from ..configuration import SfgOutputSpec from ..context import SfgContext from .prepare import prepare_context from .printers import SfgHeaderPrinter from .clang_format import invoke_clang_format -from .emitter import AbstractEmitter +from .emitter import AbstractEmitter, OutputSpec class HeaderOnlyEmitter(AbstractEmitter): - def __init__(self, output_spec: SfgOutputSpec): + def __init__(self, output_spec: OutputSpec): """Create a `HeaderImplPairEmitter` from an [SfgOutputSpec][pystencilssfg.configuration.SfgOutputSpec].""" self._basename = output_spec.basename self._output_directory = output_spec.output_directory diff --git a/src/pystencilssfg/generator.py b/src/pystencilssfg/generator.py index e7b0f5c24a71f0a41a15b559332e0d3d94817ad0..c2461800348c570a02b5cdc52de880d106b3a6c4 100644 --- a/src/pystencilssfg/generator.py +++ b/src/pystencilssfg/generator.py @@ -2,15 +2,10 @@ import sys import os from os import path -from .configuration import ( - SfgConfiguration, - SfgOutputMode, - config_from_commandline, - merge_configurations, -) +from .config import SfgConfig, CommandLineParameters, OutputMode, GLOBAL_NAMESPACE from .context import SfgContext from .composer import SfgComposer -from .emission import AbstractEmitter +from .emission import AbstractEmitter, OutputSpec class SourceFileGenerator: @@ -32,8 +27,8 @@ class SourceFileGenerator: sfg_config: User configuration for the code generator """ - def __init__(self, sfg_config: SfgConfiguration | None = None): - if sfg_config and not isinstance(sfg_config, SfgConfiguration): + def __init__(self, sfg_config: SfgConfig | None = None): + if sfg_config and not isinstance(sfg_config, SfgConfig): raise TypeError("sfg_config is not an SfgConfiguration.") import __main__ @@ -42,17 +37,27 @@ class SourceFileGenerator: scriptname = path.split(scriptpath)[1] basename = path.splitext(scriptname)[0] - project_config, cmdline_config, script_args = config_from_commandline(sys.argv) + from argparse import ArgumentParser - config = merge_configurations(project_config, cmdline_config, sfg_config) + parser = ArgumentParser( + scriptname, + description="Generator script using pystencils-sfg", + allow_abbrev=False, + ) + CommandLineParameters.add_args_to_parser(parser) + sfg_args, script_args = parser.parse_known_args() + cli_params = CommandLineParameters(sfg_args) - assert config.codestyle is not None + config = cli_params.get_config() + if sfg_config is not None: + cli_params.find_conflicts(sfg_config) + config.override(sfg_config) self._context = SfgContext( - config.outer_namespace, + None if config.outer_namespace is GLOBAL_NAMESPACE else config.outer_namespace, # type: ignore config.codestyle, argv=script_args, - project_info=config.project_info, + project_info=cli_params.get_project_info(), ) from pystencilssfg.ir import SfgHeaderInclude @@ -60,22 +65,29 @@ class SourceFileGenerator: self._context.add_include(SfgHeaderInclude("cstdint", system_header=True)) self._context.add_definition("#define RESTRICT __restrict__") + output_spec = OutputSpec( + config.get_option("output_directory"), + basename, + config.extensions.get_option("header"), + config.extensions.get_option("impl"), + ) + self._emitter: AbstractEmitter - match config.output_mode: - case SfgOutputMode.HEADER_ONLY: + match config.get_option("output_mode"): + case OutputMode.HEADER_ONLY: from .emission import HeaderOnlyEmitter - self._emitter = HeaderOnlyEmitter(config.get_output_spec(basename)) - case SfgOutputMode.INLINE: + self._emitter = HeaderOnlyEmitter(output_spec) + case OutputMode.INLINE: from .emission import HeaderImplPairEmitter self._emitter = HeaderImplPairEmitter( - config.get_output_spec(basename), inline_impl=True + output_spec, inline_impl=True ) - case SfgOutputMode.STANDALONE: + case OutputMode.STANDALONE: from .emission import HeaderImplPairEmitter - self._emitter = HeaderImplPairEmitter(config.get_output_spec(basename)) + self._emitter = HeaderImplPairEmitter(output_spec) def clean_files(self): for file in self._emitter.output_files: diff --git a/tests/generator/data/project_config.py b/tests/generator/data/project_config.py new file mode 100644 index 0000000000000000000000000000000000000000..73e5e9c6d52d979542b1f31137f8f31cf5e0c637 --- /dev/null +++ b/tests/generator/data/project_config.py @@ -0,0 +1,13 @@ +from pystencilssfg import SfgConfig + + +def configure_sfg(cfg: SfgConfig): + cfg.codestyle.code_style = "llvm" + cfg.codestyle.indent_width = 3 + cfg.output_directory = "generated_sources" + cfg.outer_namespace = "myproject" + cfg.extensions.header = "hpp" + + +magic_string = "Spam and eggs" +magic_number = 0xcafe diff --git a/tests/generator/test_config.py b/tests/generator/test_config.py index d7d38bd5020e583a2f74afe6178025665f2c7a23..879d47f71934c87d120a71cbb856f036e00ecbee 100644 --- a/tests/generator/test_config.py +++ b/tests/generator/test_config.py @@ -1,10 +1,21 @@ -from pystencilssfg.config import SfgConfig, SfgOutputMode, GLOBAL_NAMESPACE +from os import path + +from pystencilssfg import SourceFileGenerator +from pystencilssfg.config import ( + SfgConfig, + OutputMode, + GLOBAL_NAMESPACE, + CommandLineParameters, +) + + +DATA_DIR = path.join(path.split(__file__)[0], "data") def test_defaults(): cfg = SfgConfig() - assert cfg.get_option("output_mode") == SfgOutputMode.STANDALONE + assert cfg.get_option("output_mode") == OutputMode.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 @@ -42,3 +53,64 @@ def test_override(): assert cfg1.codestyle.indent_width is None assert cfg1.codestyle.code_style is None assert cfg1.codestyle.clang_format_binary == "bogus" + + +def test_from_commandline(): + from argparse import ArgumentParser + + parser = ArgumentParser() + CommandLineParameters.add_args_to_parser(parser) + + args = parser.parse_args( + ["--sfg-output-dir", ".out", "--sfg-file-extensions", ".h++,c++"] + ) + + cli_args = CommandLineParameters(args) + cfg = cli_args.get_config() + + assert cfg.output_directory == ".out" + assert cfg.extensions.header == "h++" + assert cfg.extensions.impl == "c++" + + config_module = path.join(DATA_DIR, "project_config.py") + args = parser.parse_args( + ["--sfg-output-dir", "gen_sources", "--sfg-config-module", config_module] + ) + cli_args = CommandLineParameters(args) + cfg = cli_args.get_config() + + assert cfg.codestyle.code_style == "llvm" + assert cfg.codestyle.indent_width == 3 + assert ( + cfg.output_directory == "gen_sources" + ) # value from config module overridden by commandline + assert cfg.outer_namespace == "myproject" + assert cfg.extensions.header == "hpp" + + assert cli_args.configuration_module is not None + assert cli_args.configuration_module.magic_string == "Spam and eggs" + assert cli_args.configuration_module.magic_number == 0xCAFE + + +def test_generator_config(monkeypatch, tmp_path): + import sys + + config_module = path.join(DATA_DIR, "project_config.py") + args = [ + "genscript.py", + "--sfg-file-extensions", + ".c++,.h++", + "--sfg-config-module", + config_module, + "test1", + "test2", + ] + monkeypatch.setattr(sys, "argv", args) + + cfg = SfgConfig(output_directory=str(tmp_path.absolute())) + + with SourceFileGenerator(cfg) as sfg: + ctx = sfg.context + + assert ctx.outer_namespace == "myproject" + assert ctx.argv == ["test1", "test2"]