diff --git a/setup.py b/setup.py index ac5d6af..3c402a9 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='sfzlint', - version='0.1.2', + version='0.1.4', description='parser and linter for sfz files written in python', author='jisaacstone', packages=['sfzlint'], diff --git a/sfzlint/cli.py b/sfzlint/cli.py index 4e857ac..fb2b6ee 100644 --- a/sfzlint/cli.py +++ b/sfzlint/cli.py @@ -2,11 +2,11 @@ import sys from pathlib import Path from argparse import ArgumentParser -from . import spec, parser, lint, opcodes +from . import spec, parser, lint, opcodes, settings def print_codes(search=None, filters=None, printer=print): - for o in spec.opcodes.values(): + for o in spec.opcodes().values(): print_code(o, search, filters, printer) @@ -47,7 +47,7 @@ def print_codes_in_path(path, search, filters, printer=print): codes.add(str(opcode)) except Exception as e: sys.stderr.write(f'Error checking {fp}: {e}') - op_data = spec.opcodes + op_data = spec.opcodes() def unknown(code): return {'name': code, 'ver': 'unknown'} @@ -80,7 +80,14 @@ def eq_filter(string): '--path', '-p', type=Path, help='print only opcodes found in the sfz file(s) in PATH') + parser.add_argument( + '--no-pickle', + action='store_true', + help='do not use the pickle cache (for testing)') args = parser.parse_args() + if not args.no_pickle: + settings.pickle = True + 1/0 try: if args.path: print_codes_in_path(args.path, args.search, args.filters, printer) @@ -91,4 +98,5 @@ def eq_filter(string): def sfzlint(): + settings.pickle = True return lint.main() diff --git a/sfzlint/lint.py b/sfzlint/lint.py index 1720b0d..5224ab7 100644 --- a/sfzlint/lint.py +++ b/sfzlint/lint.py @@ -7,7 +7,7 @@ from pathlib import Path from lark.exceptions import UnexpectedCharacters, UnexpectedToken from .parser import validate, SFZ, SFZValidatorConfig -from . import spec +from . import spec, settings formats = { @@ -101,7 +101,12 @@ def main(): parser.add_argument( '--rel-path', help='validate includes and sample paths relative to this path') + parser.add_argument( + '--no-pickle', + action='store_true', + help='do not use the pickle cache (for testing)') args = parser.parse_args() + settings.pickle = not args.no_pickle lint(args) diff --git a/sfzlint/opcodes.py b/sfzlint/opcodes.py index fa73655..6e506cd 100644 --- a/sfzlint/opcodes.py +++ b/sfzlint/opcodes.py @@ -98,7 +98,7 @@ def _try_cc_subs(opcode): for alt in cc_alts: if alt != variation: alternative = opcode.replace(variation, alt) - if alternative in spec.cc_opcodes: + if alternative in spec.cc_opcodes(): return alternative return None @@ -106,9 +106,9 @@ def _try_cc_subs(opcode): def validate_curvecc(raw_opcode, token, config): '''Specializing for now, until we get this into the .yml''' opcode, subs = OpcodeIntRepl.sub(raw_opcode) - known_op = opcode in spec.opcodes + known_op = opcode in spec.opcodes() if known_op: - validation = spec.opcodes[opcode] + validation = spec.opcodes()[opcode] spec_ver = config.spec_versions if spec_ver and validation['ver'] not in spec_ver: raise ValidationError( @@ -126,13 +126,13 @@ def validate_curvecc(raw_opcode, token, config): def validate_opcode_expr(raw_opcode, token, config): - if raw_opcode not in spec.opcodes: + if raw_opcode not in spec.opcodes(): opcode, subs = OpcodeIntRepl.sub(raw_opcode) else: opcode = raw_opcode.value subs = {} - if opcode not in spec.opcodes: + if opcode not in spec.opcodes(): if 'cc' in opcode and 'curvecc' not in opcode: new_opcode = _try_cc_subs(opcode) if new_opcode: @@ -143,7 +143,7 @@ def validate_opcode_expr(raw_opcode, token, config): f'undocumented alias of {new_opcode} ({opcode})', raw_opcode) try: - op_meta = spec.opcodes[opcode] + op_meta = spec.opcodes()[opcode] except KeyError: raise ValidationWarning( f'unknown opcode ({opcode})', diff --git a/sfzlint/settings.py b/sfzlint/settings.py new file mode 100644 index 0000000..064da81 --- /dev/null +++ b/sfzlint/settings.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +import os + + +pickle = bool(os.environ.get('NOPICKLE')) diff --git a/sfzlint/spec.py b/sfzlint/spec.py index e5182dc..efac8fa 100644 --- a/sfzlint/spec.py +++ b/sfzlint/spec.py @@ -7,7 +7,7 @@ from numbers import Real # int or float import appdirs import yaml -from . import validators +from . import validators, settings ver_mapping = { @@ -31,6 +31,7 @@ def ver_code(version): 'string': str, } + # there will be repetitive calls to listdir @lru_cache(maxsize=32) def listdir(path): @@ -81,6 +82,12 @@ def validate(self, value, config, *args): return 'no corresponding curve_index found' +class KeyValidator(validators.Range): + def validate(self, value, config, *args): + if value == -1 and config.spec_versions == ['v1']: + return '-1 is only valid from V2 onward' + + overrides = { ('tune', 'value', 'validator'): TuneValidator(), ('sample', 'value', 'validator'): SampleValidator(), @@ -93,6 +100,9 @@ def validate(self, value, config, *args): ('group_label', 'value', 'type'): object, ('region_label', 'value', 'type'): object, ('sw_label', 'value', 'type'): object, + # -1 prevents region being triggered by any key + ('hikey', 'value', 'validator'): KeyValidator(-1, 127), + ('lokey', 'value', 'validator'): KeyValidator(-1, 127), } @@ -160,9 +170,13 @@ def op_to_validator(op_data, **kwargs): alias_meta['ver'] = valid_meta['ver'] yield alias_meta if 'modulation' in alias: - yield from extract_modulation(alias['modulation'].items(), alias['name']) + yield from extract_modulation( + alias['modulation'].items(), + alias['name']) if 'modulation' in op_data: - yield from extract_modulation(op_data['modulation'].items(), op_data['name']) + yield from extract_modulation( + op_data['modulation'].items(), + op_data['name']) def extract_modulation(items, op_name): @@ -179,9 +193,9 @@ def _extract_vdr_meta(op_data, valid_meta): if v_key not in valid_meta: valid_meta[v_key] = {} valid_meta[v_key]['validator'] = _validator(op_data[v_key]) - if 'type' in op_data[v_key]: - valid_meta[v_key]['type'] = type_mapping[ - op_data[v_key]['type_name']] + type_name = op_data[v_key].get('type_name') + if type_name: + valid_meta[v_key]['type'] = type_mapping[type_name] def _validator(data_value): @@ -199,6 +213,9 @@ def _validator(data_value): def _pickled(name, fn): + # pickling as cache cuts script time by ~400ms on my system + if not settings.pickle: + return fn() user_cache_dir = Path(appdirs.user_cache_dir("sfzlint", "jisaacstone")) p_file = user_cache_dir / f'{name}.pickle' if not p_file.exists(): @@ -212,6 +229,17 @@ def _pickled(name, fn): return data -# pickling as cache cuts script time by ~400ms on my system -opcodes = _pickled('opcodes', lambda: _override(_extract())) -cc_opcodes = {k for k in opcodes if 'cc' in k and 'curvecc' not in k} +_cache = {} + + +def opcodes(): + if 'opcodes' not in _cache: + _cache['opcodes'] = _pickled('opcodes', lambda: _override(_extract())) + return _cache['opcodes'] + + +def cc_opcodes(): + if 'cc_opcodes' not in _cache: + _cache['cc_opcodes'] = { + k for k in opcodes() if 'cc' in k and 'curvecc' not in k} + return _cache['cc_opcodes'] diff --git a/sfzlint/validators.py b/sfzlint/validators.py index c340732..e0b69e8 100644 --- a/sfzlint/validators.py +++ b/sfzlint/validators.py @@ -60,7 +60,7 @@ def __init__(self, name): self.name = name def validate(self, value, *args): - opc = spec.opcodes[self.name].get('value') + opc = spec.opcodes()[self.name].get('value') if opc and 'validator' in opc: return opc['validator'].validate(value, *args) diff --git a/tests/test_cli.py b/tests/test_cli.py index e550ce8..04d4b14 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ from pathlib import Path from collections import namedtuple from sfzlint.cli import sfzlint, sfzlist +from sfzlint import settings fixture_dir = Path(__file__).parent / 'fixtures' @@ -20,21 +21,32 @@ def __new__(cls, file, row, column, l_m): return super().__new__(cls, file, row, column, level, message) +def patchargs(path, *args): + newargv = ['sfzlint', '--no-pickle', str(fixture_dir / path)] + list(args) + + def wrapper(fn): + return patch('sys.argv', new=newargv)(fn) + + return wrapper + + class TestSFZLint(TestCase): + def tearDown(self): + # Ensure this does not get accidentally set + self.assertFalse(settings.pickle) + def assert_has_message(self, message, err_list): msglen = len(message) msgs = {e.message[:msglen] for e in err_list} self.assertIn(message, msgs, f'{message} not in {err_list}') - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/valid.sfz')]) + @patchargs('basic/valid.sfz') @patch('builtins.print') def test_valid_file(self, print_mock): sfzlint() self.assertFalse(print_mock.called, print_mock.call_args_list) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/bad.sfz')]) + @patchargs('basic/bad.sfz') @patch('builtins.print') def test_invalid_file(self, print_mock): sfzlint() @@ -43,8 +55,7 @@ def test_invalid_file(self, print_mock): for a in print_mock.call_args_list] self.assert_has_message('unknown opcode', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic')]) + @patchargs('basic') def test_lint_dir(self): with patch('builtins.print') as print_mock: sfzlint() @@ -53,8 +64,7 @@ def test_lint_dir(self): for a in print_mock.call_args_list] self.assert_has_message('unknown opcode', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'include/inbadfile.sfz')]) + @patchargs('include/inbadfile.sfz') def test_include_parse_error(self): with patch('builtins.print') as print_mock: sfzlint() @@ -63,16 +73,13 @@ def test_include_parse_error(self): for a in print_mock.call_args_list] self.assert_has_message('error loading include', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'include/hasinc.sfz')]) + @patchargs('include/hasinc.sfz') @patch('builtins.print') def test_include_define(self, print_mock): sfzlint() self.assertFalse(print_mock.called, print_mock.call_args_list) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/valid.sfz'), - '--spec-version', 'v1']) + @patchargs('basic/valid.sfz', '--spec-version', 'v1') @patch('builtins.print') def test_spec_version(self, print_mock): sfzlint() @@ -82,8 +89,7 @@ def test_spec_version(self, print_mock): self.assert_has_message('header spec v2 not in', calls) self.assert_has_message('opcode spec v2 is not', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/nosample.sfz')]) + @patchargs('basic/nosample.sfz') @patch('builtins.print') def test_missing_sample(self, print_mock): sfzlint() @@ -92,22 +98,19 @@ def test_missing_sample(self, print_mock): for a in print_mock.call_args_list] self.assert_has_message('file not found', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/relsample.sfz')]) + @patchargs('basic/relsample.sfz') def test_relative_path(self): with patch('builtins.print') as print_mock: sfzlint() self.assertFalse(print_mock.called, print_mock.call_args_list) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/def_path.sfz')]) + @patchargs('basic/def_path.sfz') def test_default_path(self): with patch('builtins.print') as print_mock: sfzlint() self.assertFalse(print_mock.called, print_mock.call_args_list) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'basic/badcase.sfz')]) + @patchargs('basic/badcase.sfz') def test_bad_case(self): with patch('builtins.print') as print_mock: sfzlint() @@ -119,16 +122,14 @@ def test_bad_case(self): else: self.assert_has_message('file not found', calls) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'include/sub/relpath.sfz'), - '--rel-path', str(fixture_dir / 'include')]) + @patchargs('include/sub/relpath.sfz', + '--rel-path', str(fixture_dir / 'include')) def test_rel_path(self): with patch('builtins.print') as print_mock: sfzlint() self.assertFalse(print_mock.called, print_mock.call_args_list) - @patch('sys.argv', new=[ - 'sfzlint', str(fixture_dir / 'aria_program.xml')]) + @patchargs('aria_program.xml') def test_xml_with_defines(self): with patch('builtins.print') as print_mock: sfzlint() @@ -140,7 +141,7 @@ def test_xml_with_defines(self): class TestSFZList(TestCase): - @patch('sys.argv', new=['sfzlist']) + @patch('sys.argv', new=['sfzlist', '--no-pickle']) def test_valid_file(self): print_mock = MagicMock() sfzlist(print_mock) @@ -151,7 +152,7 @@ def test_valid_file(self): self.assertIn(test_opcode, opcodes) @patch('sys.argv', new=[ - 'sfzlist', '--path', str(fixture_dir / 'basic')]) + 'sfzlist', '--no-pickle', '--path', str(fixture_dir / 'basic')]) def test_path_dir(self): print_mock = MagicMock() sfzlist(print_mock) diff --git a/tests/test_valid.py b/tests/test_valid.py index 23038fd..f8d674b 100644 --- a/tests/test_valid.py +++ b/tests/test_valid.py @@ -6,6 +6,7 @@ class TestValid(TestCase): + def assertEqual(self, aa, bb, *args, **kwargs): # handle tokens transparently if hasattr(aa, 'value'): @@ -146,6 +147,19 @@ def test_aria_control_code(self): ''') self.assertEqual(sfz.headers[0]['amplitude_oncc140'], 75) + def test_neg_hikey(self): + # In the SFZ 1 specification, the allowed range is 0 to 127. + # However, SFZ 2 additionally includes the possibility to set + # lokey and hikey to -1, to prevent a region from being triggered + sfz = self._parse( + ''' + + hikey=-1 + lokey=-1 + ''') + self.assertEqual(sfz.headers[0]['hikey'], -1) + self.assertEqual(sfz.headers[0]['lokey'], -1) + def test_hint(self): sfz = self._parse( ''' diff --git a/testsuite.sh b/testsuite.sh new file mode 100755 index 0000000..c89d678 --- /dev/null +++ b/testsuite.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -ex + +git clone https://github.com/sfz/tests.git /tmp/suite + +out=$(sfzlint --no-pickle "/tmp/suite/sfz1 basic tests") +if [ -n "${out}" ]; then + echo "v1 tests failed" + exit 1 +fi +out=$(sfzlint --no-pickle "/tmp/suite/sfz2 basic tests") +if [ -n "${out}" ]; then + echo "v2 tests failed" + exit 2 +fi + +rm -rf /tmp/suite