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 = _TemplateConfig( options=[], new_sources=[] ) 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)