diff --git a/external/scons-plus-plus b/external/scons-plus-plus index 161f2e5..c3b5244 160000 --- a/external/scons-plus-plus +++ b/external/scons-plus-plus @@ -1 +1 @@ -Subproject commit 161f2e52d86d9aa851f047d011ac9eccc0422c10 +Subproject commit c3b5244eac5187a64b6f46a1a4dc171416fff313 diff --git a/tools/common/__init__.py b/tools/common/__init__.py new file mode 100644 index 0000000..ccddace --- /dev/null +++ b/tools/common/__init__.py @@ -0,0 +1,121 @@ +import json +import logging +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Sequence, Callable, Optional, Literal, TypeAlias, TypeVar + +_invalid_file_char_regex = re.compile(r'[^a-zA-Z0-9_]') +_invalid_path_char_regex = re.compile(r'[^a-zA-Z0-9_/]') +_variable_regex = re.compile(r'@([A-Z_]+)@') +_logger = logging.getLogger(__name__) + + +T = TypeVar('T') +_Validator : TypeAlias = Callable[[T], bool] + +def script_preamble() -> None: + logging.basicConfig(level=logging.DEBUG, format='%(message)s') + _root = Path(__file__).parent.parent.parent + os.chdir(_root) + +def run_script(main_fn: Callable[[],None]) -> None: + script_preamble() + try: + main_fn() + except KeyboardInterrupt: + _logger.warning('Cancelled.') + sys.exit(2) + except Exception as e: + _logger.error('There was an error running the script: %s', e) + sys.exit(1) + + +def exec_checked(args: Sequence[str], **kwargs) -> None: + subprocess.run(args, stdout=sys.stdout, stderr=sys.stderr, check=True, **kwargs) + +def exec_get_output(args: Sequence[str], **kwargs) -> str: + return subprocess.run(args, text=True, check=True, capture_output=True).stdout + +def get_project_config() -> dict: + raw = exec_get_output(('scons', '-s', '--disable_auto_update', '--dump=config', '--dump_format=json')) + return json.loads(raw) + +def prompt(prefix: str = '> ') -> str: + sys.stdout.write(prefix) + sys.stdout.flush() + return sys.stdin.readline() + + +def prompt_choices[T](message: str, choices: dict[str, T], prefix: str = '> ') -> T: + while True: + print(f'{message} [{", ".join(choices.keys())}]') + val = prompt(prefix).strip().lower() + for name, value in choices.items(): + if name.lower() == val: + return value + +def prompt_yesno(message: str, prefix: str = '> ') -> bool: + while True: + print(f'{message} [(y)es, (n)o]') + val = prompt(prefix).strip().lower() + if val in ('y', 'yes'): + return True + if val in ('n', 'no'): + return False + +def prompt_path(message: str, + prefix: str = '> ', + mode: Literal['save']|Literal['load']|None = None, + allow_slash: bool = False, + allow_absolute: bool = False, + relative_to: Optional[Path] = None, + validator: Optional[_Validator[Path]] = None) -> Path: + while True: + print(message) + val = prompt(prefix).strip() + if val == '': + continue + if mode != 'load': # no verification needed when loading a file + regex = _invalid_path_char_regex if allow_slash else _invalid_file_char_regex + invalid_chars = regex.findall(val) + if len(invalid_chars) > 0: + print(f'Path contains the following invalid chars: {", ".join(invalid_chars)}') + continue + path = Path(val) + if not allow_absolute and path.is_absolute(): + print('Absolute paths are not allowed.') + continue + if relative_to is not None: + path = relative_to / path + if mode == 'load': + if not path.exists(): + print('File does not exist.') + continue + elif mode == 'save': + if path.exists(): + if not prompt_yesno(f'File {path} already exists. Continue?'): + continue + if validator is not None and not validator(path): + continue + return path + + + +def escape_filename(name: str) -> str: + return _invalid_file_char_regex.sub('_', name) + +def replace_in_file(file: Path, **replacements) -> None: + _logger.debug('Patching file "%s"...', file) + bakfile = file.with_suffix(f'{file.suffix}.bak') + bakfile.unlink(missing_ok=True) + file.rename(bakfile) + + with bakfile.open('r') as fin, file.open('w') as fout: + for line in fin: + def _repl(match: re.Match) -> str: + return replacements.get(match.group(1), '') + fout.write(_variable_regex.sub(_repl, line)) + bakfile.unlink() \ No newline at end of file diff --git a/tools/common/jinja.py b/tools/common/jinja.py new file mode 100644 index 0000000..741ed81 --- /dev/null +++ b/tools/common/jinja.py @@ -0,0 +1,44 @@ +from pathlib import Path +from typing import Any, Callable, Optional, TYPE_CHECKING + +try: + import jinja2 +except ImportError: + jinja2 = None + +if TYPE_CHECKING: + _JinjaEnv = jinja2.Environment +else: + _JinjaEnv = object + +def is_jinja_installed() -> bool: + return jinja2 is not None + +def verify_jinja() -> None: + if not is_jinja_installed(): + raise RuntimeError('Python module Jinja2 is not installed.') + +def make_env(filters: Optional[dict[str, Callable]] = None, + tests: Optional[dict[str, Callable[[Any], bool]]] = None, + environment: Optional[dict[str, Any]] = None, + templates_path: Optional[Path] = None) -> _JinjaEnv: + if templates_path is None: + templates_path = (Path(__file__).parent.parent / 'templates').absolute() + jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_path)) + if filters is not None: + jinja_env.filters.update(filters) + if tests is not None: + jinja_env.tests.update(tests) + if environment is not None: + jinja_env.globals.update(environment) + return jinja_env + + +def generate_file(template: str, target: Path, context: dict[str, Any], **kwargs) -> None: + jinja_env = make_env(**kwargs) + template = jinja_env.get_template(template) + result = template.render(**context) + + target.parent.mkdir(parents=True, exist_ok=True) + with target.open('w') as f: + f.write(result) diff --git a/tools/init_project.py b/tools/init_project.py index d869604..aeb377d 100644 --- a/tools/init_project.py +++ b/tools/init_project.py @@ -4,30 +4,18 @@ import logging import os from pathlib import Path import shutil -import subprocess import re import sys -from typing import Sequence + +sys.path.append(os.path.dirname(__file__)) + +from common import exec_checked, exec_get_output, prompt, escape_filename, replace_in_file, run_script _invalid_file_char_regex = re.compile(r'[^a-zA-Z0-9_]') _variable_regex = re.compile(r'@([A-Z_]+)@') _logger = logging.getLogger(__name__) _root: Path -def _exec_checked(args: Sequence[str], **kwargs) -> None: - subprocess.run(args, stdout=sys.stdout, stderr=sys.stderr, check=True, **kwargs) - -def _exec_get_output(args: Sequence[str], **kwargs) -> str: - return subprocess.run(args, text=True, check=True, capture_output=True).stdout - -def _prompt(prefix: str = '> ') -> str: - sys.stdout.write(prefix) - sys.stdout.flush() - return sys.stdin.readline() - -def _escape_filename(name: str) -> str: - return _invalid_file_char_regex.sub('_', name) - def verify_tools() -> None: _logger.debug('Verifying all required tools are available...') success = True @@ -42,72 +30,59 @@ def verify_tools() -> None: def download_spp() -> None: _logger.debug('Checking if Scons++ is checked out...') - output = _exec_get_output(['git', 'submodule', 'status', 'external/scons-plus-plus']) + output = exec_get_output(['git', 'submodule', 'status', 'external/scons-plus-plus']) if output[0] in ('+', ' '): return assert output[0] == '-' _logger.info('SCons++ not checkout out yet, doing it now.') - _exec_checked(['git', 'submodule', 'init']) - _exec_checked(['git', 'submodule', 'update', 'external/scons-plus-plus']) + exec_checked(['git', 'submodule', 'init']) + exec_checked(['git', 'submodule', 'update', 'external/scons-plus-plus']) def update_spp() -> None: _logger.debug('Updating SCons++ submodule...') os.chdir(_root / 'external/scons-plus-plus') try: - _exec_checked(['git', 'fetch', 'origin', 'master']) - _exec_checked(['git', 'checkout', 'master']) - output = _exec_get_output(['git', 'status', '--porcelain']) + exec_checked(['git', 'fetch', 'origin', 'master']) + exec_checked(['git', 'checkout', 'master']) + output = exec_get_output(['git', 'status', '--porcelain']) if output.strip() == '': return finally: os.chdir(_root) _logger.info('Changes in SCons++ detected, creating commit.') - _exec_checked(['git', 'commit', '-m', 'Updated Scons++', 'external/scons-plus-plus']) + exec_checked(['git', 'commit', '-m', 'Updated Scons++', 'external/scons-plus-plus']) def verify_unchanged() -> None: - output = _exec_get_output(['git', 'status', '--porcelain']) + output = exec_get_output(['git', 'status', '--porcelain']) if output != '': raise RuntimeError('There are uncommitted changes. Commit, stash or revert them before running this script.') -def _replace_in_file(file: Path, **replacements) -> None: - _logger.debug('Patching file "%s"...', file) - bakfile = file.with_suffix(f'{file.suffix}.bak') - bakfile.unlink(missing_ok=True) - file.rename(bakfile) - - with bakfile.open('r') as fin, file.open('w') as fout: - for line in fin: - def _repl(match: re.Match) -> str: - return replacements.get(match.group(1), '') - fout.write(_variable_regex.sub(_repl, line)) - bakfile.unlink() - def setup_project() -> None: project_name = '' while project_name == '': print('Please enter a name for your project.') - project_name = _prompt().strip() + project_name = prompt().strip() module_name = project_name print(f'Please enter a name for the first module. Leave empty to use the project name ("{module_name}").') - new_name = _prompt().strip() + new_name = prompt().strip() if new_name != '': module_name = new_name - module_folder_name = _escape_filename(project_name.lower()) + module_folder_name = escape_filename(project_name.lower()) print(f'Please enter a folder name for the first module. Leave empty for "{module_folder_name}". Anything but [A-Za-z0-9_] will be replaced with underscores.') - new_name = _prompt().strip() + new_name = prompt().strip() if new_name != '': - module_folder_name = _escape_filename(new_name) # just enforce nice names + module_folder_name = escape_filename(new_name) # just enforce nice names - module_exe_name = _escape_filename(module_name.lower()) + module_exe_name = escape_filename(module_name.lower()) print(f'Please enter a file name for the module executable. Leave empty for "{module_exe_name}". Omit the file suffix. Anything but [A-Za-z0-9_] will be replaced with underscores.') - new_name = _prompt().strip() + new_name = prompt().strip() if new_name != '': - module_exe_name = _escape_filename(new_name) + module_exe_name = escape_filename(new_name) - _replace_in_file(_root / 'SConstruct', PROJECT_NAME=project_name, MODULE_FOLDER_NAME=module_folder_name) - _replace_in_file(_root / 'private/spp_template/SModule', MODULE_NAME=module_name, EXE_NAME=module_exe_name) + replace_in_file(_root / 'SConstruct', PROJECT_NAME=project_name, MODULE_FOLDER_NAME=module_folder_name) + replace_in_file(_root / 'private/spp_template/SModule', MODULE_NAME=module_name, EXE_NAME=module_exe_name) template_folder = _root / 'private/spp_template' module_folder = _root / 'private' / module_folder_name @@ -115,11 +90,11 @@ def setup_project() -> None: shutil.move(template_folder, module_folder) _logger.info('Creating a git commit for the setup.') - _exec_checked(['git', 'add', '.']) - _exec_checked(['git', 'commit', '-m', 'Project setup']) + exec_checked(['git', 'add', '.']) + exec_checked(['git', 'commit', '-m', 'Project setup']) def setup_git_remote() -> None: - current_url = _exec_get_output(['git', 'remote', 'get-url', 'origin']) + current_url = exec_get_output(['git', 'remote', 'get-url', 'origin']) if 'spp_template' not in current_url: _logger.debug('Remote already set up.') return @@ -127,21 +102,17 @@ def setup_git_remote() -> None: remote_url = '' while remote_url == '': print('Please enter a new URL for your git remote.') - remote_url = _prompt().strip() - _exec_checked(['git', 'remote', 'set-url', 'origin', remote_url]) + remote_url = prompt().strip() + exec_checked(['git', 'remote', 'set-url', 'origin', remote_url]) +def script_main(): + verify_tools() + download_spp() + update_spp() + verify_unchanged() + setup_project() + setup_git_remote() + if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG, format='%(message)s') - _root = Path(__file__).parent.parent - os.chdir(_root) - try: - verify_tools() - download_spp() - update_spp() - verify_unchanged() - setup_project() - setup_git_remote() - except Exception as e: - _logger.error('There was an error running the script: %s', e) - sys.exit(1) + run_script(script_main) diff --git a/tools/new_source.py b/tools/new_source.py new file mode 100644 index 0000000..2985cb0 --- /dev/null +++ b/tools/new_source.py @@ -0,0 +1,104 @@ + + +import logging +import os +import sys +from pathlib import Path +from typing import Optional + +sys.path.append(os.path.dirname(__file__)) + +from common import prompt_choices, prompt_path, prompt_yesno, run_script +from common.jinja import is_jinja_installed, generate_file + + +_PRIVATE_PATH = Path('private') +_PUBLIC_PATH = Path('public') +_HEADER_TEMPLATE_NAME = 'header.hpp.jinja' +_SOURCE_TEMPLATE_NAME = 'source.cpp.jinja' + +_logger = logging.getLogger(__name__) +_header_path: Optional[Path] = None +_source_path: Optional[Path] = None +_namespace: str + +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_params() -> None: + global _header_path, _source_path, _namespace + header_folder = prompt_choices('Create Header?', { + 'public': _PUBLIC_PATH, + 'private': _PRIVATE_PATH, + 'no': None + }) + 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) + if header_path.exists(): + print(f'Header file {header_path} already exists.') + result = False + if do_create_source: + source_path = _make_source_path(path) + if source_path.exists(): + print(f'Source file {source_path} already exists.') + result = False + return result + + 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) + + +def create_header() -> None: + if _header_path is None: + return + + _logger.info('Generating header at %s.', str(_header_path)) + + guard = '_'.join(_header_path.with_suffix('').parts[1:]).upper() + generate_file(_HEADER_TEMPLATE_NAME, _header_path, { + 'guard': guard, + 'namespace': _namespace + }) + + +def create_source() -> None: + if _source_path is None: + return + + _logger.info('Generating source at %s.', str(_source_path)) + generate_file(_SOURCE_TEMPLATE_NAME, _source_path, { + 'header_path': '/'.join(_header_path.parts[1:]) if _header_path else None, + 'namespace': _namespace + }) + +def script_main() -> None: + verify_tools() + query_params() + create_header() + create_source() + +if __name__ == '__main__': + run_script(script_main) diff --git a/tools/templates/header.hpp.jinja b/tools/templates/header.hpp.jinja new file mode 100644 index 0000000..eb0d8cc --- /dev/null +++ b/tools/templates/header.hpp.jinja @@ -0,0 +1,13 @@ + +#pragma once + +#if !defined({{guard}}) +#define {{guard}} 1 + +namespace {{namespace}} +{{ "{" }} + +{{ "}" }} // namespace {{namespace}} + +#endif // !defined({{guard}}) + diff --git a/tools/templates/source.cpp.jinja b/tools/templates/source.cpp.jinja new file mode 100644 index 0000000..152742d --- /dev/null +++ b/tools/templates/source.cpp.jinja @@ -0,0 +1,10 @@ + +{%- if header_path is not none %} +#include "{{header_path}}" +{%- endif %} + +namespace {{namespace}} +{{ "{" }} + +{{ "}" }} // namespace {{namespace}} +