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__) _root : Path T = TypeVar('T') _Validator : TypeAlias = Callable[[T], bool] def get_root() -> Path: return _root def script_preamble() -> None: global _root 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 get_module_config() -> dict: raw = exec_get_output(('scons', '-s', '--disable_auto_update', '--dump=modules', '--dump_format=json')) return json.loads(raw) def find_module_folder(subpath: Path) -> Optional[Path]: for parent in subpath.parents: if (parent / 'SModule').exists(): return parent return None def prompt(prefix: str = '> ') -> str: sys.stdout.write(prefix) sys.stdout.flush() 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())}]') 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()