diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ca7ae3ba9f695c20a0ea0e8292fa10773a7aa18..5331eca41f6a1ef142a74f923f3a79007e18d025 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/conftest.py b/conftest.py index e1d0cdda1b64ec9eff93367589fe3a08d975deb7..1c85902fdb05c4799fd18c2b4ac1710fca3ae933 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,5 @@ import pytest +from os import path @pytest.fixture(autouse=True) @@ -9,3 +10,11 @@ def prepare_composer(doctest_namespace): sfg = SfgComposer(SfgContext()) doctest_namespace["sfg"] = sfg + + +DATA_DIR = path.join(path.split(__file__)[0], "tests/data") + + +@pytest.fixture +def sample_config_module(): + return path.join(DATA_DIR, "project_config.py") diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..09b697c6051fd22dc5893980ac87e6a0d978cf50 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +**/generated diff --git a/docs/source/_templates/autosummary/entire_class.rst b/docs/source/_templates/autosummary/entire_class.rst new file mode 100644 index 0000000000000000000000000000000000000000..8643ade9ab822e8b1ffebc5f3f7ba96db5d0824f --- /dev/null +++ b/docs/source/_templates/autosummary/entire_class.rst @@ -0,0 +1,6 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: diff --git a/docs/source/_templates/autosummary/recursive_class.rst b/docs/source/_templates/autosummary/recursive_class.rst new file mode 100644 index 0000000000000000000000000000000000000000..4a8af04df02869e7af736e61f79fa9faa607c6c8 --- /dev/null +++ b/docs/source/_templates/autosummary/recursive_class.rst @@ -0,0 +1,26 @@ +{% extends "!autosummary/class.rst" %} + + {% block methods %} + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :toctree: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :toctree: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/source/api/generation.rst b/docs/source/api/generation.rst index f15b1170099cfb10c9f333cb6f4540137e162654..36b5ca03e52dabb2301a2ff0b72f5b9624d32fbb 100644 --- a/docs/source/api/generation.rst +++ b/docs/source/api/generation.rst @@ -5,13 +5,32 @@ Generator Script Interface .. autoclass:: pystencilssfg.SourceFileGenerator :members: -.. autoclass:: pystencilssfg.SfgConfiguration +Configuration +============= + +.. module:: pystencilssfg.config + +.. autoclass:: SfgConfig :members: + :exclude-members: __init__ + + +Categories, Parameter Types, and Special Values +----------------------------------------------- + +.. autoclass:: _GlobalNamespace +.. autodata:: GLOBAL_NAMESPACE -.. autoclass:: pystencilssfg.SfgOutputMode +.. autoclass:: OutputMode :members: -.. autoclass:: pystencilssfg.SfgCodeStyle +.. autoclass:: CodeStyle :members: -.. autoattribute:: pystencilssfg.configuration.DEFAULT_CONFIG +.. autoclass:: ClangFormatOptions + :members: + +Option Descriptors +------------------ + +.. autoclass:: Option diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst deleted file mode 100644 index 1ea987fdc32cc22a18c423c970d7bdfd0c8ca0cc..0000000000000000000000000000000000000000 --- a/docs/source/api/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -############# -API Reference -############# - -These pages provide a reference for the public API of *pystencils-sfg*. - -.. toctree:: - :maxdepth: 1 - - generation - composer - lang - ir - errors diff --git a/docs/source/conf.py b/docs/source/conf.py index c9e2cc3b0f04aa70619b8e10e7e0407c00f0145f..40d5a0fbccc11bddd40d0da96a3dc883b64cdc6e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,6 +27,7 @@ extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.napoleon", + "sphinx.ext.autosummary", "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", @@ -42,6 +43,10 @@ source_suffix = { } master_doc = "index" nitpicky = True +myst_enable_extensions = [ + "colon_fence", + "dollarmath" +] # -- Options for HTML output ------------------------------------------------- @@ -71,6 +76,7 @@ intersphinx_mapping = { autodoc_member_order = "bysource" autodoc_typehints = "description" +# autodoc_class_signature = "separated" # Doctest Setup diff --git a/docs/source/index.md b/docs/source/index.md index c65d57700d041c31de6b950d7d5b7a703b27d73d..4e93f0ce207f948be923a24d7943892bf4a48f7f 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -3,9 +3,24 @@ ```{toctree} :maxdepth: 1 :hidden: +:caption: User Guide -usage/index -api/index +usage/generator_scripts +usage/project_integration +usage/tips_n_tricks +``` + + +```{toctree} +:maxdepth: 1 +:hidden: +:caption: API Reference + +api/generation +api/composer +api/lang +api/ir +api/errors ``` [](https://i10git.cs.fau.de/pycodegen-/pystencils-sfg/commits/master) @@ -210,4 +225,34 @@ To `#include` them, add the prefix `gen/<target name>`: ``` For details on how to add *pystencils-sfg* to your CMake project, refer to -[CLI and Build System Integration](usage/cli_and_build_system.md). +[the project integration guide](#guide_project_integration). + +## Learn To Use pystencils-sfg + +Here is an overview of user guides for pystencils-sfg available on this site. +A basic understanding of [pystencils](https://pycodegen.pages.i10git.cs.fau.de/pystencils/index.html) +is required. + +```{card} Writing Generator Scripts +:link: guide:generator_scripts +:link-type: ref + +Learn about *generator scripts*, the primary usage idiom of *pystencils-sfg*: +Embedd *pystencils*-generated kernels into C++ source files and augment them with +arbitrary C++ glue code. +``` + +```{card} CLI and Build System Integration +:link: guide_project_integration +:link-type: ref + +Learn how to control code generation from the command line +and how to embedd *pystencils-sfg* into your build system. +``` + +```{card} Tips and Tricks +:link: guide:tips_n_tricks +:link-type: ref + +A collection of various tricks that might come in handy when working with *pystencils-sfg*. +``` diff --git a/docs/source/usage/cli_and_build_system.md b/docs/source/usage/cli_and_build_system.md deleted file mode 100644 index 910b6b630e4f7b4501dd2f4ca0958042f4d43eda..0000000000000000000000000000000000000000 --- a/docs/source/usage/cli_and_build_system.md +++ /dev/null @@ -1,78 +0,0 @@ -(guide:cli)= -# CLI and Build System - -## Command Line Interface - -*pystencils-sfg* exposes not one, but two command line interfaces: -The *global CLI* offers a few tools meant to be used by build systems, -while the *generator script* command line interface is meant for a build system to communicate -with the code generator during on-the-fly generation. - -### Global CLI - -The global CLI may be accessed either through the `sfg-cli` shell command, or using `python -m pystencilssfg`. - -### Generator Script CLI - -The [SourceFileGenerator][pystencilssfg.SourceFileGenerator] evaluates a generator script's command line arguments, -which can be supplied by the user, but more frequently by the build system. - -## CMake Integration - -*pystencils-sfg* is shipped with a CMake module for on-the-fly code generation during the CMake build process. - -### Add the module - -To include the module in your CMake source tree, a separate find module is provided. -You can use the global CLI to obtain the find module; simply run - -```shell -sfg-cli cmake make-find-module -``` - -to create the file `FindPystencilsSfg.cmake` in the current directory. -Add it to the CMake module path, and load the *pystencils-sfg* module via *find_package*: - -```CMake -find_package( PystencilsSfg ) -``` - -Make sure to set the `Python_ROOT_DIR` cache variable to point to the correct Python interpreter -(i.e. the virtual environment you have installed *pystencils-sfg* into). - -### Add generator scripts - -The primary interaction point in CMake is the function `pystencilssfg_generate_target_sources`, -with the following signature: - -```CMake -pystencilssfg_generate_target_sources( <target> - SCRIPTS script1.py [script2.py ...] - [DEPENDS dependency1.py [dependency2.py...]] - [FILE_EXTENSIONS <header-extension> <impl-extension>] - [HEADER_ONLY]) -``` - -It registers the generator scripts `script1.py [script2.py ...]` to be executed at compile time using `add_custom_command` -and adds their output files to the specified `<target>`. -Any changes in the generator scripts, or any listed dependency, will trigger regeneration. -The function takes the following options: - - - `SCRIPTS`: A list of generator scripts - - `DEPENDS`: A list of dependencies for the generator scripts - - `FILE_EXTENSION`: The desired extensions for the generated files - - `HEADER_ONLY`: Toggles header-only code generation - -### Include generated files - -The `pystencils-sfg` CMake module creates a subfolder `sfg_sources/gen` at the root of the build tree -and writes all generated source files into it. The directory `sfg_sources` is added to the project's include -path, such that generated header files for a target `<target>` may be included via: -```C++ -#include "gen/<target>/kernels.h" -``` - -### Project Configuration - -The *pystencils-sfg* CMake module reads the scoped variable `PystencilsSfg_CONFIGURATOR_SCRIPT` to find -the *configuration module* that should be passed to the generator scripts. diff --git a/docs/source/usage/examples/.gitignore b/docs/source/usage/examples/.gitignore index 26a5736a94d1260044161ac4963b05b3f3407025..97dbcc9ef5b156c49d997a8606b4a3e5ac3b8dd6 100644 --- a/docs/source/usage/examples/.gitignore +++ b/docs/source/usage/examples/.gitignore @@ -1,2 +1,3 @@ *.cpp -*.h \ No newline at end of file +*.h +*.hpp \ No newline at end of file diff --git a/docs/source/usage/examples/guide_generator_scripts/custom_cmdline_args/kernels.py b/docs/source/usage/examples/guide_generator_scripts/custom_cmdline_args/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..b5f2f2e4535c12fdbeceb3a9d8e5670480c982a6 --- /dev/null +++ b/docs/source/usage/examples/guide_generator_scripts/custom_cmdline_args/kernels.py @@ -0,0 +1,9 @@ +from pystencilssfg import SourceFileGenerator +from argparse import ArgumentParser + +parser = ArgumentParser() +# set up parser ... + +with SourceFileGenerator(keep_unknown_argv=True) as sfg: + args = parser.parse_args(sfg.context.argv) + ... diff --git a/docs/source/usage/examples/guide_generator_scripts/inline_config/kernels.py b/docs/source/usage/examples/guide_generator_scripts/inline_config/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..f8d3f6d224cebb9ddf8cfaf7ba293aa9ecad07a3 --- /dev/null +++ b/docs/source/usage/examples/guide_generator_scripts/inline_config/kernels.py @@ -0,0 +1,8 @@ +from pystencilssfg import SourceFileGenerator, SfgConfig + +cfg = SfgConfig() +cfg.output_directory = "gen_src" +cfg.codestyle.indent_width = 4 + +with SourceFileGenerator(cfg) as sfg: + ... diff --git a/docs/source/usage/generator_scripts.md b/docs/source/usage/generator_scripts.md index 57b9bcdf3e2aa56a942da0d884265cd669d445cc..d4086027ee9e2ec9f4b0bad226aad363f814e040 100644 --- a/docs/source/usage/generator_scripts.md +++ b/docs/source/usage/generator_scripts.md @@ -3,7 +3,7 @@ Writing generator scripts is the primary usage idiom of *pystencils-sfg*. A generator script is a Python script, say `kernels.py`, which contains *pystencils-sfg* -code at the top level that, when executed, emits source code to a pair of files `kernels.h` +code at the top level that, when executed, emits source code to a pair of files `kernels.hpp` and `kernels.cpp`. This guide describes how to write such a generator script, its structure, and how it can be used to generate code. @@ -23,14 +23,14 @@ To start, place the following code in a Python script, e.g. `kernels.py`: The source file is constructed within the context manager's managed region. During execution of the script, when the region ends, a header/source file pair -`kernels.h` and `kernels.cpp` will be written to disk next to your script. +`kernels.hpp` and `kernels.cpp` will be written to disk next to your script. Execute the script as-is and inspect the generated files, which will of course still be empty: ``````{dropdown} Generated Files `````{tab-set} -````{tab-item} kernels.h -```{literalinclude} examples/guide_generator_scripts/01/kernels.h +````{tab-item} kernels.hpp +```{literalinclude} examples/guide_generator_scripts/01/kernels.hpp ``` ```` @@ -41,31 +41,110 @@ Execute the script as-is and inspect the generated files, which will of course s ````` `````` -<!-- A few notes on configuration: - - - The [SourceFileGenerator](#pystencilssfg.SourceFileGenerator) parses the script's command line arguments - for configuration options (refer to [CLI and Build System Integration](cli_and_build_system.md)). - If you intend to evaluate command-line parameters inside your - generator script, read them from `sfg.context.argv` instead of `sys.argv`. - There, all arguments meant for the code generator are already removed. - - The code generator's configuration is consolidated from a global project configuration which may - be provided by the build system; a number of command line arguments; and the - [SfgConfiguration](#pystencilssfg.SfgConfiguration) provided in the script. - The project configuration may safely be overridden by the latter two; however, conflicts - between command-line arguments and the configuration defined in the script will cause - an exception to be thrown. --> - ## Using the Composer The object `sfg` constructed in above snippet is an instance of [SfgComposer](#pystencilssfg.composer.SfgComposer). The composer is the central part of the user front-end of *pystencils-sfg*. It provides an interface for constructing source files that closely mimics C++ syntactic structures within Python. -Here is an overview of its various functions: + +::::{dropdown} Composer API Overview +```{eval-rst} +.. currentmodule:: pystencilssfg.composer +``` + +Structure and Verbatim Code: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.prelude + SfgBasicComposer.include + SfgBasicComposer.namespace + SfgBasicComposer.code + SfgBasicComposer.define_once +``` + +Kernels and Kernel Namespaces: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.kernels + SfgBasicComposer.kernel_namespace + SfgBasicComposer.kernel_function +``` + +Function definition, parameters, and header inclusion: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.function + SfgBasicComposer.params + SfgBasicComposer.require +``` + +Variables, expressions, and variable initialization: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.var + SfgBasicComposer.vars + SfgBasicComposer.expr + SfgBasicComposer.init + + SfgBasicComposer.map_field + SfgBasicComposer.set_param +``` + +Parameter mappings: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.set_param + SfgBasicComposer.map_field + SfgBasicComposer.map_vector +``` + +Control Flow: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.branch + SfgBasicComposer.switch +``` + +Kernel Invocation: + +```{eval-rst} + +.. autosummary:: + :nosignatures: + + SfgBasicComposer.call + SfgBasicComposer.cuda_invoke +``` +:::: ### Includes and Definitions -With [`SfgComposer.include`](#pystencilssfg.composer.SfgBasicComposer.include), the code generator can be instructed to include header files. +With {any}`include <SfgBasicComposer.include>`, the code generator can be instructed to include header files. As in C++, you can use the `<>` delimiters for system headers, and omit them for project headers. `````{tab-set} @@ -75,8 +154,8 @@ As in C++, you can use the `<>` delimiters for system headers, and omit them for ``` ```` -````{tab-item} kernels.h -```{literalinclude} examples/guide_generator_scripts/02/kernels.h +````{tab-item} kernels.hpp +```{literalinclude} examples/guide_generator_scripts/02/kernels.hpp ``` ```` @@ -112,8 +191,8 @@ the [`sfg.kernel_namespace`](#pystencilssfg.composer.SfgBasicComposer.kernel_nam ``` ```` -````{tab-item} kernels.h -```{literalinclude} examples/guide_generator_scripts/03/kernels.h +````{tab-item} kernels.hpp +```{literalinclude} examples/guide_generator_scripts/03/kernels.hpp ``` ```` @@ -141,8 +220,8 @@ Use `sfg.function` to create a function, and `sfg.call` to call a kernel: ``` ```` -````{tab-item} kernels.h -```{literalinclude} examples/guide_generator_scripts/04/kernels.h +````{tab-item} kernels.hpp +```{literalinclude} examples/guide_generator_scripts/04/kernels.hpp ``` ```` @@ -171,8 +250,8 @@ such as, for example, `std::span` or `std::vector`, like this: ``` ```` -````{tab-item} kernels.h -```{literalinclude} examples/guide_generator_scripts/05/kernels.h +````{tab-item} kernels.hpp +```{literalinclude} examples/guide_generator_scripts/05/kernels.hpp ``` ```` @@ -192,3 +271,77 @@ The pystencils-sfg provides modelling support for a number of C++ standard libra (see {any}`pystencilssfg.lang.cpp.std`). It also provides the necessary infrastructure for modelling the data structures of any C++ framework in a similar manner. + + +## Configuration and Invocation + +There are several ways to affect the behavior and output of a generator script. +For one, the `SourceFileGenerator` itself may be configured from the combination of three +different configuration sources: + +- **Inline Configuration:** The generator script may set up an {any}`SfgConfig` object, + which is passed to the `SourceFileGenerator` at its creation; see [Inline Configuration](#inline_config) +- **Command-Line Options:** The `SourceFileGenerator` parses the command line arguments of + the generator script to set some of its configuration options; see [Command-Line Options](#cmdline_options) +- **Project Configuration:** When embedded into a larger project, using a build system such as CMake, generator scripts + may be configured globally within that project by the use of a *configuration module*. + Settings specified inside that configuration module are always overridden by the former to configuration sources. + For details on configuration modules, refer to the guide on [Project and Build System Integration](#guide_project_integration). + +(inline_config)= +### Inline Configuration + +To configure the source file generator within your generator script, import the {any}`SfgConfig` from `pystencilssfg`. +You may then set up the configuration object before passing it to the `SourceFileGenerator` constructor. +To illustrate, the following snippet alters the code indentation width and changes the output directory +of the generator script to `gen_src`: + +```{literalinclude} examples/guide_generator_scripts/inline_config/kernels.py +``` + +(cmdline_options)= +### Command-Line Options + +The `SourceFileGenerator` consumes a number of command-line parameters that may be passed to the script +on invocation. These include: + +- `--sfg-output-dir <path>`: Set the output directory of the generator script. This corresponds to {any}`SfgConfig.output_directory`. +- `--sfg-file-extensions <exts>`: Set the file extensions used for the generated files; + `exts` must be a comma-separated list not containing any spaces. Corresponds to {any}`SfgConfig.extensions`. +- `--sfg-output-mode <mode>`: Set the output mode of the generator script. Corresponds to {any}`SfgConfig.output_mode`. + +If any configuration option is set to conflicting values on the command line and in the inline configuration, +the generator script will terminate with an error. + +You may examine the full set of possible command line parameters by invoking a generator script +with the `--help` flag: + +```bash +$ python kernels.py --help +``` + +## Adding Custom Command-Line Options + +Sometimes, you might want to add your own command-line options to a generator script +in order to affect its behavior from the shell, +for instance by using {any}`argparse` to set up an argument parser. +If you parse your options directly from {any}`sys.argv`, +as {any}`parse_args <argparse.ArgumentParser.parse_args>` does by default, +your parser will also receive any options meant for the `SourceFileGenerator`. +To filter these out of the argument list, +pass the additional option `keep_unknown_argv=True` to your `SourceFileGenerator`. +This will instruct it to store any unknown command line arguments into `sfg.context.argv`, +where you can then retrieve them from and pass on to your custom parser: + +```{literalinclude} examples/guide_generator_scripts/custom_cmdline_args/kernels.py +``` + +Any SFG-specific arguments will already have been filtered out of this argument list. +As a consequence of the above, if the generator script is invoked with a typo in some SFG-specific argument, +which the `SourceFileGenerator` therefore does not recognize, +that argument will be passed on to your downstream parser instead. + +:::{important} +If you do *not* pass on `sfg.context.argv` to a downstream parser, make sure that `keep_unknown_argv` is set to +`False` (which is the default), such that typos or illegal arguments will not be ignored. +::: diff --git a/docs/source/usage/index.md b/docs/source/usage/index.md deleted file mode 100644 index 5783e9ebe71faade1b69dd233595eb8dcb3710ce..0000000000000000000000000000000000000000 --- a/docs/source/usage/index.md +++ /dev/null @@ -1,38 +0,0 @@ -# Usage Guides - -```{toctree} -:maxdepth: 1 -:hidden: - -generator_scripts -cli_and_build_system -tips_n_tricks -``` - -These pages provide an overview of how to use the pystencils Source File Generator. -A basic understanding of [pystencils](https://pycodegen.pages.i10git.cs.fau.de/pystencils/index.html) -is required. - -```{card} Writing Generator Scripts -:link: guide:generator_scripts -:link-type: ref - -Learn about *generator scripts*, the primary usage idiom of *pystencils-sfg*: -Embedd *pystencils*-generated kernels into C++ source files and augment them with -arbitrary C++ glue code. -``` - -```{card} CLI and Build System Integration -:link: guide:cli -:link-type: ref - -Learn how to control code generation from the command line -and how to embedd *pystencils-sfg* into your build system. -``` - -```{card} Tips and Tricks -:link: guide:tips_n_tricks -:link-type: ref - -A collection of various tricks that might come in handy when working with *pystencils-sfg*. -``` diff --git a/docs/source/usage/project_integration.md b/docs/source/usage/project_integration.md new file mode 100644 index 0000000000000000000000000000000000000000..19809a616175bee796a435b440951dffc07199b2 --- /dev/null +++ b/docs/source/usage/project_integration.md @@ -0,0 +1,120 @@ +(guide_project_integration)= +# Project and Build System Integration + +(config_module)= +## Project-Wide Settings using Configuration Modules + +When embedding *pystencils-sfg* into a C++ project or build system, +you might want to set a project-wide base configuration for all generator scripts. +In addition, it might be necessary to pass various details about the project +and build setup to the generator scripts. +Both can be achieved by the use of a *configuration module*. + +A configuration module is a Python file that defines up to two functions: +- `def configure_sfg(cfg: SfgConfig)` is called to set up the project-wide base configuration. + It takes an {any}`SfgConfig` object which it may modify to establish the project-wide option set. +- `def project_info() -> Any` is called by *pystencils-sfg* to retrieve an object that encapsulates + any custom project-specific information. + This information is passed on to the generator scripts through + the {any}`sfg.context.project_info <SfgContext.project_info>` attribute. + +An example configuration module might look like this: + +```Python +from pystencilssfg import SfgConfig + +def configure_sfg(cfg: SfgConfig): + cfg.extensions.header = "h++" + cfg.extensions.impl = "c++" + cfg.clang_format.code_style = "llvm" + ... + +def project_info(): + return { + "project_name": "my-project", + "float_precision": "float32", + "use_cuda": False, + ... + } +``` + +Here, `project_info` returns a dictionary, but this is just for illustration; +the function may return any type of arbitrarily complex objects. +For improved API safety, {any}`dataclasses` might be a good tool for setting up +project info objects. + +When invoking a generator script, the path to the current configuration module must be passed to it +using the `--sfg-config-module` command-line parameter. +This can be automated by an adequately set up build system, such as GNU Make or CMake. + +If you are using pystencils-sfg with CMake through the provided CMake module, +[see below](#cmake_set_config_module) on how to specify a configuration module for your project. + +(cmake_integration)= +## CMake Integration + +*pystencils-sfg* is shipped with a CMake module for on-the-fly code generation during the CMake build process. + +### Add the module + +To include the module in your CMake source tree, a separate find module is provided. +You can use the global CLI to obtain the find module; simply run + +```shell +sfg-cli cmake make-find-module +``` + +to create the file `FindPystencilsSfg.cmake` in the current directory. +Add it to the CMake module path, and load the *pystencils-sfg* module via *find_package*: + +```CMake +find_package( PystencilsSfg ) +``` + +Make sure to set the `Python_ROOT_DIR` cache variable to point to the correct Python interpreter +(i.e. the virtual environment you have installed *pystencils-sfg* into). + +### Add generator scripts + +The primary interaction point in CMake is the function `pystencilssfg_generate_target_sources`, +with the following signature: + +```CMake +pystencilssfg_generate_target_sources( <target> + SCRIPTS script1.py [script2.py ...] + [DEPENDS dependency1.py [dependency2.py...]] + [FILE_EXTENSIONS <header-extension> <impl-extension>] + [OUTPUT_MODE <standalone|inline|header-only>] +) +``` + +It registers the generator scripts `script1.py [script2.py ...]` to be executed at compile time using `add_custom_command` +and adds their output files to the specified `<target>`. +Any changes in the generator scripts, or any listed dependency, will trigger regeneration. +The function takes the following options: + + - `SCRIPTS`: A list of generator scripts + - `DEPENDS`: A list of dependencies for the generator scripts + - `FILE_EXTENSION`: The desired extensions for the generated files + - `OUTPUT_MODE`: Sets the output mode of the code generator; see {any}`SfgConfig.output_mode`. + +### Include generated files + +The `pystencils-sfg` CMake module creates a subfolder `sfg_sources/gen` at the root of the build tree +and writes all generated source files into it. The directory `sfg_sources` is added to the project's include +path, such that generated header files for a target `<target>` may be included via: +```C++ +#include "gen/<target>/kernels.h" +``` + +(cmake_set_config_module)= +### Set a Configuration Module + +To specify a [configuration module](#config_module) for your project, +set the scoped variable `PystencilsSfg_CONFIG_MODULE` to point at the respective Python file. +The pystencils-sfg CMake system will then pass that module to each generator script invocation. + +You might want to populate your configuration module with information about the current +build setup and environment. +For this purpose, take a look at the +[configure_file](https://cmake.org/cmake/help/latest/command/configure_file.html) CMake function. diff --git a/integration/CMakeDemo/CMakeLists.txt b/integration/CMakeDemo/CMakeLists.txt index 6642f40e63368d7ad60b4dd602eaab8e737c1c71..b69ff9aec9fe93af6d5364edb80cff67a51a5ba0 100644 --- a/integration/CMakeDemo/CMakeLists.txt +++ b/integration/CMakeDemo/CMakeLists.txt @@ -11,7 +11,7 @@ execute_process( COMMAND sfg-cli cmake make-find-module find_package( PystencilsSfg REQUIRED ) -set( PystencilsSfg_CONFIGURATOR_SCRIPT codegen_config.py ) +set( PystencilsSfg_CONFIG_MODULE codegen_config.py ) add_library( genlib ) pystencilssfg_generate_target_sources( genlib SCRIPTS kernels.py FILE_EXTENSIONS .h .cpp ) diff --git a/integration/CMakeDemo/codegen_config.py b/integration/CMakeDemo/codegen_config.py index 2f1231a95d4050b994235b4dd56e964819d774c0..8162ae460328ab0229f422c3ec9d63522106a78b 100644 --- a/integration/CMakeDemo/codegen_config.py +++ b/integration/CMakeDemo/codegen_config.py @@ -1,16 +1,6 @@ -from sys import stderr -from pystencilssfg import SfgConfiguration +from pystencilssfg import SfgConfig -def sfg_config(): - print("sfg_config() called!", file=stderr) - project_info = { - 'B': 'A' - } - - return SfgConfiguration( - header_extension='hpp', - impl_extension='cpp', - outer_namespace='cmake_demo', - project_info=project_info - ) +def configure(cfg: SfgConfig): + cfg.extensions.header = "h++" + cfg.extensions.impl = "c++" diff --git a/pyproject.toml b/pyproject.toml index 48795fa3ab5b83ba7bd54b191a2531c00031ddeb..0a3cde6b11c7e282bb05bff349cb75c21f7bec80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,6 @@ requires = [ build-backend = "setuptools.build_meta" [project.optional-dependencies] -interactive = [ - "ipython>=8.17.2", -] testing = [ "flake8>=6.1.0", "mypy>=1.7.0", @@ -39,7 +36,8 @@ docs = [ "sphinx_design", "sphinx_autodoc_typehints", "sphinx-copybutton", - "packaging" + "packaging", + "clang-format" ] [tool.versioneer] @@ -51,4 +49,8 @@ tag_prefix = "v" parentdir_prefix = "pystencilssfg-" [tool.coverage.run] -include = ["src/pystencilssfg/*"] +omit = [ + "setup.py", + "src/pystencilssfg/_version.py", + "integration/*" +] diff --git a/pytest.ini b/pytest.ini index 8eb047cc9f4e18d995e09941d9e5d30a2ef0ff41..7dc0d3812dee41d0511ea766ec4e8b032760bf60 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,10 @@ 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 + --ignore=tests/generator_scripts/config + --ignore=tests/data doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL diff --git a/src/pystencilssfg/__init__.py b/src/pystencilssfg/__init__.py index b5ac38f9c9487c6b8caf77a72cda55ea7fc1e792..b2def3b84ba3eab0706b095878a9c165322c1d8b 100644 --- a/src/pystencilssfg/__init__.py +++ b/src/pystencilssfg/__init__.py @@ -1,16 +1,16 @@ -from .configuration import SfgConfiguration, SfgOutputMode, SfgCodeStyle -from .generator import SourceFileGenerator +from .config import SfgConfig +from .generator import SourceFileGenerator, GLOBAL_NAMESPACE, OutputMode from .composer import SfgComposer from .context import SfgContext from .lang import SfgVar, AugExpr from .exceptions import SfgException __all__ = [ + "SfgConfig", + "GLOBAL_NAMESPACE", + "OutputMode", "SourceFileGenerator", "SfgComposer", - "SfgConfiguration", - "SfgOutputMode", - "SfgCodeStyle", "SfgContext", "SfgVar", "AugExpr", diff --git a/src/pystencilssfg/cli.py b/src/pystencilssfg/cli.py index e5bdb4d815f97cb5dcfb58208341ff91c26609ce..3b321c2d0be13b906384701080b8de870647d507 100644 --- a/src/pystencilssfg/cli.py +++ b/src/pystencilssfg/cli.py @@ -4,13 +4,8 @@ from os import path from argparse import ArgumentParser, BooleanOptionalAction -from .configuration import ( - SfgConfigException, - SfgConfigSource, - add_config_args_to_parser, - config_from_parser_args, - merge_configurations, -) +from .config import CommandLineParameters, SfgConfigException, OutputMode +from .emission import OutputSpec def add_newline_arg(parser): @@ -39,7 +34,7 @@ def cli_main(program="sfg-cli"): ) outfiles_parser.set_defaults(func=list_files) - add_config_args_to_parser(outfiles_parser) + CommandLineParameters.add_args_to_parser(outfiles_parser) add_newline_arg(outfiles_parser) outfiles_parser.add_argument( "--sep", type=str, default=" ", dest="sep", help="Separator for list items" @@ -79,21 +74,18 @@ def version(args): def list_files(args): - try: - project_config, cmdline_config = config_from_parser_args(args) - except SfgConfigException as exc: - abort_with_config_exception(exc) - - config = merge_configurations(project_config, cmdline_config, None) + cli_params = CommandLineParameters(args) + config = cli_params.get_config() _, scriptname = path.split(args.codegen_script) basename = path.splitext(scriptname)[0] - from .emission import HeaderImplPairEmitter - - emitter = HeaderImplPairEmitter(config.get_output_spec(basename)) + output_spec = OutputSpec.create(config, basename) + output_files = [output_spec.get_header_filepath()] + if config.output_mode != OutputMode.HEADER_ONLY: + output_files.append(output_spec.get_impl_filepath()) - print(args.sep.join(emitter.output_files), end=os.linesep if args.newline else "") + print(args.sep.join(output_files), end=os.linesep if args.newline else "") exit(0) @@ -112,18 +104,6 @@ def make_cmake_find_module(args): exit(0) -def abort_with_config_exception(exception: SfgConfigException): - def eprint(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - - match exception.config_source: - case SfgConfigSource.PROJECT: - eprint( - f"Invalid project configuration: {exception.message}\nCheck your configurator script." - ) - case SfgConfigSource.COMMANDLINE: - eprint(f"Invalid configuration on command line: {exception.message}") - case _: - assert False, "(Theoretically) unreachable code. Contact the developers." - +def abort_with_config_exception(exception: SfgConfigException, source: str): + print(f"Invalid {source} configuration: {exception.args[0]}.", file=sys.stderr) exit(1) diff --git a/src/pystencilssfg/cmake/modules/PystencilsSfg.cmake b/src/pystencilssfg/cmake/modules/PystencilsSfg.cmake index deb97ce841f394ddf4ff31f2e326ce18f91706cd..c42a251fda1c4201023e8150f9e35c4427e532c8 100644 --- a/src/pystencilssfg/cmake/modules/PystencilsSfg.cmake +++ b/src/pystencilssfg/cmake/modules/PystencilsSfg.cmake @@ -54,11 +54,22 @@ function(pystencilssfg_generate_target_sources TARGET) endif() if(DEFINED PystencilsSfg_CONFIGURATOR_SCRIPT) + message(AUTHOR_WARNING "The variable PystencilsSfg_CONFIGURATOR_SCRIPT is deprecated. Set PystencilsSfg_CONFIG_MODULE instead.") cmake_path(ABSOLUTE_PATH PystencilsSfg_CONFIGURATOR_SCRIPT OUTPUT_VARIABLE configscript) list(APPEND generatorArgs "--sfg-config-module=${configscript}") list(APPEND _pssfg_DEPENDS ${configscript}) endif() + if(DEFINED PystencilsSfg_CONFIG_MODULE) + if(DEFINED PystencilsSfg_CONFIGURATOR_SCRIPT) + message(FATAL_ERROR "At most one of PystencilsSfg_CONFIGURATOR_SCRIPT and PystencilsSfg_CONFIG_MODULE may be set.") + endif() + + cmake_path(ABSOLUTE_PATH PystencilsSfg_CONFIG_MODULE OUTPUT_VARIABLE config_module) + list(APPEND generatorArgs "--sfg-config-module=${config_module}") + list(APPEND _pssfg_DEPENDS ${config_module}) + endif() + if(DEFINED _pssfg_FILE_EXTENSIONS) string(JOIN "," extensionsString ${_pssfg_FILE_EXTENSIONS}) diff --git a/src/pystencilssfg/composer/basic_composer.py b/src/pystencilssfg/composer/basic_composer.py index 15177e6d768e3e568d8f4d30785d87344520b4b4..d54659626d6c23f36d24b62ea59bdb5e04c7470b 100644 --- a/src/pystencilssfg/composer/basic_composer.py +++ b/src/pystencilssfg/composer/basic_composer.py @@ -319,6 +319,7 @@ class SfgBasicComposer(SfgIComposer): return SfgFunctionParams([x.as_variable() for x in args]) def require(self, *includes: str | SfgHeaderInclude) -> SfgRequireIncludes: + """Use inside a function body to require the inclusion of headers.""" return SfgRequireIncludes( list(SfgHeaderInclude.parse(incl) for incl in includes) ) @@ -443,6 +444,7 @@ class SfgBasicComposer(SfgIComposer): return SfgBranchBuilder() def switch(self, switch_arg: ExprLike) -> SfgSwitchBuilder: + """Use inside a function to construct a switch-case statement.""" return SfgSwitchBuilder(switch_arg) def map_field( diff --git a/src/pystencilssfg/config.py b/src/pystencilssfg/config.py new file mode 100644 index 0000000000000000000000000000000000000000..aef3e78e1ab1488bb4cab3da1e3822e26d2c080e --- /dev/null +++ b/src/pystencilssfg/config.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +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 +from os import path +from importlib import util as iutil + + +class SfgConfigException(Exception): ... # noqa: E701 + + +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 | None = None, + 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 | None: + return self._default + + def get(self, obj) -> Option_T | None: + 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) + + def __delete__(self, obj): + delattr(obj, self._lookup) + + +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 f in fields(self): # type: ignore + fvalue = getattr(self, f.name) + if isinstance(fvalue, ConfigBase): # type: ignore + fvalue.override(getattr(other, f.name)) + else: + new_val = getattr(other, f.name) + if new_val is not None: + setattr(self, f.name, new_val) + + +@dataclass +class FileExtensions(ConfigBase): + """Option category containing output file extensions.""" + + header: Option[str] = Option("hpp") + """File extension for generated header file.""" + + impl: Option[str] = Option() + """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 OutputMode(Enum): + """Output mode of the source file generator.""" + + 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 CodeStyle(ConfigBase): + """Options affecting the code style used by the source file generator.""" + + indent_width: Option[int] = Option(2) + """The number of spaces successively nested blocks should be indented with""" + + # TODO possible future options: + # - newline before opening { + # - trailing return types + + def indent(self, s: str): + from textwrap import indent + + prefix = " " * self.get_option("indent_width") + return indent(s, prefix) + + +@dataclass +class ClangFormatOptions(ConfigBase): + """Options affecting the invocation of ``clang-format`` for automatic code formatting.""" + + 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: Option[bool] = Option(False) + """If set to ``True``, abort code generation if ``clang-format`` binary cannot be found.""" + + skip: Option[bool] = Option(False) + """If set to ``True``, skip formatting using ``clang-format``.""" + + binary: Option[str] = Option("clang-format") + """Path to the clang-format executable""" + + @force.validate + def _validate_force(self, val: bool) -> bool: + if val and self.skip: + raise SfgConfigException( + "Cannot set both `clang_format.force` and `clang_format.skip` at the same time" + ) + return val + + @skip.validate + def _validate_skip(self, val: bool) -> bool: + if val and self.force: + raise SfgConfigException( + "Cannot set both `clang_format.force` and `clang_format.skip` at the same time" + ) + return val + + +class _GlobalNamespace: ... # noqa: E701 + + +GLOBAL_NAMESPACE = _GlobalNamespace() +"""Indicates the C++ global namespace.""" + + +@dataclass +class SfgConfig(ConfigBase): + """Configuration options for the `SourceFileGenerator`.""" + + extensions: FileExtensions = field(default_factory=FileExtensions) + """File extensions of the generated files + + Options in this category: + .. autosummary:: + FileExtensions.header + FileExtensions.impl + """ + + output_mode: Option[OutputMode] = Option(OutputMode.STANDALONE) + """The generator's output mode; defines which files to generate, and the set of legal file extensions. + + Possible parameters: + .. autosummary:: + OutputMode.STANDALONE + OutputMode.INLINE + OutputMode.HEADER_ONLY + """ + + 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. + + .. autosummary:: + GLOBAL_NAMESPACE + """ + + codestyle: CodeStyle = field(default_factory=CodeStyle) + """Options affecting the code style emitted by pystencils-sfg. + + Options in this category: + .. autosummary:: + CodeStyle.indent_width + """ + + clang_format: ClangFormatOptions = field(default_factory=ClangFormatOptions) + """Options governing the code style used by the code generator + + Options in this category: + .. autosummary:: + ClangFormatOptions.code_style + ClangFormatOptions.force + ClangFormatOptions.skip + ClangFormatOptions.binary + """ + + output_directory: Option[str] = Option(".") + """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) -> None: + 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 + + self._config_module: ModuleType | 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.strip() for ext in extensions) + 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) # type: ignore + return config_module diff --git a/src/pystencilssfg/configuration.py b/src/pystencilssfg/configuration.py deleted file mode 100644 index a76251a04cde0d875b0ba41908a0fadc5fc90553..0000000000000000000000000000000000000000 --- a/src/pystencilssfg/configuration.py +++ /dev/null @@ -1,384 +0,0 @@ -# mypy: strict_optional=False - -from __future__ import annotations - -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 - -from importlib import util as iutil - -from .exceptions import SfgException - - -class SfgConfigSource(Enum): - DEFAULT = auto() - PROJECT = auto() - COMMANDLINE = auto() - SCRIPT = auto() - - -class SfgConfigException(Exception): - 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 - """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) - - -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: - """ - Configuration for the `SfgSourceFileGenerator`. - - The source file generator draws configuration from a total of four sources: - - - 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: - - - 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. - - **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: - - .. code-block:: Python - - from pystencilssfg import SfgConfiguration - - def sfg_config() -> SfgConfiguration: - # ... - 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 - - header_extension: str | None = None - """File extension for generated header file.""" - - impl_extension: str | None = None - """File extension for generated implementation file.""" - - 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 - (like ``a::b::c``) or `None` if no outer namespace should be generated.""" - - codestyle: SfgCodeStyle | None = None - """Code style that should be used by the code generator.""" - - 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.""" - - 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:] - - 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 - } - 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", - output_mode=SfgOutputMode.STANDALONE, - outer_namespace=None, - codestyle=SfgCodeStyle(), - output_directory=".", -) -"""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) - - if cfg_spec is None: - raise SfgConfigException( - SfgConfigSource.PROJECT, - f"Unable to load configurator script {configurator_script}", - ) - - configurator = iutil.module_from_spec(cfg_spec) - cfg_spec.loader.exec_module(configurator) - - 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( - "--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" - ) - - return parser - - -def config_from_parser_args(args): - if args.configurator_script is not None: - project_config = run_configurator(args.configurator_script) - else: - project_config = None - - 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( - 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, - output_mode=output_mode, - output_directory=args.output_directory, - ) - - return project_config, cmdline_config - - -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: - 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 - 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( - "Conflicting configuration:" - + f" Parameter {key} was specified both in the script and on the command line." - ) - - config = config.override(script_config) - - return config - - -def _get_file_extensions( - cfgsrc: SfgConfigSource, extensions: Sequence[str], output_mode: SfgOutputMode -): - h_ext = None - src_ext = None - - extensions = tuple((ext[1:] if ext[0] == "." else ext) for ext in extensions) - - for ext in extensions: - if ext in HEADER_FILE_EXTENSIONS: - if h_ext is not None: - raise SfgConfigException( - cfgsrc, "Multiple header file extensions specified." - ) - h_ext = ext - elif ext in IMPL_FILE_EXTENSIONS[output_mode]: - if src_ext is not None: - raise SfgConfigException( - cfgsrc, "Multiple source file extensions specified." - ) - src_ext = ext - else: - raise SfgConfigException( - cfgsrc, f"Invalid file extension '.{ext}' for output mode {output_mode}" - ) - - 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)) 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/clang_format.py b/src/pystencilssfg/emission/clang_format.py index eea152a062474a6761fdbbdecfaaeb88bb63c4d3..53540d3c4698aa1e16bb846e41f8bb7f8c87a35a 100644 --- a/src/pystencilssfg/emission/clang_format.py +++ b/src/pystencilssfg/emission/clang_format.py @@ -1,11 +1,11 @@ import subprocess import shutil -from ..configuration import SfgCodeStyle +from ..config import ClangFormatOptions from ..exceptions import SfgException -def invoke_clang_format(code: str, codestyle: SfgCodeStyle) -> str: +def invoke_clang_format(code: str, options: ClangFormatOptions) -> str: """Call the `clang-format` command-line tool to format the given code string according to the given style arguments. @@ -24,13 +24,15 @@ def invoke_clang_format(code: str, codestyle: SfgCodeStyle) -> str: be executed (binary not found, or error during exection), the function will throw an exception. """ - if codestyle.skip_clang_format: + if options.get_option("skip"): return code - args = [codestyle.clang_format_binary, f"--style={codestyle.code_style}"] + binary = options.get_option("binary") + force = options.get_option("force") + args = [binary, f"--style={options.code_style}"] - if not shutil.which(codestyle.clang_format_binary): - if codestyle.force_clang_format: + if not shutil.which(binary): + if force: raise SfgException( "`force_clang_format` was set to true in code style, " "but clang-format binary could not be found." @@ -41,7 +43,7 @@ def invoke_clang_format(code: str, codestyle: SfgCodeStyle) -> str: result = subprocess.run(args, input=code, capture_output=True, text=True) if result.returncode != 0: - if codestyle.force_clang_format: + if force: raise SfgException(f"Call to clang-format failed: \n{result.stderr}") else: return code diff --git a/src/pystencilssfg/emission/emitter.py b/src/pystencilssfg/emission/emitter.py index 55fe43c337069e49d38ed27a85e2f33929dfdc54..c32b18af351579b98477bf688c0789a394fc3732 100644 --- a/src/pystencilssfg/emission/emitter.py +++ b/src/pystencilssfg/emission/emitter.py @@ -1,7 +1,63 @@ +from __future__ import annotations + from typing import Sequence from abc import ABC, abstractmethod +from dataclasses import dataclass +from os import path from ..context import SfgContext +from ..config import SfgConfig, OutputMode + + +@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()) + + @staticmethod + def create(config: SfgConfig, basename: str) -> OutputSpec: + output_mode = config.get_option("output_mode") + header_extension = config.extensions.get_option("header") + impl_extension = config.extensions.get_option("impl") + + if impl_extension is None: + match output_mode: + case OutputMode.INLINE: + impl_extension = "ipp" + case OutputMode.STANDALONE: + impl_extension = "cpp" + + return OutputSpec( + config.get_option("output_directory"), + basename, + header_extension, + impl_extension, + ) class AbstractEmitter(ABC): diff --git a/src/pystencilssfg/emission/header_impl_pair.py b/src/pystencilssfg/emission/header_impl_pair.py index 8d2cd2cf437e0dfbfa8527d6f288d291c27e0a83..d70cfd255e5df6d01169eea50fe4ac87bba37bdf 100644 --- a/src/pystencilssfg/emission/header_impl_pair.py +++ b/src/pystencilssfg/emission/header_impl_pair.py @@ -1,19 +1,24 @@ 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 ..config import ClangFormatOptions -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, + clang_format: ClangFormatOptions | None = None, + ): """Create a `HeaderImplPairEmitter` from an [SfgOutputSpec][pystencilssfg.configuration.SfgOutputSpec].""" self._basename = output_spec.basename self._output_directory = output_spec.output_directory @@ -22,6 +27,7 @@ class HeaderImplPairEmitter(AbstractEmitter): self._inline_impl = inline_impl self._ospec = output_spec + self._clang_format = clang_format @property def output_files(self) -> Sequence[str]: @@ -42,8 +48,9 @@ class HeaderImplPairEmitter(AbstractEmitter): header = header_printer.get_code() impl = impl_printer.get_code() - header = invoke_clang_format(header, ctx.codestyle) - impl = invoke_clang_format(impl, ctx.codestyle) + if self._clang_format is not None: + header = invoke_clang_format(header, self._clang_format) + impl = invoke_clang_format(impl, self._clang_format) makedirs(self._output_directory, exist_ok=True) diff --git a/src/pystencilssfg/emission/header_only.py b/src/pystencilssfg/emission/header_only.py index 7347a61059ac48ad2f5b7c320aa3a2965c5b8452..ce6ce90f609f110153c5d5ae753d0d511540ab18 100644 --- a/src/pystencilssfg/emission/header_only.py +++ b/src/pystencilssfg/emission/header_only.py @@ -1,23 +1,26 @@ 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 ..config import ClangFormatOptions 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, clang_format: ClangFormatOptions | None = None + ): """Create a `HeaderImplPairEmitter` from an [SfgOutputSpec][pystencilssfg.configuration.SfgOutputSpec].""" self._basename = output_spec.basename self._output_directory = output_spec.output_directory self._header_filename = output_spec.get_header_filename() self._ospec = output_spec + self._clang_format = clang_format @property def output_files(self) -> Sequence[str]: @@ -29,7 +32,8 @@ class HeaderOnlyEmitter(AbstractEmitter): header_printer = SfgHeaderPrinter(ctx, self._ospec) header = header_printer.get_code() - header = invoke_clang_format(header, ctx.codestyle) + if self._clang_format is not None: + header = invoke_clang_format(header, self._clang_format) makedirs(self._output_directory, exist_ok=True) diff --git a/src/pystencilssfg/emission/printers.py b/src/pystencilssfg/emission/printers.py index c562bf7bca5d59365c3a1cd5b9d77b8f7e7920f5..6daccfa0c5edab18fa4c509ad8eb379a5f163204 100644 --- a/src/pystencilssfg/emission/printers.py +++ b/src/pystencilssfg/emission/printers.py @@ -7,7 +7,6 @@ from pystencils import KernelFunction from pystencils.backend.emission import emit_code from ..context import SfgContext -from ..configuration import SfgOutputSpec from ..visitors import visitor from ..exceptions import SfgException @@ -25,6 +24,8 @@ from ..ir.source_components import ( SfgVisibilityBlock, ) +from .emitter import OutputSpec + def interleave(*iters): try: @@ -71,7 +72,7 @@ class SfgGeneralPrinter: class SfgHeaderPrinter(SfgGeneralPrinter): def __init__( - self, ctx: SfgContext, output_spec: SfgOutputSpec, inline_impl: bool = False + self, ctx: SfgContext, output_spec: OutputSpec, inline_impl: bool = False ): self._output_spec = output_spec self._ctx = ctx @@ -180,7 +181,7 @@ class SfgHeaderPrinter(SfgGeneralPrinter): class SfgImplPrinter(SfgGeneralPrinter): def __init__( - self, ctx: SfgContext, output_spec: SfgOutputSpec, inline_impl: bool = False + self, ctx: SfgContext, output_spec: OutputSpec, inline_impl: bool = False ): self._output_spec = output_spec self._ctx = ctx diff --git a/src/pystencilssfg/generator.py b/src/pystencilssfg/generator.py index aa8396c4ecae7471c333a14587435ac3f35eec3a..cc9cd6252604cd9bedb42a995c7c242a61e117d4 100644 --- a/src/pystencilssfg/generator.py +++ b/src/pystencilssfg/generator.py @@ -1,25 +1,17 @@ -# TODO -# mypy strict_optional=False - -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 +from .exceptions import SfgException class SourceFileGenerator: """Context manager that controls the code generation process in generator scripts. - **Usage:** The `SourceFileGenerator` must be used as a context manager by calling it within + The `SourceFileGenerator` must be used as a context manager by calling it within a ``with`` statement in the top-level code of a generator script (see :ref:`guide:generator_scripts`). Upon entry to its context, it creates an :class:`SfgComposer` which can be used to populate the generated files. When the managed region finishes, the code files are generated and written to disk at the locations @@ -27,35 +19,59 @@ class SourceFileGenerator: Existing copies of the target files are deleted on entry to the managed region, and if an exception occurs within the managed region, no files are exported. - **Configuration:** The `SourceFileGenerator` optionally takes a user-defined configuration - object which is merged with configuration obtained from the build system; for details - on configuration sources, refer to :class:`SfgConfiguration`. - Args: - sfg_config: User configuration for the code generator + sfg_config: Inline configuration for the code generator + keep_unknown_argv: If `True`, any command line arguments given to the generator script + that the `SourceFileGenerator` does not understand are stored in + `sfg.context.argv`. """ - 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, keep_unknown_argv: bool = False + ): + if sfg_config and not isinstance(sfg_config, SfgConfig): raise TypeError("sfg_config is not an SfgConfiguration.") import __main__ + if not hasattr(__main__, "__file__"): + raise SfgException( + "Invalid execution environment: " + "It seems that you are trying to run the `SourceFileGenerator` in an environment " + "without a valid entry point, such as a REPL or a multiprocessing fork." + ) + scriptpath = __main__.__file__ 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 + + parser = ArgumentParser( + scriptname, + description="Generator script using pystencils-sfg", + allow_abbrev=False, + ) + CommandLineParameters.add_args_to_parser(parser) + + if keep_unknown_argv: + sfg_args, script_args = parser.parse_known_args() + else: + sfg_args = parser.parse_args() + script_args = [] - config = merge_configurations(project_config, cmdline_config, sfg_config) + 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 @@ -63,22 +79,29 @@ class SourceFileGenerator: self._context.add_include(SfgHeaderInclude("cstdint", system_header=True)) self._context.add_definition("#define RESTRICT __restrict__") + output_mode = config.get_option("output_mode") + output_spec = OutputSpec.create(config, basename) + self._emitter: AbstractEmitter - match config.output_mode: - case SfgOutputMode.HEADER_ONLY: + match 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, clang_format=config.clang_format + ) + case OutputMode.INLINE: from .emission import HeaderImplPairEmitter self._emitter = HeaderImplPairEmitter( - config.get_output_spec(basename), inline_impl=True + output_spec, inline_impl=True, clang_format=config.clang_format ) - case SfgOutputMode.STANDALONE: + case OutputMode.STANDALONE: from .emission import HeaderImplPairEmitter - self._emitter = HeaderImplPairEmitter(config.get_output_spec(basename)) + self._emitter = HeaderImplPairEmitter( + output_spec, clang_format=config.clang_format + ) def clean_files(self): for file in self._emitter.output_files: diff --git a/tests/data/project_config.py b/tests/data/project_config.py new file mode 100644 index 0000000000000000000000000000000000000000..03c11b60868e9231e14eff2bba5fa88765564019 --- /dev/null +++ b/tests/data/project_config.py @@ -0,0 +1,22 @@ +from pystencilssfg import SfgConfig + + +def configure_sfg(cfg: SfgConfig): + cfg.codestyle.indent_width = 3 + cfg.clang_format.code_style = "llvm" + cfg.clang_format.skip = True + cfg.output_directory = "generated_sources" + cfg.outer_namespace = "myproject" + cfg.extensions.header = "hpp" + + +magic_string = "Spam and eggs" +magic_number = 0xcafe + + +def project_info(): + return { + "use_openmp": True, + "use_cuda": True, + "float_format": "float32" + } diff --git a/tests/generator/test_config.py b/tests/generator/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..4485dc22e639b185b8b0756ae6d69f92af5e45e8 --- /dev/null +++ b/tests/generator/test_config.py @@ -0,0 +1,110 @@ +import pytest + +from pystencilssfg.config import ( + SfgConfig, + OutputMode, + GLOBAL_NAMESPACE, + CommandLineParameters, + SfgConfigException +) + + +def test_defaults(): + cfg = SfgConfig() + + 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.clang_format.get_option("binary") == "clang-format" + assert cfg.clang_format.get_option("code_style") == "file" + assert cfg.get_option("outer_namespace") is GLOBAL_NAMESPACE + + cfg.extensions.impl = ".cu" + assert cfg.extensions.get_option("impl") == "cu" + + # Check that section subobjects of different config objects are independent + # -> must use default_factory to construct them, because they are mutable! + cfg.clang_format.binary = "bogus" + + cfg2 = SfgConfig() + assert cfg2.clang_format.binary is None + + +def test_override(): + cfg1 = SfgConfig() + cfg1.outer_namespace = "test" + cfg1.extensions.header = "h" + cfg1.extensions.impl = "c" + cfg1.clang_format.force = True + + cfg2 = SfgConfig() + cfg2.outer_namespace = GLOBAL_NAMESPACE + cfg2.extensions.header = "hpp" + cfg2.extensions.impl = "cpp" + cfg2.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.indent_width is None + assert cfg1.clang_format.force is True + assert cfg1.clang_format.code_style is None + assert cfg1.clang_format.binary == "bogus" + + +def test_sanitation(): + cfg = SfgConfig() + + cfg.extensions.header = ".hxx" + assert cfg.extensions.header == "hxx" + + cfg.extensions.header = ".cxx" + assert cfg.extensions.header == "cxx" + + cfg.clang_format.force = True + with pytest.raises(SfgConfigException): + cfg.clang_format.skip = True + + cfg.clang_format.force = False + cfg.clang_format.skip = True + with pytest.raises(SfgConfigException): + cfg.clang_format.force = True + + +def test_from_commandline(sample_config_module): + 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++" + + args = parser.parse_args( + ["--sfg-output-dir", "gen_sources", "--sfg-config-module", sample_config_module] + ) + cli_args = CommandLineParameters(args) + cfg = cli_args.get_config() + + assert cfg.codestyle.indent_width == 3 + assert cfg.clang_format.code_style == "llvm" + assert cfg.clang_format.skip is True + 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 diff --git a/tests/generator_scripts/config/TestConfigModule_cfg.py b/tests/generator_scripts/config/TestConfigModule_cfg.py new file mode 100644 index 0000000000000000000000000000000000000000..e118b43e3a10ff131d63accb748400b8298af85b --- /dev/null +++ b/tests/generator_scripts/config/TestConfigModule_cfg.py @@ -0,0 +1,14 @@ +from pystencilssfg import SfgConfig + + +def configure_sfg(cfg: SfgConfig): + cfg.outer_namespace = "myproject" + cfg.codestyle.indent_width = 3 + + +def project_info(): + return { + "use_openmp": True, + "use_cuda": True, + "float_format": "float32", + } diff --git a/tests/generator_scripts/scripts/Structural.py b/tests/generator_scripts/scripts/Structural.py index e8cf2ab0d87ecf770871c6ef2d15deb7820b4561..7cfe352910b676429b97cb8f3d29bec68b74810a 100644 --- a/tests/generator_scripts/scripts/Structural.py +++ b/tests/generator_scripts/scripts/Structural.py @@ -1,9 +1,8 @@ -from pystencilssfg import SourceFileGenerator, SfgConfiguration, SfgCodeStyle +from pystencilssfg import SourceFileGenerator, SfgConfig # Do not use clang-format, since it reorders headers -cfg = SfgConfiguration( - codestyle=SfgCodeStyle(skip_clang_format=True) -) +cfg = SfgConfig() +cfg.clang_format.skip = True with SourceFileGenerator(cfg) as sfg: sfg.prelude("Expect the unexpected, and you shall never be surprised.") diff --git a/tests/generator_scripts/scripts/TestConfigModule.py b/tests/generator_scripts/scripts/TestConfigModule.py new file mode 100644 index 0000000000000000000000000000000000000000..35b41923ac7a2defd5dc5b459939e046f48f1ce4 --- /dev/null +++ b/tests/generator_scripts/scripts/TestConfigModule.py @@ -0,0 +1,15 @@ +from pystencilssfg import SourceFileGenerator + +with SourceFileGenerator() as sfg: + ctx = sfg.context + + assert ctx.outer_namespace == "myproject" + assert ctx.codestyle.indent_width == 3 + + assert not ctx.argv + assert isinstance(ctx.project_info, dict) + assert ctx.project_info == { + "use_openmp": True, + "use_cuda": True, + "float_format": "float32", + } diff --git a/tests/generator_scripts/scripts/TestExtraCommandLineArgs.py b/tests/generator_scripts/scripts/TestExtraCommandLineArgs.py new file mode 100644 index 0000000000000000000000000000000000000000..2cf989321a160c714ee6ecb972061359d40d1439 --- /dev/null +++ b/tests/generator_scripts/scripts/TestExtraCommandLineArgs.py @@ -0,0 +1,6 @@ +from pystencilssfg import SourceFileGenerator + +with SourceFileGenerator(keep_unknown_argv=True) as sfg: + ctx = sfg.context + + assert ctx.argv == ["--precision", "float32", "test1", "test2"] diff --git a/tests/generator_scripts/scripts/TestIllegalArgs.py b/tests/generator_scripts/scripts/TestIllegalArgs.py new file mode 100644 index 0000000000000000000000000000000000000000..ce0a0cbf4dd29f36e508d33b48c3e68e4adaaf58 --- /dev/null +++ b/tests/generator_scripts/scripts/TestIllegalArgs.py @@ -0,0 +1,4 @@ +from pystencilssfg import SourceFileGenerator + +with SourceFileGenerator() as sfg: + ... diff --git a/tests/generator_scripts/scripts/Variables.py b/tests/generator_scripts/scripts/Variables.py index 9fd4e0027104451738abea72e15084047cf4465e..bcc85e4cba1b75364b500628b8b10fdb24a7470c 100644 --- a/tests/generator_scripts/scripts/Variables.py +++ b/tests/generator_scripts/scripts/Variables.py @@ -1,7 +1,6 @@ -import sympy as sp from pystencils import TypedSymbol, fields, kernel -from pystencilssfg import SourceFileGenerator, SfgConfiguration +from pystencilssfg import SourceFileGenerator with SourceFileGenerator() as sfg: α = TypedSymbol("alpha", "float32") diff --git a/tests/generator_scripts/test_generator_scripts.py b/tests/generator_scripts/test_generator_scripts.py index 51e05c01f2cae3a637e235e4a94d1585b390a5af..16adba2ec1ff9731f799609a5635cc49f3de5a0f 100644 --- a/tests/generator_scripts/test_generator_scripts.py +++ b/tests/generator_scripts/test_generator_scripts.py @@ -9,6 +9,7 @@ import subprocess THIS_DIR = path.split(__file__)[0] SCRIPTS_DIR = path.join(THIS_DIR, "scripts") +CONFIG_DIR = path.join(THIS_DIR, "config") EXPECTED_DIR = path.join(THIS_DIR, "expected") @@ -30,6 +31,12 @@ class ScriptInfo: Output files will all be placed in the ``out`` folder. """ + args: tuple[str, ...] = () + """Command-line arguments to be passed to the generator script""" + + should_fail: bool = False + """Whether the exeuction of this script should fail.""" + compilable_output: str | None = None """File extension of the output file that can be compiled. @@ -50,10 +57,41 @@ When adding new generator scripts to the `scripts` directory, do not forget to include them here. """ SCRIPTS = [ - ScriptInfo.make("Structural", ("h", "cpp")), - ScriptInfo.make("SimpleJacobi", ("h", "cpp"), compilable_output="cpp"), - ScriptInfo.make("SimpleClasses", ("h", "cpp")), - ScriptInfo.make("Variables", ("h", "cpp"), compilable_output="cpp"), + ScriptInfo.make( + "TestConfigModule", + ("h++", "c++"), + args=( + "--sfg-file-extensions", + ".c++,.h++", + "--sfg-config-module", + path.join(CONFIG_DIR, "TestConfigModule_cfg.py"), + ), + ), + ScriptInfo.make( + "TestIllegalArgs", + ("h++", "c++"), + args=( + "--sfg-file-extensionss", + ".c++,.h++", + ), + should_fail=True + ), + ScriptInfo.make( + "TestExtraCommandLineArgs", + ("h++", "c++"), + args=( + "--sfg-file-extensions", + ".c++,.h++", + "--precision", + "float32", + "test1", + "test2" + ), + ), + ScriptInfo.make("Structural", ("hpp", "cpp")), + ScriptInfo.make("SimpleJacobi", ("hpp", "cpp"), compilable_output="cpp"), + ScriptInfo.make("SimpleClasses", ("hpp", "cpp")), + ScriptInfo.make("Variables", ("hpp", "cpp"), compilable_output="cpp"), ] @@ -75,12 +113,17 @@ def test_generator_script(script_info: ScriptInfo): shutil.rmtree(output_dir) os.makedirs(output_dir, exist_ok=True) - args = ["python", script_file, "--sfg-output-dir", output_dir] + args = ["python", script_file, "--sfg-output-dir", output_dir] + list(script_info.args) result = subprocess.run(args) + if script_info.should_fail: + if result.returncode == 0: + pytest.fail(f"Generator script {script_name} was supposed to fail, but didn't.") + return + if result.returncode != 0: - raise AssertionError(f"Generator script {script_name} failed.") + pytest.fail(f"Generator script {script_name} failed.") # Check generated files expected_files = set( diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..d3c2585453627e7dc706083f4b1508160ea2f09c --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,68 @@ +import subprocess + + +def test_list_files(): + output_dir = "/my/output/directory" + args = [ + "sfg-cli", + "list-files", + "--sfg-output-dir", + output_dir, + "--sfg-file-extensions", + "cu, cuh", + "genscript.py", + ] + + result = subprocess.run(args, capture_output=True, text=True) + + assert result.returncode == 0 + assert ( + result.stdout + == "/my/output/directory/genscript.cuh /my/output/directory/genscript.cu\n" + ) + + +def test_list_files_headeronly(): + output_dir = "/my/output/directory" + args = [ + "python", "-m", "pystencilssfg", + "list-files", + "--sfg-output-dir", + output_dir, + "--sfg-output-mode", + "header-only", + "genscript.py", + ] + + result = subprocess.run(args, capture_output=True, text=True) + + assert result.returncode == 0 + assert result.stdout == "/my/output/directory/genscript.hpp\n" + + +def test_list_files_with_config_module(sample_config_module): + args = [ + "sfg-cli", + "list-files", + "--sfg-config-module", + sample_config_module, + "genscript.py", + ] + + result = subprocess.run(args, capture_output=True, text=True) + + assert result.returncode == 0 + assert ( + result.stdout + == "generated_sources/genscript.hpp generated_sources/genscript.cpp\n" + ) + + +def test_make_find_module(tmp_path): + args = ["sfg-cli", "cmake", "make-find-module"] + + result = subprocess.run(args, cwd=str(tmp_path)) + assert result.returncode == 0 + + expected_path = tmp_path / "FindPystencilsSfg.cmake" + assert expected_path.exists()