#!/usr/bin/env python3 # This file is a part of toml++ and is subject to the the terms of the MIT license. # Copyright (c) Mark Gillard # See https://github.com/marzer/tomlplusplus/blob/master/LICENSE for the full license text. # SPDX-License-Identifier: MIT import sys import utils import io import re import json import yaml import math import dateutil.parser from pathlib import Path from datetime import datetime, date, time def sanitize(s): s = re.sub(r'[ _:;\/-]+', '_', s, 0, re.I | re.M) if s in ('bool', 'float', 'int', 'double', 'auto'): s = s + '_' return s def python_value_to_tomlpp(val): if isinstance(val, str): if re.fullmatch(r'^[+-]?[0-9]+[eE][+-]?[0-9]+$', val, re.M): return str(float(val)) else: return 'R"({})"sv'.format(val) elif isinstance(val, bool): return 'true' if val else 'false' elif isinstance(val, float): if math.isinf(val): return f'{"-" if val < 0.0 else ""}std::numeric_limits::infinity()' elif math.isnan(val): return 'std::numeric_limits::quiet_NaN()' else: return str(val) elif isinstance(val, int): if val == 9223372036854775807: return 'INT64_MAX' elif val == -9223372036854775808: return 'INT64_MIN' else: return str(val) elif isinstance(val, (TomlPPArray, TomlPPTable)): return str(val) elif isinstance(val, datetime): offset = None if val.tzinfo is not None: offset = val.tzinfo.utcoffset(val) mins = offset.total_seconds() / 60 offset = (int(mins / 60), int(mins % 60)) return 'toml::date_time{{ {{ {}, {}, {} }}, {{ {}, {}, {}, {}u }}{} }}'.format( val.year, val.month, val.day, val.hour, val.minute, val.second, val.microsecond*1000, '' if offset is None else ', {{ {}, {} }}'.format(offset[0], offset[1]) ) elif isinstance(val, date): return 'toml::date{{ {}, {}, {} }}'.format( val.year, val.month, val.day ) elif isinstance(val, time): return 'toml::time{{ {}, {}, {}, {} }}'.format( val.hour, val.minute, val.second, val.microsecond*1000 ) else: raise ValueError(str(type(val))) class TomlPPArray: def __init__(self, init_data=None): self.values = init_data if init_data else list() def render(self, indent = '', indent_declaration = False): s = '' if indent_declaration: s += indent if len(self.values) == 0: s += 'toml::array{}' else: s += 'toml::array{' for val in self.values: s += '\n' + indent + '\t' if isinstance(val, TomlPPArray) and len(self.values) == 1: s += 'toml::inserter{' if isinstance(val, (TomlPPTable, TomlPPArray)) and len(val) > 0: s += val.render(indent + '\t') else: s += python_value_to_tomlpp(val) if isinstance(val, TomlPPArray) and len(self.values) == 1: s += '}' s += ',' s += '\n' + indent + '}' return s def __str__(self): return self.render() def __len__(self): return len(self.values) class TomlPPTable: def __init__(self, init_data=None): self.values = init_data if init_data else dict() def render(self, indent = '', indent_declaration = False): s = '' if indent_declaration: s += indent if len(self.values) == 0: s += 'toml::table{}' else: s += 'toml::table{{' for key, val in self.values.items(): s += '\n' + indent + '\t{ ' if isinstance(val, (TomlPPTable, TomlPPArray)) and len(val) > 0: s += '\n' + indent + '\t\t{},'.format(python_value_to_tomlpp(str(key))) #s += '\n' + val.render(indent + '\t\t') s += ' ' + val.render(indent + '\t\t') s += '\n' + indent + '\t' else: s += '{}, {} '.format(python_value_to_tomlpp(str(key)), python_value_to_tomlpp(val)) s += '},' #else: #s += '}\n' s += '\n' + indent + '}}' return s def __str__(self): return self.render() def __len__(self): return len(self.values) def json_to_python(val): if isinstance(val, dict): if len(val) == 2 and "type" in val and "value" in val: val_type = val["type"] if val_type == "integer": return int(val["value"]) elif val_type == "float": return float(val["value"]) elif val_type == "string": return str(val["value"]) elif val_type == "bool": return True if val["value"].lower() == "true" else False elif val_type == "array": return json_to_python(val["value"]) elif val_type in ("datetime", "date", "time", "datetime-local"): dt_val = dateutil.parser.parse(val["value"]) if val_type == "date": return dt_val.date() elif val_type == "time": return dt_val.time() else: return dt_val else: raise ValueError(val_type) else: vals = dict() for k,v in val.items(): vals[k] = json_to_python(v) return vals elif isinstance(val, list): vals = list() for v in val: vals.append(json_to_python(v)) return vals else: raise ValueError(str(type(val))) def python_to_tomlpp(node): if isinstance(node, dict): table = TomlPPTable() for key, val in node.items(): table.values[key] = python_to_tomlpp(val) return table elif isinstance(node, (set, list, tuple)): array = TomlPPArray() for val in node: array.values.append(python_to_tomlpp(val)) return array else: return node class TomlTest: def __init__(self, file_path, is_valid_case): self.__name = file_path.stem self.__identifier = sanitize(self.__name) self.__data = utils.read_all_text_from_file(file_path, logger=True).strip() self.condition = '' self.requires_unicode = False if is_valid_case: self.__expected = True path_base = str(Path(file_path.parent, file_path.stem)) yaml_file = Path(path_base + r'.yaml') if yaml_file.exists(): self.__expected = python_to_tomlpp(yaml.load( utils.read_all_text_from_file(yaml_file, logger=True), Loader=yaml.FullLoader )) else: json_file = Path(path_base + r'.json') if json_file.exists(): self.__expected = python_to_tomlpp(json_to_python(json.loads( utils.read_all_text_from_file(json_file, logger=True), ))) else: self.__expected = False def name(self): return self.__name def identifier(self): return self.__identifier def data(self): return self.__data def expected(self): return self.__expected def __str__(self): return 'static constexpr auto {} = R"({})"sv;'.format( self.__identifier, self.__data, ) def load_tests(source_folder, is_valid_set, ignore_list): source_folder = source_folder.resolve() utils.assert_existing_directory(source_folder) files = utils.get_all_files(source_folder, all="*.toml") if ignore_list: files = [f for f in files if f.stem not in ignore_list] return [TomlTest(f, is_valid_set) for f in files] def set_condition(tests, condition, names): for test in tests: if test.name() in names: test.condition = condition def load_valid_inputs(tests, extern_root): tests['valid']['burntsushi'] = load_tests(Path(extern_root, 'toml-test', 'tests', 'valid'), True, ( # newline/escape handling tests. these get broken by I/O (I test them separately) 'string-escapes', # bugged: https://github.com/BurntSushi/toml-test/issues/58 'datetime' )) tests['valid']['iarna'] = load_tests(Path(extern_root, 'toml-spec-tests', 'values'), True, ( # these are stress-tests for 'large' datasets. I test these separately. Having them inline in C++ code is insane. 'qa-array-inline-1000', 'qa-array-inline-nested-1000', 'qa-key-literal-40kb', 'qa-key-string-40kb', 'qa-scalar-literal-40kb', 'qa-scalar-literal-multiline-40kb', 'qa-scalar-string-40kb', 'qa-scalar-string-multiline-40kb', 'qa-table-inline-1000', 'qa-table-inline-nested-1000', # newline/escape handling tests. these get broken by I/O (I test them separately) 'spec-newline-1', 'spec-newline-2', 'spec-newline-3', 'spec-string-escape-1', 'spec-string-escape-2', 'spec-string-escape-3', 'spec-string-escape-4', 'spec-string-escape-5', 'spec-string-escape-6', 'spec-string-escape-7', 'spec-string-escape-8', 'spec-string-escape-9', # bugged: https://github.com/iarna/toml-spec-tests/issues/3 'spec-date-time-6', 'spec-date-time-local-2', 'spec-time-2', # breaks gcc: 'spec-string-basic-multiline-4', )) def load_invalid_inputs(tests, extern_root): tests['invalid']['burntsushi'] = load_tests(Path(extern_root, 'toml-test', 'tests', 'invalid'), False, ( # false negatives after TOML 0.4.0 'array-mixed-types-arrays-and-ints', 'array-mixed-types-ints-and-floats', 'array-mixed-types-strings-and-ints' )) set_condition(tests['invalid']['burntsushi'], '!TOML_LANG_UNRELEASED', ( 'datetime-malformed-no-secs', 'inline-table-linebreak', 'multi-line-inline-table', 'string-byte-escapes' )) tests['invalid']['iarna'] = load_tests(Path(extern_root, 'toml-spec-tests', 'errors'), False, ( # I test these explicitly in the other test files (they get broken by I/O) 'comment-control-1', 'comment-control-2', 'comment-control-3', 'comment-control-4', 'string-basic-control-1', 'string-basic-control-2', 'string-basic-control-3', 'string-basic-control-4', 'string-basic-multiline-control-1', 'string-basic-multiline-control-2', 'string-basic-multiline-control-3', 'string-basic-multiline-control-4', 'string-literal-control-1', 'string-literal-control-2', 'string-literal-control-3', 'string-literal-control-4', 'string-literal-multiline-control-1', 'string-literal-multiline-control-2', 'string-literal-multiline-control-3', 'string-literal-multiline-control-4' )) set_condition(tests['invalid']['iarna'], '!TOML_LANG_UNRELEASED', ( 'inline-table-trailing-comma', )) def requires_unicode(s): for c in s: if ord(c) > 127: return True return False def write_test_file(name, test_cases): conditions = set() for test in test_cases: conditions.add(test.condition) test_file_path = Path(utils.entry_script_dir(), '..', 'tests', rf'conformance_{sanitize(name.strip())}.cpp').resolve() print(rf'Writing to {test_file_path}') with open(test_file_path, 'w', encoding='utf-8', newline='\n') as test_file: write = lambda txt: print(txt, file=test_file) # preamble write('// This file is a part of toml++ and is subject to the the terms of the MIT license.') write('// Copyright (c) Mark Gillard ') write('// See https://github.com/marzer/tomlplusplus/blob/master/LICENSE for the full license text.') write('// SPDX-License-Identifier: MIT') write('//-----') write('// this file was generated by generate_conformance_tests.py - do not modify it directly') write('') write('#include "tests.h"') write('using namespace toml::impl;') write('') # test data write('TOML_DISABLE_WARNINGS; // unused variable spam') write('') write('namespace') write('{') for test in test_cases: s = f'\t{test}' test.requires_unicode = requires_unicode(s) if test.requires_unicode: write('\t#if UNICODE_LITERALS_OK') write(s) write('\t#endif // UNICODE_LITERALS_OK') else: write(s) write('}') write('') write('TOML_ENABLE_WARNINGS;') write('') # tests write(f'TEST_CASE("conformance - {name}")') write('{') for condition in conditions: if condition != '': write('') write(f'\t#if {condition}'); for test in test_cases: if test.condition != condition: continue expected = test.expected() if isinstance(expected, bool): if expected: write(f'\tparsing_should_succeed(FILE_LINE_ARGS, {test.identifier()});') else: write(f'\tparsing_should_fail(FILE_LINE_ARGS, {test.identifier()});') else: s = expected.render('\t\t') if not test.requires_unicode: test.requires_unicode = requires_unicode(s) if test.requires_unicode: write('\t#if UNICODE_LITERALS_OK') write(f'\tparsing_should_succeed(FILE_LINE_ARGS, {test.identifier()}, [](toml::table&& tbl)') write('\t{') write(f'\t\tauto expected = {s};') write('\t\tREQUIRE(tbl == expected);') write('\t});') if test.requires_unicode: write('\t#endif // UNICODE_LITERALS_OK') write('') if condition != '': write(f'\t#endif // {condition}'); write('}') write('') def main(): extern_root = Path(utils.entry_script_dir(), '..', 'external').resolve() utils.assert_existing_directory(extern_root) assert extern_root.exists() tests = { 'valid': dict(), 'invalid': dict() } load_valid_inputs(tests, extern_root) load_invalid_inputs(tests, extern_root) for test_type, test_groups in tests.items(): for test_group, test_cases in test_groups.items(): write_test_file('{}/{}'.format(test_group, test_type), test_cases ) if __name__ == '__main__': utils.run(main, verbose=True)