#!/usr/bin/env python
r"""
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.

You're dealing with floats here, so values will rarely match up exactly.
To cope for that, add the number of significant digits (10 by default)
after a comma::

	alles==verteilt, 2

[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>[^=]*)=="
		r"(?P<right>[^,]*)(?P<figs>,\s*\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("utf-8"))


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