From 9bfbd44e34ef198d09231228314962625ea7cb4e Mon Sep 17 00:00:00 2001 From: Patrick Wuttke Date: Thu, 19 Jun 2025 15:40:48 +0200 Subject: [PATCH] Added script for generating modules. --- tools/common/__init__.py | 7 + tools/common/jinja.py | 9 +- tools/new_module.py | 221 ++++++++++++++++++ tools/new_source.py | 58 +++-- .../module/app/private/SModule.jinja | 17 ++ tools/templates/module/app/private/main.cpp | 5 + tools/templates/module/library/config.json | 17 ++ .../module/library/private/SModule.jinja | 17 ++ 8 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 tools/new_module.py create mode 100644 tools/templates/module/app/private/SModule.jinja create mode 100644 tools/templates/module/app/private/main.cpp create mode 100644 tools/templates/module/library/config.json create mode 100644 tools/templates/module/library/private/SModule.jinja diff --git a/tools/common/__init__.py b/tools/common/__init__.py index ccddace..2e5fc76 100644 --- a/tools/common/__init__.py +++ b/tools/common/__init__.py @@ -49,6 +49,13 @@ def prompt(prefix: str = '> ') -> str: return sys.stdin.readline() +def prompt_and_validate(message: str, validator: _Validator[str], prefix: str = '> ') -> str: + while True: + print(message) + input = prompt(prefix) + if validator(input): + return input + def prompt_choices[T](message: str, choices: dict[str, T], prefix: str = '> ') -> T: while True: print(f'{message} [{", ".join(choices.keys())}]') diff --git a/tools/common/jinja.py b/tools/common/jinja.py index 741ed81..4674f57 100644 --- a/tools/common/jinja.py +++ b/tools/common/jinja.py @@ -34,11 +34,16 @@ def make_env(filters: Optional[dict[str, Callable]] = None, return jinja_env -def generate_file(template: str, target: Path, context: dict[str, Any], **kwargs) -> None: +def generate_file(template_name: str, target: Path, context: dict[str, Any], **kwargs) -> None: jinja_env = make_env(**kwargs) - template = jinja_env.get_template(template) + template = jinja_env.get_template(template_name) result = template.render(**context) target.parent.mkdir(parents=True, exist_ok=True) with target.open('w') as f: f.write(result) + +def render_string(input: str, context: dict[str, Any], **kwargs) -> str: + jinja_env = make_env(**kwargs) + template = jinja_env.from_string(input) + return template.render(**context) diff --git a/tools/new_module.py b/tools/new_module.py new file mode 100644 index 0000000..c7976fb --- /dev/null +++ b/tools/new_module.py @@ -0,0 +1,221 @@ +import json +import logging +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Literal, Any, Callable + +sys.path.append(os.path.dirname(__file__)) + +from common import exec_checked, prompt, prompt_and_validate, prompt_choices, run_script, escape_filename, prompt_yesno +from common.jinja import is_jinja_installed, generate_file, render_string +from new_source import run_new_source + +class _TemplateCustomizable: ... +@dataclass +class _TemplateOption: + name: str + message: str + + @staticmethod + def from_json(type: str, **kwargs) -> '_TemplateOption': + tp = { + 'choices': _TemplateOptionChoices + }[type] + return tp.from_json(**kwargs) + +@dataclass +class _TemplateOptionChoices(_TemplateOption): + choices: list[str] + + @staticmethod + def from_json(**kwargs) -> '_TemplateOptionChoices': + return _TemplateOptionChoices(**kwargs) + + +@dataclass +class _TemplateNewSource(_TemplateCustomizable): + header: Literal['public', 'private', 'no'] + source: bool + path: str + + @staticmethod + def from_json(**kwargs) -> '_TemplateNewSource': + return _TemplateNewSource(**kwargs) + +@dataclass +class _TemplateConfig(_TemplateCustomizable): + options: list[_TemplateOption] + new_sources: list[_TemplateNewSource] + + @staticmethod + def from_json(data: dict) -> '_TemplateConfig': + return _TemplateConfig( + options=[_TemplateOption.from_json(**ele) for ele in data.get('options', [])], + new_sources=[_TemplateNewSource.from_json(**ele) for ele in data.get('new_sources', [])] + ) + + +_PRIVATE_PATH = Path('private') +_PUBLIC_PATH = Path('public') +_TEMPLATE_BASE_PATH = Path(__file__).parent / 'templates/module' + +_logger = logging.getLogger(__name__) +_module_folder_name: str +_module_name: str +_template_folder: Path +_template_config: _TemplateConfig +_template_options: dict[str, Any] = {} + +def verify_tools() -> None: + success = True + if not is_jinja_installed(): + _logger.error('Python module Jinja2 is not installed.') + success = False + if not success: + raise RuntimeError('one or more required tools could not be found') + +def query_common_params() -> None: + global _module_folder_name, _module_name, _template_folder + + def _validate_name(name: str) -> bool: + return name.strip() != '' + + _module_name = prompt_and_validate('Enter module name.', _validate_name).strip() + + _module_folder_name = escape_filename(_module_name.lower()) + while True: + print(f'Please enter a folder name for the module. Leave empty for "{_module_folder_name}". Anything but [A-Za-z0-9_] will be replaced with underscores.') + new_name = prompt().strip() + if new_name != '': + _module_folder_name = escape_filename(new_name) # just enforce nice names + module_path = _PRIVATE_PATH / _module_folder_name + if not module_path.exists(): + break + print(f'Module folder at {module_path} already exists.') + + + template_choices = {} + for subpath in sorted(_TEMPLATE_BASE_PATH.iterdir()): + if not subpath.is_dir(): + continue + template_choices[subpath.name] = subpath + _template_folder = prompt_choices('Please select a module template.', template_choices) + + # sanity check + if not (_template_folder / 'private/SModule.jinja').exists(): + raise RuntimeError('module template appears to be invalid') + +def load_template_config() -> None: + global _template_config + config_file = _template_folder / 'config.json' + if not config_file.exists(): + _template_config = {} + return + with config_file.open('r') as f: + try: + _template_config = _TemplateConfig.from_json(json.load(f)) + except Exception as e: + raise RuntimeError(f'Error while parsing template config: {e}') + +def query_template_params() -> None: + def _query_choices(option: _TemplateOptionChoices) -> str: + return prompt_choices(option.message, {ele: ele for ele in option.choices}) + + _QUERY_FUNCS : dict[type[_TemplateOption], Callable] = { + _TemplateOptionChoices: _query_choices + } + for option in _template_config.options: + _template_options[option.name] = _QUERY_FUNCS[option.__class__](option) + +def generate_module() -> None: + # firest prepare the template context + context = { + 'name': _module_name, + 'target_name': _module_folder_name, + 'folder_name': _module_folder_name, + **_template_options + } + + # next parse templates in the config + def _replace_options(obj: _TemplateCustomizable) -> None: + def _process_value(value: Any, setter: Optional[Callable[[str], None]]) -> None: + if isinstance(value, str): + setter(render_string(value, context)) + elif isinstance(value, list): + for idx in range(len(value)): + def __set(v: str) -> None: + value[idx] = v + _process_value(value[idx], __set) + elif isinstance(value, dict): + for key in list(value.keys()): + def __set(v: str) -> None: + value[key] = v + _process_value(value[key], __set) + elif isinstance(value, _TemplateCustomizable): + _process_value(value.__dict__, setter) + _process_value(obj.__dict__, None) + _replace_options(_template_config) + + # then copy the files over + def _copy_template(src: Path, dst: Path) -> None: + dst.mkdir(parents=True, exist_ok=True) + for ele in src.iterdir(): + dstele = dst / ele.name + if ele.is_dir(): + _copy_template(ele, dstele) + continue + if ele.suffix == '.jinja': + generate_file(str(ele.relative_to(_template_folder)), dstele.with_suffix(''), context, templates_path=_template_folder) + else: + shutil.copyfile(ele, dstele) + + template_private = _template_folder / 'private' + template_public = _template_folder / 'public' + + _copy_template(template_private, _PRIVATE_PATH / _module_folder_name) + + if template_public.exists(): + _copy_template(template_public, _PUBLIC_PATH / _module_folder_name) + + # and then generate sources + for new_source in _template_config.new_sources: + run_new_source(new_source.header, new_source.source, Path(new_source.path)) + +def append_module() -> None: + print('Appending module to SConstruct file. You might want to adjust it.') + + sconstruct_file = Path('SConstruct') + bak_file = sconstruct_file.with_suffix('.bak') + + shutil.copyfile(sconstruct_file, bak_file) + + with bak_file.open('r') as fin, sconstruct_file.open('w') as fout: + for line in fin: + if line.strip() == 'env.Finalize()': + fout.write(f'# {_module_name}\n') + fout.write(f"env = env.Module('{_PRIVATE_PATH / _module_folder_name}/SModule')\n") + fout.write('\n\n') + fout.write(line) + +def refresh_projects() -> None: + if Path('.idea').exists(): + if prompt_yesno('CLion project found. Regenerate it?'): + exec_checked(('scons', '--generate_project=clion')) + if Path('.vscode').exists(): + if prompt_yesno('VS Code project found. Regenerate it?'): + exec_checked(('scons', '--generate_project=vscode')) + +def script_main() -> None: + verify_tools() + query_common_params() + load_template_config() + query_template_params() + generate_module() + append_module() + refresh_projects() + +if __name__ == '__main__': + run_script(script_main) diff --git a/tools/new_source.py b/tools/new_source.py index 49e1ae7..67dcf13 100644 --- a/tools/new_source.py +++ b/tools/new_source.py @@ -4,7 +4,7 @@ import logging import os import sys from pathlib import Path -from typing import Optional +from typing import Optional, Literal sys.path.append(os.path.dirname(__file__)) @@ -16,6 +16,11 @@ _PRIVATE_PATH = Path('private') _PUBLIC_PATH = Path('public') _HEADER_TEMPLATE_NAME = 'header.hpp.jinja' _SOURCE_TEMPLATE_NAME = 'source.cpp.jinja' +_HEADER_CHOICES = { + 'public': _PUBLIC_PATH, + 'private': _PRIVATE_PATH, + 'no': None +} _logger = logging.getLogger(__name__) _header_path: Optional[Path] = None @@ -30,27 +35,32 @@ def verify_tools() -> None: if not success: raise RuntimeError('one or more required tools could not be found') -def query_params() -> None: +def _make_header_path(header_folder: Path, base_path: Path) -> Path: + return header_folder / base_path.with_suffix('.hpp') + +def _make_source_path(base_path: Path) -> Path: + return _PRIVATE_PATH / base_path.with_suffix('.cpp') + +def _apply_params(header_folder: Path, do_create_source: bool, base_path: Path) -> None: global _header_path, _source_path, _namespace - header_folder = prompt_choices('Create Header?', { - 'public': _PUBLIC_PATH, - 'private': _PRIVATE_PATH, - 'no': None - }) + + _namespace = base_path.parts[0] + + if header_folder is not None: + _header_path = _make_header_path(header_folder, base_path) + if do_create_source: + _source_path = _make_source_path(base_path) + +def query_params() -> None: + header_folder = prompt_choices('Create Header?', _HEADER_CHOICES) do_create_source = prompt_yesno('Create source?') if header_folder is None and not do_create_source: raise RuntimeError('Neither header nor source selected for creation.') - def _make_header_path(base_path: Path) -> Path: - return header_folder / base_path.with_suffix('.hpp') - - def _make_source_path(base_path: Path) -> Path: - return _PRIVATE_PATH / base_path.with_suffix('.cpp') - def _validate_path(path: Path) -> bool: result = True if header_folder is not None: - header_path = _make_header_path(path) + header_path = _make_header_path(header_folder, path) if header_path.exists(): print(f'Header file {header_path} already exists.') result = False @@ -63,12 +73,7 @@ def query_params() -> None: input_path = prompt_path('Enter basename for the source (relative to public/private folder, no file extension).', allow_slash=True, validator=_validate_path) - _namespace = input_path.parts[0] - - if header_folder is not None: - _header_path = _make_header_path(input_path) - if do_create_source: - _source_path = _make_source_path(input_path) + _apply_params(header_folder, do_create_source, input_path) def create_header() -> None: @@ -94,11 +99,18 @@ def create_source() -> None: 'namespace': _namespace }) -def script_main() -> None: - verify_tools() - query_params() +def _run() -> None: create_header() create_source() +def run_new_source(header: Literal['private', 'public', 'no'], source: bool, path: Path) -> None: + _apply_params(_HEADER_CHOICES[header], source, path) + _run() + +def script_main() -> None: + verify_tools() + query_params() + _run() + if __name__ == '__main__': run_script(script_main) diff --git a/tools/templates/module/app/private/SModule.jinja b/tools/templates/module/app/private/SModule.jinja new file mode 100644 index 0000000..89539ab --- /dev/null +++ b/tools/templates/module/app/private/SModule.jinja @@ -0,0 +1,17 @@ + +Import('env') + +src_files = Split(""" + main.cpp +""") + +prog_app = env.UnityProgram( + name = '{{name}}', + target = env['BIN_DIR'] + '/{{target_name}}', + source = src_files, + dependencies = { + 'mijin': {} + } +) + +Return('env') diff --git a/tools/templates/module/app/private/main.cpp b/tools/templates/module/app/private/main.cpp new file mode 100644 index 0000000..d454a90 --- /dev/null +++ b/tools/templates/module/app/private/main.cpp @@ -0,0 +1,5 @@ + +int main(int, char**) +{ + return 0; +} diff --git a/tools/templates/module/library/config.json b/tools/templates/module/library/config.json new file mode 100644 index 0000000..4853b8f --- /dev/null +++ b/tools/templates/module/library/config.json @@ -0,0 +1,17 @@ +{ + "options": [ + { + "name": "library_type", + "message": "Enter library type.", + "type": "choices", + "choices": ["static", "shared"] + } + ], + "new_sources": [ + { + "header": "public", + "source": true, + "path": "{{folder_name}}/{{target_name}}" + } + ] +} \ No newline at end of file diff --git a/tools/templates/module/library/private/SModule.jinja b/tools/templates/module/library/private/SModule.jinja new file mode 100644 index 0000000..ece1c1f --- /dev/null +++ b/tools/templates/module/library/private/SModule.jinja @@ -0,0 +1,17 @@ + +Import('env') + +src_files = Split(""" + {{target_name}}.cpp +""") + +env.Unity{% if library_type == "shared" %}Shared{% else %}Static{% endif %}Library( + name = '{{name}}', + target = env['LIB_DIR'] + '/{{target_name}}', + source = src_files, + dependencies = { + 'mijin': {} + } +) + +Return('env')