Robert Brewer avatar Robert Brewer committed f63ee0e

First two stages of optional variables in flow dates/amounts: evaluation when forming obligations, and storing variables in the DB. Last stage will be a UI for vars that auto-updates dependent flows' obligations.

Comments (0)

Files changed (7)

flowrate/__init__.py

 db = None
 
 from flowrate import csvutil, flows
+from flowrate.variables import ReferenceFinder
 
 
 def set_db(connstring, **kwargs):
             "WHERE id = %s RETURNING *;",
             (amount, credit, debit,
              vals['postdate'], vals['description'],
-             -1 if credit_type in ('asset', 'expense') else 1,
-             1 if debit_type in ('asset', 'expense') else -1,
+             -1 if credit_type['type'] in ('asset', 'expense') else 1,
+             1 if debit_type['type'] in ('asset', 'expense') else -1,
              req.txid)).fetchone()
 
         flows.unfulfill(txrow.id)
             "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *",
             (amount, credit, debit,
              vals["postdate"], vals['description'],
-             -1 if credit_type in ('asset', 'expense') else 1,
-             1 if debit_type in ('asset', 'expense') else -1,
+             -1 if credit_type['type'] in ('asset', 'expense') else 1,
+             1 if debit_type['type'] in ('asset', 'expense') else -1,
              )).fetchone()
 
         flows.fulfill(newrow)
         return {'self': cherrypy.url(),
                 'description': self.description % vars(),
                 'body': {
-                    'amount': flow.amount,
                     'credit': cherrypy.url("/accounts/%s" % flow.credit_account),
                     'debit': cherrypy.url("/accounts/%s" % flow.debit_account),
-                    'start': flow.range_start.isoformat(),
-                    'end': flow.range_end.isoformat(),
+                    'amount': flow.amount,
+                    'start': flow.range_start,
+                    'end': flow.range_end,
                     'period': flow.period,
                     'unit': flow.unit,
                     'days': flow.days,
         # Accept the new flow definition.
         vals = req.json["body"]
         credit, debit = popint(vals['credit']), popint(vals['debit'])
+
+        refs = set()
+        finder = ReferenceFinder()
+        for key in ('start', 'end', 'amount'):
+            val = vals[key].strip()
+            if val.startswith("="):
+                newrefs = finder.find(val[1:])
+                refs.update(newrefs)
+
         flow.row = db.execute(
             "UPDATE flows SET amount = %s, "
             "credit_account = %s, debit_account = %s, "
             "range_start = %s, range_end = %s, "
             "period = %s, unit = %s, days = %s, "
-            "description = %s "
+            "description = %s, variables = %s "
             "WHERE id = %s RETURNING *;",
             (vals['amount'], credit, debit, vals['start'], vals['end'],
             vals['period'], vals['unit'], vals['days'], vals['description'],
-            flow.id)).fetchone()
+            list(refs), flow.id)).fetchone()
 
         flow.clear_obligations()
         flow.obligate()
         rows = db.execute(sql, args).fetchall()
         t['data'] = [
             {"id": cherrypy.url("/flows/%s" % row.id),
-             "start": row.range_start.isoformat(),
-             "end": row.range_end.isoformat(),
+             "start": row.range_start,
+             "end": row.range_end,
              "period": row.period,
              "unit": row.unit,
              "days": row.days,

flowrate/csvutil.py

 class Importer(object):
 
     def get_account(self, account_name):
-        a = flowrate.db.execute("SELECT * FROM accounts WHERE name = %s",
-                                (account_name,)).fetchone()
+        return flowrate.db.execute("SELECT * FROM accounts WHERE name = %s",
+                                   (account_name,)).fetchone()
 
     def add_tx(self, credit, debit, amount, postdate, description):
         exists = flowrate.db.execute(
             newrow = flowrate.db.execute("INSERT INTO transactions "
                 "(amount, credit_account, debit_account, postdate,"
                 " description, credit_mult, debit_mult) "
-                "VALUES (%s, %s, %s, %s, %s) RETURNING *",
+                "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *",
                 (amount, credit['id'], debit['id'], postdate, description,
                  -1 if credit['type'] in ('asset', 'expense') else 1,
                  1 if debit['type'] in ('asset', 'expense') else -1,

flowrate/flowrate.sql

     credit_account integer NOT NULL,
     debit_account integer NOT NULL,
     description text,
-    amount numeric(10,2) NOT NULL,
-    range_start date NOT NULL,
-    range_end date NOT NULL,
     period integer,
     unit text,
-    days integer[]
+    days integer[],
+    -- An array of the variable names this flow refers to in any formulas below.
+    variables text[] NOT NULL DEFAULT '{}',
+    -- Any of the following may be a formula if it starts with "=":
+    amount text NOT NULL,
+    range_start text NOT NULL,
+    range_end text NOT NULL
 );
 
 
 
 
 --
+-- Name: variables; Type: TABLE; Schema: public; Owner: postgres; Tablespace: 
+--
+
+CREATE TABLE variables (
+    name text NOT NULL,
+    expr text,
+    variables text[] NOT NULL DEFAULT '{}'
+);
+
+
+ALTER TABLE public.variables OWNER TO postgres;
+
+--
 -- Name: accounts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: 
 --
 
 
 
 --
+-- Name: variables_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres; Tablespace: 
+--
+
+ALTER TABLE ONLY variables
+    ADD CONSTRAINT variables_pkey PRIMARY KEY (name);
+
+
+--
 -- Name: fki_flows_credit_account; Type: INDEX; Schema: public; Owner: postgres; Tablespace: 
 --
 

flowrate/flows.py

 import calendar
 import datetime
+import decimal
 
 import flowrate
+from flowrate.variables import ReferenceFinder, environment as env
 
 
 class month(object):
     @classmethod
     def insert(cls, **vals):
         flow = cls(None)
+
+        refs = set()
+        finder = ReferenceFinder()
+        for key in ('start', 'end', 'amount'):
+            val = vals[key].strip()
+            if val.startswith("="):
+                newrefs = finder.find(val[1:])
+                refs.update(newrefs)
+
         flow.row = flowrate.db.execute("INSERT INTO flows"
                    " (amount, credit_account, debit_account, "
-                   "range_start, range_end, period, unit, days, description) "
-                   "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *",
+                   "range_start, range_end, period, unit, days, description, "
+                   "variables) "
+                   "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
+                   "RETURNING *",
                    (vals["amount"],
                     vals["credit"], vals["debit"],
                     vals["start"], vals["end"],
                     vals["period"], vals['unit'], vals["days"],
-                    vals["description"],
+                    vals["description"], list(refs),
                     )).fetchone()
         flow.id = flow.row.id
         return flow
 
     def delete(self):
-        db.execute("DELETE FROM flows WHERE id = %s;", (self.id,))
+        flowrate.db.execute("DELETE FROM flows WHERE id = %s;", (self.id,))
 
     def __getattr__(self, key):
         return getattr(self.row, key)
         elif self.unit == 'years':
             unit, day = lambda d: d.year, lambda d: d.timetuple().tm_yday
 
-        for d in range(0, (self.range_end - self.range_start).days + 1):
-            postdate = self.range_start + datetime.timedelta(days=d)
+        escrow = datetime.date(2014, 2, 1)
+
+        start = self.range_start.strip()
+        if start.startswith("="):
+            # It's a Python expression
+            start = eval(start[1:], env, locals())
+        else:
+            # Assume it's an ISO date YYYY-MM-DD
+            start = datetime.date(*map(int, start.split("-")))
+
+        end = self.range_end.strip()
+        if end.startswith("="):
+            # It's a Python expression
+            end = eval(end[1:], env, locals())
+        else:
+            # Assume it's an ISO date YYYY-MM-DD
+            end = datetime.date(*map(int, end.split("-")))
+
+        for d in range(0, (end - start).days + 1):
+            postdate = start + datetime.timedelta(days=d)
             if day(postdate) not in self.days:
                 continue
 
             p = unit(postdate)
             # This will be an integer number of units between post and start
-            diff = p - unit(self.range_start)
+            diff = p - unit(start)
             # But range_start might be after one or more of our self.day(s)
             for fd in sorted(self.days):
-                if day(self.range_start) > fd:
+                if day(start) > fd:
                     diff -= 1
 
             if diff % self.period == 0:
+                # Eval the amount inside this loop to allow it to refer
+                # to the postdate or the iteration variable "d"
+                amount = self.amount.strip()
+                if amount.startswith("="):
+                    # It's a Python expression
+                    amount = eval(amount[1:], env, locals())
+                else:
+                    # Assume it's a number
+                    amount = decimal.Decimal(amount)
+
                 # Yield one transaction for the year/month/week on the day.
                 yield {
                     'id': None,
                     'credit': self.credit_account,
                     'debit': self.debit_account,
                     'description': self.description,
-                    'amount': self.amount,
+                    'amount': amount,
                     }
 
     def obligate(self):
         else:
             dategroupformat = 'YYYY-MM'
 
-        credit_type = db.execute(
+        credit_type = flowrate.db.execute(
             "SELECT type FROM accounts WHERE id = %s",
             (self.credit_account,)).fetchone()
-        debit_type = db.execute(
+        debit_type = flowrate.db.execute(
             "SELECT type FROM accounts WHERE id = %s",
             (self.debit_account,)).fetchone()
 
         obs = {}
         for ob in self.obligations():
+            print ob
             # TODO: this is slow. Can we change it to an "INSERT INTO ... FROM"?
             row = flowrate.db.execute(
                 "INSERT INTO obligations "
                 "RETURNING id;",
                 (self.id, ob['postdate'], ob['credit'], ob['debit'],
                  ob['description'], ob['amount'], dategroupformat,
-                 -1 if credit_type in ('asset', 'expense') else 1,
-                 1 if debit_type in ('asset', 'expense') else -1,
+                 -1 if credit_type['type'] in ('asset', 'expense') else 1,
+                 1 if debit_type['type'] in ('asset', 'expense') else -1,
                  )).fetchone()
             obs[row.id] = ob
 
         (txid,)).fetchall()]
     for obid in obids:
         flowrate.db.execute(
-            "UPDATE obligations o SET o.remaining = o.amount - "
+            "UPDATE obligations ob SET ob.remaining = ob.amount - "
             "(SELECT COALESCE(SUM(f.amount), 0) FROM fulfillments f"
-            " WHERE f.obligationid = o.id) "
-            "WHERE o.id = %s;", (obid,))
+            " WHERE f.obligationid = ob.id) "
+            "WHERE ob.id = %s;", (obid,))
 
 
 def transactions(accounts=None, credits=None, debits=None,
-import simplejson
 import optparse
 import os
 thisdir = os.path.abspath(os.path.dirname(__file__))
 
 import cherrypy
 from cherrypy.process import plugins
+import simplejson
 
 import flowrate
 from flowrate import dbutil
+from flowrate import variables
 
 
 class Postgres(plugins.SimplePlugin):
     start.priority = 70
 
 
+class EnvLoader(plugins.SimplePlugin):
+
+    def __init__(self, bus):
+        self.bus = bus
+
+    def start(self):
+        self.bus.log("Loading Flowrate variables...")
+
+        allvars = dict(
+            (row['name'], [row['expr'], row['variables']])
+            for row in flowrate.db.execute("SELECT * FROM variables;"
+                                           ).fetchall())
+
+        # Evaluate in topological order.
+        # Start with all the ones which reference no others.
+        Q = [(name, expr)
+             for name, (expr, refs) in allvars.iteritems() if not refs]
+        while Q:
+            name, expr = Q.pop()
+
+            # Eval and store result.
+            variables.bind(name, expr)
+
+            # Mark all vars which reference "name".
+            for k, (e, r) in allvars.iteritems():
+                if name in r:
+                    r.remove(name)
+                    # Add if all references have been evaluated.
+                    if not r:
+                        Q.append((k, e))
+
+        # Error if there are any circular definitions.
+        remaining = [(name, expr, refs)
+                     for name, (expr, refs) in allvars.iteritems() if refs]
+        if remaining:
+            raise ValueError("Circular dependencies found.", remaining)
+
+        print "Environment:", variables.environment
+
+    start.priority = Postgres.start.priority + 5
+
+
 def run(config):
     cherrypy.engine.timeout_monitor.unsubscribe()
     Postgres(cherrypy.engine, config['db_info']).subscribe()
+    EnvLoader(cherrypy.engine).subscribe()
     FlowrateApp(cherrypy.engine, config).subscribe()
     cherrypy.engine.start()
     cherrypy.engine.block()

flowrate/ui/flows.html

     // amount
     d = document.createElement("td");
     d.className = 'flowamount';
-    set_text(d, '$' + commafy(flow.amount.toFixed(2)));
+    set_text(d, flow.amount);
     r.appendChild(d);
     
     // Insert the row in order by postdate descending
     flow.credit = $('edit_credit').value;
     flow.debit = $('edit_debit').value;
     flow.description = $('edit_description').value;
-    flow.amount = parseFloat($('edit_amount').value.replace(/[^0-9.]/g, ''));
+    flow.amount = $('edit_amount').value;
 
     if (flowid_being_edited == null) {
         // New flow
     h.send(JSON.stringify({"body": flow}));
 }
 
-function calc_edit_amount(e) {
-    try { if (e.value[0] == "=") e.value = eval(e.value.substring(1)); } catch(e) {}
-}
-
 //                                   OTHER                                   //
 
 budgets = [];
     <td><select id='edit_credit'></select></td>
     <td><select id='edit_debit'></select></td>
     <td><input id='edit_description' type='text' value="New flow" /></td>
-    <td><input id='edit_amount' type='text' size='10' style="text-align: right" value="0.00" 
-               onBlur='calc_edit_amount(this)' /></td>
+    <td><input id='edit_amount' type='text' size='10' style="text-align: right" value="0.00" /></td>
 </tr>
 <tr id='flowheader'>
     <th>ID&nbsp;<span onClick="edit_flow(null)" title="New flow" style="cursor: pointer">(+)</span></th>

flowrate/variables.py

+import ast
+import calendar
+import datetime
+import decimal
+
+
+class Visitor(object):
+    """Visitor for Python code."""
+
+    def __init__(self, strict=True):
+        self.strict = strict
+
+    def visit(self, node):
+        """Visit a node."""
+        try:
+            clsname = node.__class__.__name__
+            visitor = getattr(self, 'visit_' + clsname, None)
+            if visitor is None:
+                if self.strict:
+                    raise NotImplementedException(clsname)
+                else:
+                    return self.visit_all(node)
+            else:
+                return visitor(node)
+        except Exception, exc:
+            if hasattr(node, "lineno"):
+                if hasattr(exc, "lineno"):
+                    # The exception aleady had a lineno attached, probably in
+                    # a nested call, so don't re-set it here to an outer node.
+                    pass
+                else:
+                    exc.lineno = node.lineno
+            raise
+
+    def visit_all(self, node):
+        """Visit all child nodes of the given node."""
+        for field, value in ast.iter_fields(node):
+            if isinstance(value, list):
+                for item in value:
+                    if isinstance(item, ast.AST):
+                        self.visit(item)
+            elif isinstance(value, ast.AST):
+                self.visit(value)
+
+
+class ReferenceFinder(Visitor):
+    """Visitor for finding variable references in Python code.
+
+    Use the find(source) method of an instance of this class to find
+    the set of names which the given source references; that is,
+    if you update a variable "foo" you know you need to recalculate
+    any "source" where `'foo' in ReferenceFinder().find(source)`.
+    """
+
+    def __init__(self):
+        # We know we're only interested in Name. All the others can visit_all.
+        self.strict = False
+
+    def find(self, source, mode='exec'):
+        """Return the set of variable names referred to in the given source."""
+        self.variables = set()
+
+        if isinstance(source, ast.AST):
+            a = source
+        elif isinstance(source, basestring):
+            #start = time.time()
+            a = compile(source, '<string>', mode, ast.PyCF_ONLY_AST)
+            #print "Parsed block in %s seconds." % (time.time() - start)
+        else:
+            raise TypeError("source must be a string of code or an AST node.")
+
+        #print ast.dump(a)
+        #start = time.time()
+        try:
+            self.visit(a)
+        except Exception, exc:
+            exc.source = source
+            raise
+        #print "Checked block in %s seconds." % (time.time() - start)
+
+        return self.variables
+
+    def visit_Name(self, node):
+        self.variables.add(node.id)
+
+
+# --------------------------------- Builtins --------------------------------- #
+
+def days(n):
+    return datetime.timedelta(days=n)
+
+
+class months(object):
+
+    def __init__(self, n):
+        self.n = n
+
+    def __radd__(self, other):
+        if not isinstance(other, (datetime.datetime, datetime.date)):
+            raise TypeError("months may only be added to dates or datetimes")
+        m = other.month + self.n
+        y = other.year
+        while m > 12:
+            m -= 12
+            y += 1
+        return other.replace(year=y, month=m)
+
+    def __rsub__(self, other):
+        if not isinstance(other, (datetime.datetime, datetime.date)):
+            raise TypeError(
+                "months may only be subtracted from dates or datetimes")
+        m = other.month - self.n
+        y = other.year
+        while m <= 0:
+            m += 12
+            y -= 1
+        return other.replace(year=y, month=m)
+
+def eom(d):
+    return d.replace(day=calendar.monthrange(d.year, d.month)[1])
+
+environment = {
+    'datetime': datetime,
+    'decimal': decimal,
+    'days': days,
+    'months': months,
+    'eom': eom,
+    }
+
+def bind(name, source):
+    """Evaluate the given source and add its result to the environment."""
+    environment[name] = eval(source, {}, environment)
+
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.