diff --git a/tools/text2filetree.py b/tools/text2filetree.py new file mode 100755 index 0000000000..d23fdebb5d --- /dev/null +++ b/tools/text2filetree.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys + + +def parse_file_tree(input_data, tab_width=4): + """ + Parse a file tree hierarchy from an input stream and convert it to a nested dictionary. + + :param input_data: Input string or file-like object representing the file tree + :param tab_width: Number of spaces to replace tabs with + :return: A nested dictionary representing the file tree + """ + # If input is a string, convert to a list of lines + if isinstance(input_data, str): + lines = input_data.splitlines() + else: + lines = [line.rstrip() for line in input_data.readlines()] + + def _parse_tree(lines): + # Create main tree dictionary + tree = {} + + # Stack to keep track of nested dictionaries and their indentation levels + dict_stack = [(tree, -1)] + + for i, line in enumerate(lines): + # Skip empty lines + if not line.strip(): + continue + + # Replace tabs with specified number of spaces + line = line.replace("\t", " " * tab_width).rstrip() + + # Compute indentation and clean name + indent_level = len(line) - len(line.lstrip()) + current_name = line.strip() + + # Find the correct parent dictionary based on indentation + while dict_stack and dict_stack[-1][1] >= indent_level: + dict_stack.pop() + + # If stack is empty, something went wrong with indentation + if not dict_stack: + raise ValueError(f"Invalid indentation for line: {line}") + + # Get the current parent dictionary + parent_dict, _ = dict_stack[-1] + + # Determine if it's a directory + # 1. Explicitly marked with '/' + # 2. Has children on next lines with more indentation + is_dir = current_name.endswith("/") or ( + i < len(lines) - 1 + and len(lines[i + 1]) - len(lines[i + 1].lstrip()) > indent_level + ) + + # Normalize directory name + if is_dir and not current_name.endswith("/"): + current_name += "/" + + # Add item to the dictionary + if is_dir: + # Create a new nested dictionary for directories + new_dict = {} + parent_dict[current_name] = new_dict + # Push new dictionary and its indentation to the stack + dict_stack.append((new_dict, indent_level)) + else: + # Add files with empty string value + parent_dict[current_name] = "" + + return tree + + # Call the internal parsing function and return its result + return _parse_tree(lines) + + +def decorate_output(output_str, decoration_type): + """ + Decorate the output based on the specified decoration type + + :param output_str: JSON string to be decorated + :param decoration_type: Type of decoration to apply + :return: Decorated output string + """ + if decoration_type == "bids-filetree": + return f"{{{{ MACROS___make_filetree_example(\n\n{output_str}\n\n) }}}}" + return output_str + + +def main(): + # Set up argument parsing + parser = argparse.ArgumentParser( + description="Parse file tree hierarchy into a nested dictionary." + ) + parser.add_argument( + "input_file", + nargs="?", + type=argparse.FileType("r"), + default=sys.stdin, + help="Input file to parse (default: stdin)", + ) + parser.add_argument( + "--tab-width", + type=int, + default=4, + help="Number of spaces to replace tabs with (default: 4)", + ) + parser.add_argument( + "--output-file", + type=str, + default=None, + help="Output file to write the parsed dictionary (default: stdout)", + ) + parser.add_argument( + "--indent", type=int, default=2, help="Indentation for JSON output (default: 2)" + ) + parser.add_argument( + "-D", + "--decorate", + type=str, + choices=["bids-filetree"], + default=None, + help="Decorate the output with a specific format", + ) + + # Parse arguments + args = parser.parse_args() + + # Parse the file tree + result = parse_file_tree(args.input_file, args.tab_width) + + # Prepare output using json.dumps with specified indent + output_str = json.dumps(result, indent=args.indent) + + # Decorate output if specified + if args.decorate: + output_str = decorate_output(output_str, args.decorate) + + # Determine output destination + if args.output_file: + # Write to file + with open(args.output_file, "w") as f: + f.write(output_str) + else: + # Print to stdout + print(output_str) + + +def test_example1(): + """ + Test parsing a file tree with nested directories + """ + input_tree = """file1 +a.dat +sub-1 + subsub + file.dat + filehere +anotherfile""" + + expected_output = { + "file1": "", + "a.dat": "", + "sub-1/": {"subsub/": {"file.dat": ""}, "filehere": ""}, + "anotherfile": "", + } + + # Parse the input tree + result = parse_file_tree(input_tree) + + # Use deep comparison to check the result + assert result == expected_output, f"Expected {expected_output}, but got {result}" + + +def test_decorations(): + """ + Test the output decoration functionality + """ + dummy_json = '{"test": "value"}' + + # Test bids-filetree decoration + decorated = decorate_output(dummy_json, "bids-filetree") + assert ( + decorated == '{{ MACROS___make_filetree_example(\n\n{"test": "value"}\n\n) }}' + ) + + # Test no decoration + undecorated = decorate_output(dummy_json, None) + assert undecorated == dummy_json + + +def test_more_complex_tree(): + """ + Test a more complex nested directory structure + """ + input_tree = """root + subdir1 + file1.txt + subsubdir + file2.txt + subdir2 + file3.txt""" + + expected_output = { + "root/": { + "subdir1/": {"file1.txt": "", "subsubdir/": {"file2.txt": ""}}, + "subdir2/": {"file3.txt": ""}, + } + } + + # Parse the input tree + result = parse_file_tree(input_tree) + + # Use deep comparison to check the result + assert result == expected_output, f"Expected {expected_output}, but got {result}" + + +def test_neuroimaging_dataset(): + """ + Test parsing a complex neuroimaging dataset file structure + """ + input_tree = """dataset_description.json +tasks.tsv +tasks.json +participants.tsv +sub-A/ + ses-20220101/ + ephys/ + sub-A_ses-20220101_task-nosepoke_ephys.nix + sub-A_ses-20220101_task-nosepoke_ephys.json + sub-A_ses-20220101_task-nosepoke_events.tsv + sub-A_ses-20220101_task-rest_ephys.nix + sub-A_ses-20220101_task-rest_ephys.json + sub-A_ses-20220101_channels.tsv + sub-A_ses-20220101_electrodes.tsv + sub-A_ses-20220101_probes.tsv + ses-20220102/ + ephys/ + sub-A_ses-20220102_task-rest_ephys.nix + sub-A_ses-20220102_task-rest_ephys.json + sub-A_ses-20220102_channels.tsv + sub-A_ses-20220102_electrodes.tsv + sub-A_ses-20220102_probes.tsv""" + + expected_output = { + "dataset_description.json": "", + "tasks.tsv": "", + "tasks.json": "", + "participants.tsv": "", + "sub-A/": { + "ses-20220101/": { + "ephys/": { + "sub-A_ses-20220101_task-nosepoke_ephys.nix": "", + "sub-A_ses-20220101_task-nosepoke_ephys.json": "", + "sub-A_ses-20220101_task-nosepoke_events.tsv": "", + "sub-A_ses-20220101_task-rest_ephys.nix": "", + "sub-A_ses-20220101_task-rest_ephys.json": "", + "sub-A_ses-20220101_channels.tsv": "", + "sub-A_ses-20220101_electrodes.tsv": "", + "sub-A_ses-20220101_probes.tsv": "", + } + }, + "ses-20220102/": { + "ephys/": { + "sub-A_ses-20220102_task-rest_ephys.nix": "", + "sub-A_ses-20220102_task-rest_ephys.json": "", + "sub-A_ses-20220102_channels.tsv": "", + "sub-A_ses-20220102_electrodes.tsv": "", + "sub-A_ses-20220102_probes.tsv": "", + } + }, + }, + } + + # Parse the input tree + result = parse_file_tree(input_tree) + + # Use deep comparison to check the result + assert result == expected_output, f"Expected {expected_output}, but got {result}" + + +if __name__ == "__main__": + # If run directly, execute main + main()