diff --git a/anytree/importer/__init__.py b/anytree/importer/__init__.py index 15a3f70..095a6c1 100644 --- a/anytree/importer/__init__.py +++ b/anytree/importer/__init__.py @@ -1,4 +1,6 @@ """Importer.""" from .dictimporter import DictImporter # noqa +from .indentedtextimporter import IndentedTextImporter +from .indentedtextimporter import IndentedTextImporterError from .jsonimporter import JsonImporter # noqa diff --git a/anytree/importer/indentedtextimporter.py b/anytree/importer/indentedtextimporter.py new file mode 100644 index 0000000..d0f09e3 --- /dev/null +++ b/anytree/importer/indentedtextimporter.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from anytree import Node + + +class IndentedTextImporter(object): + + def __init__(self, rootname="root"): + r""" + Import Tree from indented text. + + Every line of text is converted to an instance of Node. + The text of the lines establish the names of the nodes. + Indentation establishes the hierarchy between the nodes. + + White space must be all spaces, and what constitutes + indentation must be consistent -- That is, if you use x2 + spaces to establish 1 level of indentation, then all + indentations much be x2 spaces. The first found indentation + sets the model for all further indentation. + + (If you wish to use this with tabs, simply replace the input + text string's leading tabs with spaces, before use. + This may be as simple as s.replace("\t", " "), if the only + tabs used are used for indentation.) + + The name for a root node must be supplied; + Every line starting at column 0 is a child of this root node. + + Keyword Args: + rootname: name for the root node (invisible) + + >>> from anytree.importer import IndentedTextImporter + >>> from anytree import RenderTree + >>> importer = IndentedTextImporter("root") + >>> data = ''' + ... sub0 + ... sub0A + ... sub0B + ... sub1 + ... ''' + >>> root = importer.import_(data) + >>> print(RenderTree(root)) + Node('/root') + ├── Node('/root/sub0') + │ ├── Node('/root/sub0/sub0A') + │ └── Node('/root/sub0/sub0B') + └── Node('/root/sub1') + """ + self.rootname = rootname + + def import_(self, text): + """Import tree from `text`.""" + expected_indentation = None + root = Node(self.rootname) # node implied at "column -(INDENTx1)" + n = None # last node's indentation level + parents_at_levels = [root] # parents at indentation levels + for i, line in enumerate(text.splitlines()): + sp = len(line) - len(line.lstrip(" ")) + name = line[sp:] + if not name: # blank line + continue + if sp > 0 and expected_indentation is None: # FIRST indent + expected_indentation = sp # imprint from first indent + n = 0 # last indentation was 0 + if expected_indentation is None and sp == 0: + node = Node(name, parent=root) + if len(parents_at_levels) == 1: + parents_at_levels.append(node) # first time + elif len(parents_at_levels) == 2: + parents_at_levels[-1] = node # still no expectation + elif expected_indentation is None: + raise IndentedTextImporterError("bad indent at line", i) + elif sp == n+expected_indentation: + node = Node(name, parent=parents_at_levels[-1]) + parents_at_levels.append(node) + elif sp == n: + node = Node(name, parent=parents_at_levels[-2]) + parents_at_levels[-1] = node # replace prior end + elif (sp < n) and (sp % expected_indentation == 0): + prior_levels = n // expected_indentation + levels = sp // expected_indentation + node = Node(name, parent=parents_at_levels[levels]) + parents_at_levels[levels+1:] = [node] # replace dismissed + else: + raise IndentedTextImporterError("bad indent at line", i) + n = sp + return root + + +class IndentedTextImporterError(RuntimeError): + """IndentedTextImporter Error.""" diff --git a/tests/test_indentedtextimporter.py b/tests/test_indentedtextimporter.py new file mode 100644 index 0000000..5eac4ce --- /dev/null +++ b/tests/test_indentedtextimporter.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +from anytree.importer import IndentedTextImporter +from anytree.importer import IndentedTextImporterError + +docstring_sample = """ +sub0 + sub0A + sub0B +sub1 +"""[1:-1] + +check_docstring_sample = ["root", 0, "sub0", 0, "sub0A", -1, 1, "sub0B", -1, -1, + 1, "sub1"] + + +faulty_indent = """ +sub0 + sub0A + sub0B +sub1 +"""[1:-1] + + +early_bad_indent = """ + sub0 + sub1 +"""[1:-1] + + +large_example = """ +foo + bar + baz + this line has a lot of text on it + so does this one; lots of text to go around here + what if I embed a / in here + these/should/still/work/ + /with/many////slashes + +and there was a blank line in here too, + + and another blank line, + and indentation afterwards +how is it? +"""[1:-1] + + +check_large_example = ["root", 0, "foo", 0, "bar", 0, "baz", -1, -1, 1, + "this line has a lot of text on it", 0, + "so does this one; lots of text to go around here", + -1, -1, 2, "what if I embed a / in here", 0, + "these/should/still/work/", 0, "/with/many////slashes", + -1, -1, -1, -1, 1, + "and there was a blank line in here too,", 0, + "and another blank line,", -1, 1, + "and indentation afterwards", -1, -1, 2, "how is it?"] + + +def check(node, instructions): + for cmd in instructions: + if cmd == -1: # "go up" + node = node.parent + elif isinstance(cmd, int): # "go to numbered child" + node = node.children[cmd] + else: + if cmd != node.name: # "verify that this is the text" + raise ValueError("unexpected value located", cmd, node.name) + + +def test_importer(): + """IndentedTextImporter test""" + importer = IndentedTextImporter() + root = importer.import_(docstring_sample) + check(root, check_docstring_sample) + + +def test_faulty_indent(): + """IndentedTextImporter: bad indentation test""" + importer = IndentedTextImporter() + try: + root = importer.import_(faulty_indent) + except IndentedTextImporterError as e: + (err_name, err_lineno) = e.args + if err_name == "bad indent at line" and err_lineno == 2: + pass + else: + raise ValueError("expected bad indent error on line 2") + + +def test_early_bad_indent(): + """IndentedTextImporter: bad indentation test""" + importer = IndentedTextImporter() + try: + root = importer.import_(early_bad_indent) + except IndentedTextImporterError as e: + (err_name, err_lineno) = e.args + if err_name == "bad indent at line" and err_lineno == 0: + pass + else: + raise ValueError("expected bad indent error on line 0") + + +def test_large_example(): + """IndentedTextImporter: bad indentation test""" + importer = IndentedTextImporter() + root = importer.import_(large_example) + check(root, check_large_example)