#-==@h1
# Indented Text File Reader

#-== Utility for reading indented blocks of text from a file
# and forming a tree data structure for use in Python.
# This allows Python programs to use config files and
# data files which respect whitespace the same as Python itself.

#-==@class
class IndentReader:
	#-== @attributes
	#	filepath:	path to the file this reads
	#   node_class: 		the Python class this uses for its nodes
	# 							(default is the /Node class below)
	#	preserve_newlines:	boolean describing if
	#							the reader keeps newlines
	#							in the node /data
	#	allowed_indents:	a string of characters which are
	#							considered valid indentations
	#	indent_char:		the character or string the reader
	#							has identified for use as indentation
	#							in the current file
	#	current_line:		the current line number the reader is parsing
	#	root:				the root node of the tree data structure
	#							generated by the reader


	#-==@method
	def __init__(self, filepath,
			  		node_class = None,
					preserve_newlines = False,
					allowed_indents = ' \t',
					root_data='<root>'):
		#-== @params
		#	filepath:	path to the file this reads
		#   node_class: 		the Python class this uses for its nodes
	# 								(default is the /Node class below)
		#	preserve_newlines:	boolean describing if
		#							the reader keeps newlines
		#							in the node /data
		#	allowed_indents:	list of strings which are
		#							considered valid indentations
		#	root_data:			default /data placed in the root node
		#
		#-== When an /IndentReader object is instantiated, it will
		# immediately attempt to read and parse the file.
		# Once /IndentReader identifies a valid indent string (via the
		# /allowed_indents value), all indents must use that same
		# character or string for their indentation (just like Python,
		# you cannot mix tabs and spaces for indentation).
		#
		#-== The completed data structure is available for
		# direct consumption via the /root attribute. In the event that
		# an error occurs during parsing, the reader raises a /ReaderError .
		# The exception message indicates the error and line number.
		# The /root data structure, /current_line , and /indent_char remain
		# after parsing is complete in order to provide debug information,
		# if necessary.
		#
		#-== This class can be subclassed to create a specialized reader
		# for specific file formats or use cases. Typically the only
		# override needed is to change the /parse_data() method to
		# use logic that is specific to the use case desired.
		# You may also want to provide a different /node_class for this reader
		# (see the /Node class below).

		self.node_class = node_class if node_class else Node
		self.filepath = filepath
		self.preserve_newlines = preserve_newlines
		self.root = self.node_class(data=root_data)
		self.allowed_indents = allowed_indents

		self.current_line = 0
		self.current_node = self.root
		self.prev_child = None
		self.indent_char = None

		self.read_file()

	#-==@method
	def prepare_for_read(self):
		#-== Resets all variables to prepare
		# to read a new file.

		self.indent_char = None
		self.root.children.clear()
		self.root.child_indent = None
		self.current_line = 0
		self.current_node = self.root
		self.prev_child = None


	#-==@method
	def read_file(self):
		#-== Reads and parses the file of /filename
		# and stores the result in the /root attribute.
		# This method is automatically called upon object
		# instantiation.

		self.prepare_for_read()
		with open(self.filepath, 'r') as thefile:
			for line in thefile.readlines():
				self.current_line += 1
				if line.strip():
					self.parse_line(line)

	#-==@method
	def parse_line(self, line):
		#-== @params
		#	line: the raw line of text read from the file
		#
		#-== Parses the line, identifies the indentation level,
		# then creates a node and inserts it into  the tree.

		indent = ''
		for c in line:
			if c not in self.allowed_indents:
				self.current_node = self.find_node(indent)
				new_node = self.prepare_node(line, indent)
				self.current_node.add_child(new_node)
				self.prev_child = new_node
				break
			else:
				if self.indent_char is None:
					self.indent_char = c
				if c != self.indent_char:
					raise ReaderError('line {} uses different indentation character'.format(self.current_line))
				indent += c

	#-==@method
	def find_node(self, indent):
		#-== @params
		#	indent:	the indent string to match
		#
		#-== @return
		# The parent node which matches the /indent provided
		#
		#-== Starting at the current node, this method
		# traverses back up the parents to find the indentation
		# level that matches, then returns the parent node
		# of that indentation level. If no matching node is found,
		# this raises a /ReaderError .

		if len(indent) == 0:
			return self.root
		if len(indent) > len(self.current_node.child_indent):
			# we've found a deeper indent level!
			# return the last node we placed
			return self.prev_child

		next_parent = self.current_node
		while next_parent.parent is not None:
			if next_parent.child_indent == indent:
				return next_parent
			next_parent = next_parent.parent

		raise ReaderError('indent on line {} does not match any previous indentation'.format(self.current_line))

	#-==@method
	def prepare_node(self, rawline, indent):
		#-== @params
		#	rawline:	the raw line of text from the file
		#	indent:		the indentation string found on the line
		#
		#-== @return
		# a new /Node object with the /indent and
		# /data parsed from the /rawline
		#
		#-== This method calls the /parse_data() method
		# to assemble the data for the /Node object.

		line = rawline.lstrip(self.allowed_indents)
		if not self.preserve_newlines:
			line = line.strip('\n')
		prepared_data = self.parse_data(line, rawline)
		return self.node_class(indent=indent, data=prepared_data)


	#-==@method
	def parse_data(self, line, rawline):
		#-== @params
		#	line:	the stripped line
		#	rawline: the untouched raw text from the file
		#
		#-== @return
		# Any valid Python data to store
		# in the /data of a /Node object.
		#
		#-== This method should be overridden
		# in any subclasses to provide more specific
		# logic to parse and prepare data read from the file.
		# By default, this method just returns the /line value.

		return line

	#-==@method
	def output(self, exclude_root=False):
		#-== @params
		#	exclude_root:	do not include the /root node
		#					in the output string
		#-== @return
		# A prettified string of the
		# data structure stored within /root .

		if exclude_root:
			return self.root.output(-1, exclude_self=True)
		return self.root.output()



#-==@class
class ReaderError(Exception):
	#-== A generic exception class for /IndentReader .

	pass



#-==@class
class Node:
	#-== @attributes
	#	parent:		the parent /Node of this node
	#	indent:		the indentation string this node's
	#					/data was found at in the file
	#	level:      the indent level of the node (root level is 0)
	#	child_indent: the indentation string of this node's /children
	#	children:	a list of child /Node objects
	#	data:		the data stored in this node

	#-== The tree data structure in the /IndentReader is formed
	# by connecting /Node objects to each other as parents and children.
	# Each node has 1 parent and 0 or more children.
	#
	#-== This class can be used to derive subclasses in order to
	# customize the behavior of individual nodes of the /IndentReader.
	# Provide the subclass to the /node_class parameter
	# of the /IndentReader constructor.

	#-==@method
	def __init__(self, parent=None, indent='', data=''):
		#-== @params
		#	parent:	the parent /Node object
		#	indent:	the indentation string this node's
		#				/data was found at in the file
		#	data:	the data stored in this node

		self.parent = parent
		self.indent = indent
		self.level = parent.level+1 if parent else 0
		self.child_indent = None
		self.children = []
		self.data = data

	#-==@method
	def add_child(self, node):
		#-== @params
		#	node:	the /Node object to add as a child
		#
		#-== This method sets the /child_indent attribute,
		# adds the /node to this node's /children , and
		# assigns this node as the /parent or /node .

		node.parent = self
		node.level = self.level+1
		self.child_indent = node.indent
		self.children.append(node)

	#-==@method
	def output(self, level=0, exclude_self=False):
		#-== @params
		#	level:			indicates how far the
		#						prettified data should
		#						be indented
		#	exclude_self:	do not include the /data
		#					in the output string
		#-== @return
		# A prettified string of the /data
		# and /children of this node

		outstr = ''
		if not exclude_self:
			if level > 0:
				outstr += '  '*(level)
			outstr += str(self.data) + '\n'
		for child in self.children:
			outstr += child.output(level+1)
		return outstr

