Skip to content

Instantly share code, notes, and snippets.

@arseniiv
Created April 17, 2023 20:37
Show Gist options
  • Save arseniiv/488e741409773c701a6d7b1aefe771c9 to your computer and use it in GitHub Desktop.
Save arseniiv/488e741409773c701a6d7b1aefe771c9 to your computer and use it in GitHub Desktop.
"""
sfzdefiner.py
a tool to dump all contents of files linked to
specified SFZ file(s) into self-contained results;
replaces variables with values too
basic usage: (python3 | py) sfzdefiner.py (source-file-name)+
usage help: (python3 | py) sfzdefiner.py --help
best with Python 3.11; might work on 3.10
for earlier versions, you’ll need to desugar `match` statements
"""
from __future__ import annotations
from enum import IntEnum
from typing import Final, Iterable, Literal, Protocol, Sequence, TypeAlias, assert_never, cast
import re
import sys
from os import fspath
from pathlib import Path
import argparse as ap
class Args(Protocol):
filename: Sequence[str]
postfix: str
trace: bool
class ExitCode(IntEnum):
# NORMAL = 0
NO_FILES = 1
RECURSION = 2
Token: TypeAlias = (str | tuple[Literal['define'], str, str] |
tuple[Literal['include'], str] |
tuple[Literal['use'], str, int, int])
def pos_string(filename: str, lineno: int, column: int) -> str:
return f'[{filename} {lineno}:{column}]'
def tokenizer_re() -> re.Pattern[str]:
pat = r'''
(?P<newline> \n )|
(?P<define> \# define [ \t] \$ (?P<def_var> [A-Z] [A-Z0-9_]* )
[ \t] (?P<def_val> [-+_A-Za-z0-9#.]+ )
[ \t]* (?= \n | \Z ) )|
(?P<define_bad> \# define [ \t] (?: \S+ [ \t] )? \S* )|
(?P<include> \# include [ \t] " (?P<inc_path> .*? ) " )|
(?P<include_bad> \# include )|
(?P<use> \$ (?P<use_var> [A-Z] [A-Z0-9_]* ) )|
(?P<use_bad> \$ )|
(?P<comment> // .*? (?= \n | \Z ) )|
(?P<text> (?: [^\n#$/] | \# (?! define | include ) | / (?! / ) )+ )|
(?P<unknown> . )
'''
return re.compile(pat, re.VERBOSE)
def tokenize(source: str, name: str) -> Iterable[Token]:
lineno = 1
line_start = 0
for mo in re.finditer(tokenizer_re(), source):
kind = mo.lastgroup
value = mo.group()
column = mo.start() - line_start + 1
pos = pos_string(name, lineno, column)
match kind:
case 'newline':
line_start = mo.end()
lineno += 1
yield value
case 'define':
yield kind, mo.group('def_var'), mo.group('def_val')
case 'define_bad':
print(f'{pos} bad #define (left as is): {value}', file=sys.stderr)
yield value
case 'include':
yield kind, mo.group('inc_path')
case 'include_bad':
print(f'{pos} bad #include (left as is)', file=sys.stderr)
yield value
case 'use':
yield kind, mo.group('use_var'), lineno, column
case 'use_bad':
print(f'{pos} bad $-variable (left as is)', file=sys.stderr)
yield value
case 'comment' | 'text':
yield value
case 'unknown':
print(f'{pos} unknown character (left as is): {value}', file=sys.stderr)
yield value
case _:
raise ValueError(f'unknown token kind: {kind}')
def process(in_p: Path, out_p: Path, *, do_trace: bool) -> None:
root_path = in_p.parent
opened_files: list[Path] = []
defined_vars: dict[str, str] = dict()
chunks: list[str] = []
def include_file(path: Path) -> None:
old_path = fspath(path)
if not path.is_absolute():
path = root_path / path
if not path.exists() or not path.is_file():
if old_path != fspath(path):
add = ''
else:
add = f' (‘{fspath(path)}’)'
print(f'Can’t process the file ‘{old_path}’{add}.', file=sys.stderr)
return
for i, opened_p in enumerate(opened_files):
if path.samefile(opened_p):
print('Recursive #include in files:')
for opened_p_ in opened_files[i:]:
print(f'\t{fspath(opened_p_)}')
print(f'\t{fspath(path)} <- the same as the first')
sys.exit(ExitCode.RECURSION)
opened_files.append(path)
chunks.append('\n')
trace_here: Final = do_trace and len(opened_files) > 1
if trace_here:
trace_str = ' > '.join(map(lambda p: p.name, opened_files[1:]))
chunks.append(f'\n//INC-START[ > {trace_str} ]\n\n')
raw_contents = path.read_text(encoding='utf8')
for token in tokenize(raw_contents, path.name):
match token:
case str(x):
chunks.append(x)
case 'define', str(def_var), str(def_val):
defined_vars[def_var] = def_val
case 'include', str(inc_path):
include_file(Path(inc_path))
case 'use', str(use_var), int(lineno), int(column):
pos = pos_string(path.name, lineno, column)
use_val = defined_vars.get(use_var)
if use_val is None:
print(f'{pos} variable is undefined: ${use_var} (left as is).', file=sys.stderr)
use_val = '$' + use_var
chunks.append(use_val)
case _:
assert_never(token)
if trace_here:
chunks.append(f'\n//INC-END[ > {trace_str} ]\n')
opened_files.pop()
chunks.append('\n')
include_file(in_p)
out_p.write_text(''.join(chunks), encoding='utf8')
FileQueue: TypeAlias = list[tuple[Path, Path]]
def main() -> None:
parser = ap.ArgumentParser(
prog='sfzdefiner',
description='SFZ de-finer by 05deg',
epilog='🐱‍💻')
parser.add_argument('filename', nargs='*',
help='an input filename')
parser.add_argument('-p', '--postfix', default='-expanded',
help='postfix of output filenames (def: ‘-expanded’)')
parser.add_argument('--trace', action='store_true',
help='trace #include enters and exits')
args = cast('Args', parser.parse_args())
if not args.filename:
print('Nothing to process! 🤔')
return
file_queue: list[tuple[Path, Path]] = []
for in_p in args.filename:
in_p = Path(in_p)
if not in_p.exists():
print(f'The path ‘{fspath(in_p)}’ doesn’t exist; it will be skipped.')
if not in_p.is_file():
print(f'The path ‘{fspath(in_p)}’ is not a file; it will be skipped.')
out_p = in_p.with_stem(in_p.stem + args.postfix)
file_queue.append((in_p, out_p))
print(f'{fspath(in_p)} --> {fspath(out_p)}')
if out_p.exists():
add = '' if out_p.is_file() else ' and is not a file'
print(f'!! Note that the output already exists{add}.')
if not file_queue:
print('All specified input filenames were invalid!', file=sys.stderr)
sys.exit(ExitCode.NO_FILES)
for paths in file_queue:
process(*paths, do_trace=args.trace)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment