#!/usr/bin/env python
"""
A reporting system, a bit like a crippled awk with some tricks.

This program is supposed to make your smalltime accounting a bit easier.
Basically, you define constants (e.g., receipts), formulae (python expressions
plus simple macros giving new values), and reports, which are text-based
templates.  Additionally, you can give checks.

The input files lean towards .ini-style syntax.  There are sections
headed by [xy]-lines, empty lines and lines starting with # are ignored.

Let's go through the sections:

[Constants]

Each line must associate a python identifier with a floating point value,
like this:
foo_x=4.9

You may not redefine constant to ward against typos.  However, you can
write += instead of = to add to an existing constant.  

As an additional hack, there are macros.  Well, currently, there's one, vat.
To use it, define a constant vatRate first thing.  After that, you can specify
constants like 5.40@vat, and the constant will be 5.40*(1+vatRate).  You
could define additional macros in the Constants class.

[formulae]

Again, lines have the form <name>=<something>, where something here is
a python expression.  You can use constants and the result of previous
formulae in the expressions.  

The one redeeming feature are "macros".  Two of them are defined: @sum, 
summing results of previous formulae, and @csum, summing constants.  Both
take regular expression arguments and sum the values of all matching
names.  You could define addtional macros in the Formulae class.

[checks]

These are harmless: Each line has the form <identifier>==<identifier>
and asserts the equality of two values from the formulae section.  This
is intended to make sure that, e.g., typos didn't mess up the matching
of regexps in csum arguments.

[report-<whatever>]

Sections named report-<whatever> are templates.  They are evaluated
with the usual python percent operator, where the second operand is
a dictionary containing all formula results.  Thus, you could say
%(foo).2f to format the result of the formula foo.

Here's a quick example of how I produce financial reports for Heidelberg's
bicycle self-help repair workshop:

[constants]
vatRate=0.16 # No longer...

# receipts and bills, with dates prepended so I can sort them,
# and categorized so I can conveniently match them
_060213_baumaterial_wegertseder=18@vat
_060313_werkzeug_altavelo=50.00
_061018_sonstiges_mueller=5.62
_061024_kleinteile_eldorado=4.00
_061109_werkzeug_eldorado=13.00

# more constants, e.g. donation, account figures, etc.
kasseAnfang=391.00
kasseEnde=886.50
spenden=1682.44

[fomulae]
# use matches to figure out what money was spent on what
kleinteile=@csum(.*_kleinteile_.*)
sonstiges=@csum(.*_sonstiges_.*)
knownExpenses=kleinteile+sonstiges
totalExpenses=@csum(_[0-9].*)

[checks]
# see if all expenses fall in one of the given categories
knownExpenses==totalExpenses

[report-jahr06]
# a TeX alignment for the report
\halign to \hsize{
\tabskip=10pt plus 1fil#\hfill&\hfill#\tabskip=0pt\cr
\bf Ausgaben&\omit\cr
\noalign{\vskip4pt\hrule\vskip4pt}
Werkzeug&            %(werkzeug).2f\cr
Baumaterial&        %(baumaterial).2f\cr
...
}

[report-dach]
# just dump a value
%(dach).2f

On the command line, you just give the name(s) of the report(s) you
want dumped.
"""

import re
import sys
import operator
from itertools import *

nameRe = r"[a-zA-Z_][\w]+"

class Error(Exception):
	pass

class Constants:
	def __init__(self, computer):
		self.computer = computer
		self.constantsDict = {}
	
	def parse(self, rawConstants):
		for constDef in [_f for _f in rawConstants.split("\n") if _f]:
			mat = re.match(r"\s*(?P<name>%s)\s*(?P<operator>\+?=)"
				r"\s*(?P<value>[-+\d.e]+)\s*"
				r"(?P<macro>@\w+)?$"%nameRe, constDef)
			if not mat:
				raise Error("Malformed constant definition: %s"%repr(constDef))
			opDict = mat.groupdict()
			name, op, value = (opDict["name"], opDict["operator"],
				float(opDict["value"]))
			if opDict["macro"]:
				value = getattr(self, "_macro_"+opDict["macro"][1:])(value)
			if op=="+=":
				self.constantsDict[name] += value
			else:
				if name in self.constantsDict:
					raise Error("Redefinition of %s"%name)
				self.constantsDict[name] = value

	def _macro_vat(self, val):
		return (1+self.computer["vatRate"])*val

	def getValFor(self, key):
		return self.constantsDict[key]

	def getMatchingNames(self, pat):
		return list(filter(pat.match, self.constantsDict))


class Formulae:
	def __init__(self, computer):
		self.computer = computer
		self.formDict = {}

	def _macro_sum(self, arg):
		return "+".join(self.computer.getMatchingNames(arg))

	def _macro_csum(self, arg):
		return "+".join(self.computer.getMatchingConstantNames(arg))

	def _expandMacro(self, matchObj):
		macName, macArg = matchObj.groups()
		return getattr(self, "_macro_%s"%macName)(macArg)

	def _expandMacros(self, formula):
		return re.sub(r"@([a-zA-Z_][\w]+)\(([^)]*)\)", 
			self._expandMacro, formula)

	def _evalNames(self, formula):
		return re.sub(nameRe,
			lambda matob:"%f"%self.computer.compute(matob.group()),
			formula)

	def parse(self, rawFormulae):
		for formDef in [_f for _f in rawFormulae.split("\n") if _f]:
			mat = re.match(r"\s*([^\s=]+)\s*=(.*)$", formDef)
			if not mat:
				raise Error("Malformed formula definition: %s"%repr(formDef))
			self.formDict[mat.group(1)] = mat.group(2)

	def compute(self, key):
		expr = self._evalNames(
			self._expandMacros(self.formDict[key]))
		if not expr.strip():
			print("XXXX Empty expression for key %s"%key)
			return 0
		return eval(expr)

	def getMatchingNames(self, pat):
		return list(filter(pat.match, self.formDict))


class Checks:
	checkPat = re.compile(r"(?P<left>[^=]*)=="
		"(?P<right>[^,]*)(?P<figs>,\d+)?$")

	def __init__(self, computer):
		self.checks = []
		self.computer = computer
	
	def parse(self, rawChecks):
		for rawCheck in [_f for _f in rawChecks.split("\n") if _f]:
			mat = self.checkPat.match(rawCheck)
			if not mat:
				raise Error("Bad check syntax: %s"%repr(rawCheck))

			sigFigs = 10
			if mat.group("figs"):
				sigFigs = int(mat.group("figs")[1:])

			leftName = mat.group("left").strip()
			rightName = mat.group("right").strip()
			self.checks.append(("==", leftName, rightName, sigFigs))
	
	def runChecks(self):
		for relation, leftName, rightName, sigFigs in self.checks:
			leftVal, rightVal = self.computer[leftName], self.computer[rightName]
			if relation=="==":
				if not round(leftVal, sigFigs)==round(rightVal, sigFigs):
					raise AssertionError("Condition %s%s%s failed (%f, %f)"%(
						leftName, relation, rightName, 
						round(leftVal, sigFigs), round(rightVal, sigFigs)))
			else:
				raise Error("Invalid relation in check: %s"%repr(relation))


class Reports:
	def __init__(self, computer):
		self.computer = computer
		self.reportDict = {}
	
	def addReport(self, repName, template):
		self.reportDict[repName] = template

	def makeReport(self, repName):
		return self.reportDict[repName]%self.computer


class Computer(dict):
	def __init__(self, rawSpecs):
		dict.__init__(self)
		self.constants = Constants(self)
		self.formulae = Formulae(self)
		self.reports = Reports(self)
		self.checks = Checks(self)
		self.valCache = {}
		self._parse(rawSpecs)
		self.checks.runChecks()

	def __getitem__(self, key):
		return self.compute(key)

	def _parse(self, rawSpecs):
		def cleanup(s):
			return re.sub("\\\\n", "",
				re.sub("\n\\s*\n", "\n",
				re.sub("#.*", "", s)))
		sections = re.split(r"(?m)^\[([^]]+)\]",
			rawSpecs)[1:]
		for secName, secCont in zip(islice(sections, 0, None, 2),
				islice(sections, 1, None, 2)):
			if secName=="constants":
				self.constants.parse(cleanup(secCont))
			elif secName=="formulae":
				self.formulae.parse(cleanup(secCont))
			elif secName.startswith("report"):
				self.reports.addReport(secName[7:], secCont)
			elif secName=="checks":
				self.checks.parse(cleanup(secCont))
			else:
				raise Error("Invalid Section: %s\n"%secName)

	def getMatchingNames(self, rawPattern):
		return self.getMatchingConstantNames(rawPattern
			)+self.getMatchingFormulaNames(rawPattern)
	
	def getMatchingConstantNames(self, rawPattern):
		return self.constants.getMatchingNames(re.compile(rawPattern))

	def getMatchingFormulaNames(self, rawPattern):
		return self.formulae.getMatchingNames(re.compile(rawPattern))

	def compute(self, key):
		if key not in self:
			try:
				self[key] = self.constants.getValFor(key)
			except KeyError:
				self[key] = self.formulae.compute(key)
		return self.get(key)

	def makeReport(self, reportName):
		return self.reports.makeReport(reportName)

	
def parseCommandLine():
	return sys.argv[1], sys.argv[2:]


def main(inputFName, requestedReports):
	with open(inputFName, encoding="utf-8") as f:
		computer = Computer(f.read())
	for reportName in requestedReports:
		sys.stdout.buffer.write(
			computer.makeReport(reportName).encode("latin-1"))


if __name__=="__main__":
	inputFName, requestedReports = parseCommandLine()
	main(inputFName, requestedReports)
