Verified Commit 3eca3f6b authored by Alberto Miranda's avatar Alberto Miranda ♨️
Browse files

First implementation

parent 63fed60f
Loading
Loading
Loading
Loading

.gitignore

0 → 100644
+3 −0
Original line number Diff line number Diff line
.idea
__pycache__
*.egg-info

genopts/__init__.py

0 → 100644
+2 −0
Original line number Diff line number Diff line
# This file is here just so that 'genopts' becomes a package and we can import it in tests
# The script itself is standalone

genopts/genopts.py

0 → 100755
+589 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# Copyright 2022, Barcelona Supercomputing Center (BSC), Spain
#
# This software was partially supported by the EuroHPC-funded project ADMIRE
#   (Project ID: 956748, https://www.admire-eurohpc.eu).
#
# This file is part of genopts.
#
# genopts is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
#
# genopts is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along with genopts.  If not, see
# <https://www.gnu.org/licenses/>.
#
#  SPDX-License-Identifier: GPL-3.0-or-later

import argparse
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional, Any, Dict, List

import yaml
from cerberus import Validator
from cerberus.errors import ValidationError as CerberusValidationError

VERSION = "0.1.0"

TEMPLATES: Dict[str, str] = {
    'schema': """\
#ifndef {header_guard}
#define {header_guard}

#include <filesystem>

#include "file_options/file_options.hpp"
#include "parsers.hpp"
#include "keywords.hpp"
#include "defaults.hpp"

namespace fs = std::filesystem;

{namespace_begin}
using file_options::converter;
using file_options::declare_file;
using file_options::declare_group;
using file_options::declare_list;
using file_options::declare_option;
using file_options::file_schema;
using file_options::opt_type;
using file_options::sec_type;

const file_schema valid_options = declare_file({{
    {sections}
}});
{namespace_end}

#endif /* {header_guard} */
""",

    'keywords': """\
#ifndef {header_guard}
#define {header_guard}

{namespace_begin}
{section_keywords}
{option_keywords}
{namespace_end}

#endif /* {header_guard} */
"""}

# Validation schema for a file_option in OPT_DESC_FILE
OPTION_SCHEMA = {
    'type': 'dict',
    'schema': {
        # the name for the option
        'name': {
            'type': 'string',
            'required': True
        },
        # is the option mandatory?
        'required': {
            'type': 'boolean',
            'required': True
        },
        # the option type
        'type': {
            'type': 'string',
            'required': True,
            # 'allowed': ['bool', 'int', 'double', 'string']
        },
        # an optional converter function
        'converter': {
            'type': 'string'
        }
    }
}

# Validation schema for a section in OPT_DESC_FILE
SECTION_SCHEMA = {
    'type': 'dict',
    'schema': {
        # the name of the section
        'name': {
            'type': 'string',
            'required': True,
        },
        # is the section mandatory?
        'required': {
            'type': 'boolean',
            'required': True
        },

        # the list of options for this section
        'options': {
            'type': 'list',
            'schema': OPTION_SCHEMA
        }
    }
}

# Validation schema for the genopts option description file
OPT_DESC_FILE_SCHEMA = {
    'sections': {
        'type': 'list',
        'schema': SECTION_SCHEMA
    },
}

# Validation schema for the genopts configuration file
CONFIG_SCHEMA = {
    'genopts': {
        'type': 'dict',
        'required': True,
        'schema': {
            'schema': {
                'type': 'dict',
                'schema': {
                    'copyright_file': {'type': 'string'},
                    'header_guard': {'type': 'string', 'required': True},
                    'namespace': {'type': 'string'},
                    'output_path': {
                        'type': 'dict',
                        'schema': {
                            'header': {'type': 'string'},
                        }
                    }
                }
            },
            'keywords': {
                'type': 'dict',
                'schema': {
                    'copyright_file': {'type': 'string'},
                    'header_guard': {'type': 'string', 'required': True},
                    'namespace': {'type': 'string'},
                    'output_path': {
                        'type': 'dict',
                        'schema': {
                            'header': {'type': 'string'},
                        }
                    }
                }
            }
        }
    }
}


class ValidationError(Exception):
    def __init__(self, message: str, errors: Iterable[CerberusValidationError]):
        self.message = message
        self.errors = errors

    def __str__(self):
        return (f"{self.message}\n" +
                f"Cerberus errors: {self.errors}")


class GeneratorError(ValidationError):
    pass


class ConfigError(ValidationError):
    pass


class Namespace(ABC):
    namespace: Iterable[str]

    def __init__(self, namespace: str):
        self.namespace = namespace.split('::')

    @abstractmethod
    def begin(self) -> str:
        return NotImplemented

    @abstractmethod
    def end(self) -> str:
        return NotImplemented


class Cxx14Namespace(Namespace):

    def begin(self) -> str:
        return '\n'.join(f'namespace {n} {{' for n in self.namespace)

    def end(self) -> str:
        return '\n'.join(f'}} // namespace {n}' for n in reversed(self.namespace))


class Cxx17Namespace(Namespace):

    def begin(self) -> str:
        return f"namespace {'::'.join(self.namespace)} {{"

    def end(self) -> str:
        return f"}} // namespace {'::'.join(self.namespace)}"


class NamespaceFactory:
    NAMESPACES_BY_NAME = {
        "c++17": Cxx17Namespace,
        "c++14": Cxx14Namespace,
    }

    @staticmethod
    def create_namespace(namespace: str, name: str) -> Namespace:
        if name not in NamespaceFactory.NAMESPACES_BY_NAME.keys():
            raise ValueError(f"Invalid output language: {name}")
        return NamespaceFactory.NAMESPACES_BY_NAME[name](namespace)


@dataclass
class OutputPath:
    header: Optional[Path]
    implementation: Optional[Path]


class OutputConfig:
    namespace: Namespace
    output_path: OutputPath
    header_guard: Optional[str]
    copyright_text: Optional[str]

    def __init__(self,
                 namespace: Namespace,
                 output_path: OutputPath,
                 header_guard: Optional[str],
                 copyright_file: Optional[str]):
        self.namespace = namespace
        self.header_guard = header_guard
        self.output_path = output_path
        self.copyright_text = Path(copyright_file).read_text() if copyright_file else ''

    def __repr__(self):
        return f"OutputConfig(" \
               f"namespace={self.namespace}, " \
               f"header_guard='{self.header_guard}', " \
               f"output_path='{self.output_path}', " \
               f"copyright_text='{repr(self.copyright_text)}'" \
               f")"


class Config:
    output_lang: str
    schema_config: OutputConfig
    keywords_config: OutputConfig
    keys: Iterable[str] = ['schema', 'keywords']

    def __init__(self,
                 output_lang: str,
                 schema_config: OutputConfig,
                 keywords_config: OutputConfig):
        self.output_lang = output_lang
        self.schema_config = schema_config
        self.keywords_config = keywords_config

    def __repr__(self):
        return f"Config(" \
               f"output_lang='{self.output_lang}', " \
               f"schema_config='{self.schema_config}', " \
               f"keywords_config={self.keywords_config})"


class Option:
    name: str
    type: str
    required: bool
    converter: Optional[str]
    template: str = """\
declare_option<{type}>(
    keywords::{name},
    opt_type::{required},
    converter<{type}>({converter}))
"""

    def __init__(self, name: str, type: str, required: bool, converter: Optional[str] = None):
        self.name = name
        self.type = type
        self.required = required
        self.converter = converter

    def __repr__(self) -> str:
        return f"Option(name='{self.name}', type='{self.type}', required={self.required}, converter={self.converter})"

    def __str__(self) -> str:
        return self.template.format(name=self.name, type=self.type,
                                    required="mandatory" if self.required else "optional",
                                    converter=self.converter)


class Section:
    name: str
    required: bool
    options: Iterable[Option]
    template: str = """\
declare_section(
    keywords::{name},
    sec_type::{required},
    declare_group(
        {options}
    )
)
"""

    def __init__(self, name: str, required: bool, options: Iterable[Option]):
        self.name = name
        self.required = required
        self.options = options

    def __repr__(self) -> str:
        return f"Section(name='{self.name}', options={self.options})"

    def __str__(self) -> str:
        return self.template.format(
            name=self.name,
            required="mandatory" if self.required else "optional",
            options=",\n".join(str(opt) for opt in self.options)
        )


class Keyword:
    name: str

    def __init__(self, name: str):
        self.name = name

    def __str__(self) -> str:
        return f"constexpr static auto {self.name} {{\"{self.name}\"}};"


class Generator:
    data: Any
    config: Config
    sections: List[Section]
    keywords_by_section: Dict[str, List[Keyword]]

    def __init__(self, config: Config, desc_file: Path):
        self.sections = []
        self.keywords_by_section = {}

        with open(desc_file, "r") as f:
            self.data = yaml.safe_load(f)
            self.config = config

            v = Validator()
            if not v.validate(self.data, OPT_DESC_FILE_SCHEMA):
                raise GeneratorError("Description file is invalid", v.errors) from None

        for s in self.data['sections']:
            section_name = s['name']
            options = []

            for opt in s['options']:
                options.append(
                    Option(opt['name'],
                           opt['type'],
                           opt['required'],
                           opt.get('converter', None)))

                if section_name not in self.keywords_by_section:
                    self.keywords_by_section[section_name] = []
                self.keywords_by_section[section_name].append(Keyword(opt['name']))

            self.sections.append(
                Section(s['name'],
                        s['required'],
                        options))

    def get_schema(self) -> str:
        cfg = self.config.schema_config

        copyright_notice = cfg.copyright_text
        if copyright_notice:
            copyright_notice += "\n"

        return (copyright_notice +
                TEMPLATES['schema'].format(
                    header_guard=cfg.header_guard,
                    namespace_begin=cfg.namespace.begin() + "\n",
                    namespace_end="\n" + cfg.namespace.end(),
                    sections='\n'.join(str(s) for s in self.sections),
                ))

    def get_keywords(self) -> str:
        section_keywords = [Keyword(s.name) for s in self.sections]

        cfg = self.config.keywords_config

        copyright_notice = cfg.copyright_text
        if copyright_notice:
            copyright_notice += "\n"

        return (copyright_notice +
                TEMPLATES['keywords'].format(
                    header_guard=cfg.header_guard,
                    namespace_begin=cfg.namespace.begin() + "\n",
                    namespace_end="\n" + cfg.namespace.end(),
                    section_keywords="// section names\n" +
                                     "\n".join(str(k) for k in section_keywords) + "\n",
                    option_keywords='\n'.join(f"// option names for '{sn}' section\n" +
                                              '\n'.join(str(k) for k in self.keywords_by_section[sn.name])
                                              for sn in section_keywords)
                ))


def load_config(config_file: Path, output_lang: str) -> Config:
    with open(config_file, "r") as f:
        data = yaml.safe_load(f)

        v = Validator()

        if not v.validate(data, CONFIG_SCHEMA):
            raise ConfigError("Invalid configuration file", v.errors)

        dg = data['genopts']
        cfgs = [
            OutputConfig(
                NamespaceFactory.create_namespace(dg[k].get('namespace'), output_lang),
                OutputPath(
                    Path(dg[k]['output_path']['header']),
                    Path(dg[k]['output_path']['implementation']) if 'implementation' in dg[k]['output_path'] else None),
                dg[k].get('header_guard', None),
                dg[k].get('copyright_file', None),
            ) for k in Config.keys
        ]

        return Config(output_lang, *cfgs)


class _PrintVersion(argparse.Action):
    def __init__(self, option_strings, dest, const=None, default=None,
                 required=False, help=None, metavar=None):
        super().__init__(option_strings=option_strings,
                         dest=dest,
                         nargs=0,
                         const=const,
                         default=default,
                         required=required,
                         help=help)

    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, self.const)
        print(f"genopts {VERSION}")
        sys.exit(0)


def parse_args(args) -> argparse.Namespace:
    parser = argparse.ArgumentParser(prog="genopts",
                                     description="Parse OPT_DESC_FILE and generate a C++ schema that describes "
                                                 "the options it defines")
    parser.add_argument(
        "opt_desc_file",
        type=Path,
        metavar="OPT_DESC_FILE",
        help="A file describing the desired options."
    )

    parser.add_argument(
        "--config-file",
        "-c",
        type=Path,
        metavar="CONFIG_FILE",
        default=Path("config.yml"),
        help="A configuration file that should be used to control the generated output."
    )

    parser.add_argument(
        "--std",
        choices=["c++17", "c++14"],
        dest="output_lang",
        metavar="LANG",
        default="c++17",
        help="Determine the output language. Possible values for LANG are:\n"
             "\n"
             "c++17\n"
             "    2017 ISO C++ standard."
             "c++14\n"
             "    2014 ISO C++ standard."
    )

    parser.add_argument(
        "--write-schema",
        action='store_true',
        help="Generate a 'schema' header and write it to the location defined in CONFIG_FILE."
    )

    parser.add_argument(
        "--no-write-schema",
        dest='write_schema',
        action='store_false',
        help="Do not generate a 'schema' header."
    )
    parser.set_defaults(write_schema=True)

    parser.add_argument(
        "--write-keywords",
        action='store_true',
        help="Generate a 'keywords' header and write it to the location defined in CONFIG_FILE."
    )

    parser.add_argument(
        "--no-write-keywords",
        dest='write_keywords',
        action='store_false',
        help="Do not generate a 'keywords' header/implementation."
    )
    parser.set_defaults(write_keywords=True)

    parser.add_argument(
        "--force-console",
        "-C",
        action='store_true',
        help="Write generated code to stdout instead of files"
    )

    parser.add_argument(
        "--version",
        "-V",
        action=_PrintVersion,
        help="Display genopts version information."
    )

    return parser.parse_args(args)


def main(args):
    try:
        args = parse_args(args)
        cfg = load_config(args.config_file, args.output_lang)

        g = Generator(cfg, args.opt_desc_file)

        if args.write_schema:
            hpp_schema = g.get_schema()

            if args.force_console:
                print(hpp_schema)
            else:
                cfg.schema_config.output_path.header.write_text(hpp_schema)
                print(f"Written {cfg.schema_config.output_path.header}")

        if args.write_keywords:
            hpp_keywords = g.get_keywords()

            if args.force_console:
                print(hpp_keywords)
            else:
                cfg.keywords_config.output_path.header.write_text(hpp_keywords)
                print(f"Written {cfg.keywords_config.output_path.header}")

    except ConfigError as e:
        print(e, file=sys.stderr)
        sys.exit(1)
    except OSError as e:
        print(e, file=sys.stderr)
        sys.exit(1)
    except ValidationError as e:
        print(e, file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main(sys.argv[1:])

requirements.txt

0 → 100644
+3 −0
Original line number Diff line number Diff line
pytest~=7.1.2
PyYAML~=6.0
Cerberus~=1.3.4