Updated S++ and added tool script for creating new header & source files.

This commit is contained in:
Patrick 2025-06-19 13:34:38 +02:00
parent b3a0e68100
commit 3c7770a2a4
7 changed files with 329 additions and 66 deletions

@ -1 +1 @@
Subproject commit 161f2e52d86d9aa851f047d011ac9eccc0422c10 Subproject commit c3b5244eac5187a64b6f46a1a4dc171416fff313

121
tools/common/__init__.py Normal file
View File

@ -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), '<invalid variable>')
fout.write(_variable_regex.sub(_repl, line))
bakfile.unlink()

44
tools/common/jinja.py Normal file
View File

@ -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)

View File

@ -4,30 +4,18 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess
import re import re
import sys 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_]') _invalid_file_char_regex = re.compile(r'[^a-zA-Z0-9_]')
_variable_regex = re.compile(r'@([A-Z_]+)@') _variable_regex = re.compile(r'@([A-Z_]+)@')
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_root: Path _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: def verify_tools() -> None:
_logger.debug('Verifying all required tools are available...') _logger.debug('Verifying all required tools are available...')
success = True success = True
@ -42,72 +30,59 @@ def verify_tools() -> None:
def download_spp() -> None: def download_spp() -> None:
_logger.debug('Checking if Scons++ is checked out...') _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 ('+', ' '): if output[0] in ('+', ' '):
return return
assert output[0] == '-' assert output[0] == '-'
_logger.info('SCons++ not checkout out yet, doing it now.') _logger.info('SCons++ not checkout out yet, doing it now.')
_exec_checked(['git', 'submodule', 'init']) exec_checked(['git', 'submodule', 'init'])
_exec_checked(['git', 'submodule', 'update', 'external/scons-plus-plus']) exec_checked(['git', 'submodule', 'update', 'external/scons-plus-plus'])
def update_spp() -> None: def update_spp() -> None:
_logger.debug('Updating SCons++ submodule...') _logger.debug('Updating SCons++ submodule...')
os.chdir(_root / 'external/scons-plus-plus') os.chdir(_root / 'external/scons-plus-plus')
try: try:
_exec_checked(['git', 'fetch', 'origin', 'master']) exec_checked(['git', 'fetch', 'origin', 'master'])
_exec_checked(['git', 'checkout', 'master']) exec_checked(['git', 'checkout', 'master'])
output = _exec_get_output(['git', 'status', '--porcelain']) output = exec_get_output(['git', 'status', '--porcelain'])
if output.strip() == '': if output.strip() == '':
return return
finally: finally:
os.chdir(_root) os.chdir(_root)
_logger.info('Changes in SCons++ detected, creating commit.') _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: def verify_unchanged() -> None:
output = _exec_get_output(['git', 'status', '--porcelain']) output = exec_get_output(['git', 'status', '--porcelain'])
if output != '': if output != '':
raise RuntimeError('There are uncommitted changes. Commit, stash or revert them before running this script.') 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), '<invalid variable>')
fout.write(_variable_regex.sub(_repl, line))
bakfile.unlink()
def setup_project() -> None: def setup_project() -> None:
project_name = '' project_name = ''
while project_name == '': while project_name == '':
print('Please enter a name for your project.') print('Please enter a name for your project.')
project_name = _prompt().strip() project_name = prompt().strip()
module_name = project_name module_name = project_name
print(f'Please enter a name for the first module. Leave empty to use the project name ("{module_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 != '': if new_name != '':
module_name = 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.') 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 != '': 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.') 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 != '': 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 / '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 / 'private/spp_template/SModule', MODULE_NAME=module_name, EXE_NAME=module_exe_name)
template_folder = _root / 'private/spp_template' template_folder = _root / 'private/spp_template'
module_folder = _root / 'private' / module_folder_name module_folder = _root / 'private' / module_folder_name
@ -115,11 +90,11 @@ def setup_project() -> None:
shutil.move(template_folder, module_folder) shutil.move(template_folder, module_folder)
_logger.info('Creating a git commit for the setup.') _logger.info('Creating a git commit for the setup.')
_exec_checked(['git', 'add', '.']) exec_checked(['git', 'add', '.'])
_exec_checked(['git', 'commit', '-m', 'Project setup']) exec_checked(['git', 'commit', '-m', 'Project setup'])
def setup_git_remote() -> None: 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: if 'spp_template' not in current_url:
_logger.debug('Remote already set up.') _logger.debug('Remote already set up.')
return return
@ -127,21 +102,17 @@ def setup_git_remote() -> None:
remote_url = '' remote_url = ''
while remote_url == '': while remote_url == '':
print('Please enter a new URL for your git remote.') print('Please enter a new URL for your git remote.')
remote_url = _prompt().strip() remote_url = prompt().strip()
_exec_checked(['git', 'remote', 'set-url', 'origin', remote_url]) exec_checked(['git', 'remote', 'set-url', 'origin', remote_url])
if __name__ == '__main__': def script_main():
logging.basicConfig(level=logging.DEBUG, format='%(message)s')
_root = Path(__file__).parent.parent
os.chdir(_root)
try:
verify_tools() verify_tools()
download_spp() download_spp()
update_spp() update_spp()
verify_unchanged() verify_unchanged()
setup_project() setup_project()
setup_git_remote() setup_git_remote()
except Exception as e:
_logger.error('There was an error running the script: %s', e) if __name__ == '__main__':
sys.exit(1) run_script(script_main)

104
tools/new_source.py Normal file
View File

@ -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)

View File

@ -0,0 +1,13 @@
#pragma once
#if !defined({{guard}})
#define {{guard}} 1
namespace {{namespace}}
{{ "{" }}
{{ "}" }} // namespace {{namespace}}
#endif // !defined({{guard}})

View File

@ -0,0 +1,10 @@
{%- if header_path is not none %}
#include "{{header_path}}"
{%- endif %}
namespace {{namespace}}
{{ "{" }}
{{ "}" }} // namespace {{namespace}}