1"""Text rendering routines for serving a lists of postings/entries.
2"""
3__copyright__ = "Copyright (C) 2014-2016  Martin Blais"
4__license__ = "GNU GPLv2"
5
6import csv
7import itertools
8import math
9import textwrap
10
11from beancount.core.number import ZERO
12from beancount.core import data
13from beancount.core import realization
14from beancount.core import convert
15
16
17# Name mappings for text rendering, no more than 5 characters to save space.
18TEXT_SHORT_NAME = {
19    data.Open: 'open',
20    data.Close: 'close',
21    data.Pad: 'pad',
22    data.Balance: 'bal',
23    data.Transaction: 'txn',
24    data.Note: 'note',
25    data.Event: 'event',
26    data.Query: 'query',
27    data.Price: 'price',
28    data.Document: 'doc',
29    }
30
31
32class AmountColumnSizer:
33    """A class that computes minimal sizes for columns of numbers and their currencies.
34    """
35
36    def __init__(self, prefix):
37        self.prefix = prefix
38        self.max_number = ZERO
39        self.max_currency_width = 0
40
41    def update(self, number, currency):
42        """Update the sizer with the given number and currency.
43
44        Args:
45          number: A Decimal instance.
46          currency: A string, the currency to render for it.
47        """
48        abs_number = abs(number)
49        if abs_number > self.max_number:
50            self.max_number = abs_number
51        currency_width = len(currency)
52        if currency_width > self.max_currency_width:
53            self.max_currency_width = currency_width
54
55    def get_number_width(self):
56        """Return the width of the integer part of the max number.
57
58        Returns:
59          An integer, the number of digits required to render the integral part.
60        """
61        return ((math.floor(math.log10(self.max_number)) + 1)
62                if self.max_number > 0
63                else 1)
64
65    def get_generic_format(self, precision):
66        """Return a generic format string for rendering as wide as required.
67        This can be used to render an empty string in-lieu of a number.
68
69        Args:
70          precision: An integer, the number of digits to render after the period.
71        Returns:
72          A new-style Python format string, with PREFIX_number and PREFIX_currency named
73          fields.
74        """
75        return '{{{prefix}:<{width}}}'.format(
76            prefix=self.prefix,
77            width=1 + self.get_number_width() + 1 + precision + 1 + self.max_currency_width)
78
79    def get_format(self, precision):
80        """Return a format string for the column of numbers.
81
82        Args:
83          precision: An integer, the number of digits to render after the period.
84        Returns:
85          A new-style Python format string, with PREFIX_number and PREFIX_currency named
86          fields.
87        """
88        return ('{{0:>{width:d}.{precision:d}f}} {{1:<{currency_width}}}').format(
89            width=1 + self.get_number_width() + 1 + precision,
90            precision=precision,
91            currency_width=self.max_currency_width)
92
93
94# Verbosity levels.
95COMPACT, NORMAL, VERBOSE = 1, 2, 3
96
97# Output formats.
98FORMAT_TEXT, FORMAT_CSV = object(), object()
99
100
101def text_entries_table(oss, postings,
102                       width, at_cost, render_balance, precision, verbosity,
103                       output_format):
104    """Render a table of postings or directives with an accumulated balance.
105
106    This function has three verbosity modes for rendering:
107    1. COMPACT: no separating line, no postings
108    2. NORMAL: a separating line between entries, no postings
109    3. VERBOSE: renders all the postings in addition to normal.
110
111    The output is written to the 'oss' file object. Nothing is returned.
112
113    Args:
114      oss: A file object to write the output to.
115      postings: A list of Posting or directive instances.
116      width: An integer, the width to render the table to.
117      at_cost: A boolean, if true, render the cost value, not the actual.
118      render_balance: A boolean, if true, renders a running balance column.
119      precision: An integer, the number of digits to render after the period.
120      verbosity: An integer, the verbosity level. See COMPACT, NORMAL, VERBOSE, etc.
121      output_format: A string, either 'text' or 'csv' for the chosen output format.
122        This routine's inner loop calculations are complex enough it gets reused by both
123        formats.
124    Raises:
125      ValueError: If the width is insufficient to render the description.
126    """
127    assert output_format in (FORMAT_TEXT, FORMAT_CSV)
128    if output_format is FORMAT_CSV:
129        csv_writer = csv.writer(oss)
130
131    # Render the changes and balances to lists of amounts and precompute sizes.
132    entry_data, change_sizer, balance_sizer = size_and_render_amounts(postings,
133                                                                      at_cost,
134                                                                      render_balance)
135
136    # Render an empty line and compute the width the description should be (the
137    # description is the only elastic field).
138    empty_format = '{{date:10}} {{dirtype:5}} {{description}}  {}'.format(
139        change_sizer.get_generic_format(precision))
140    if render_balance:
141        empty_format += '  {}'.format(balance_sizer.get_generic_format(precision))
142    empty_line = empty_format.format(date='', dirtype='', description='',
143                                     change='', balance='')
144    description_width = width - len(empty_line)
145    if description_width <= 0:
146        raise ValueError(
147            "Width not sufficient to render text report ({} chars)".format(width))
148
149    # Establish a format string for the final format of all lines.
150    # pylint: disable=duplicate-string-formatting-argument
151    line_format = '{{date:10}} {{dirtype:5}} {{description:{:d}.{:d}}}  {}'.format(
152        description_width, description_width,
153        change_sizer.get_generic_format(precision))
154    change_format = change_sizer.get_format(precision)
155    if render_balance:
156        line_format += '  {}'.format(balance_sizer.get_generic_format(precision))
157        balance_format = balance_sizer.get_format(precision)
158    line_format += '\n'
159
160    # Iterate over all the pre-computed data.
161    for (entry, leg_postings, change_amounts, balance_amounts) in entry_data:
162
163        # Render the date.
164        date = entry.date.isoformat()
165
166        # Get the directive type name.
167        dirtype = TEXT_SHORT_NAME[type(entry)]
168        if isinstance(entry, data.Transaction) and entry.flag:
169            dirtype = entry.flag
170
171        # Get the description string and split the description line in multiple
172        # lines.
173        description = get_entry_text_description(entry)
174        description_lines = textwrap.wrap(description, width=description_width)
175
176        # Ensure at least one line is rendered (for zip_longest).
177        if not description_lines:
178            description_lines.append('')
179
180        # Render all the amounts in the line.
181        for (description,
182             change_amount,
183             balance_amount) in itertools.zip_longest(description_lines,
184                                                      change_amounts,
185                                                      balance_amounts,
186                                                      fillvalue=''):
187
188            change = (change_format.format(change_amount.number,
189                                           change_amount.currency)
190                      if change_amount
191                      else '')
192
193            balance = (balance_format.format(balance_amount.number,
194                                             balance_amount.currency)
195                       if balance_amount
196                       else '')
197
198            if not description and verbosity >= VERBOSE and leg_postings:
199                description = '..'
200
201            if output_format is FORMAT_TEXT:
202                oss.write(line_format.format(date=date,
203                                             dirtype=dirtype,
204                                             description=description,
205                                             change=change,
206                                             balance=balance))
207            else:
208                change_number, change_currency = '', ''
209                if change:
210                    change_number, change_currency = change.split()
211
212                if render_balance:
213                    balance_number, balance_currency = '', ''
214                    if balance:
215                        balance_number, balance_currency = balance.split()
216
217                    row = (date, dirtype, description,
218                           change_number, change_currency,
219                           balance_number, balance_currency)
220                else:
221                    row = (date, dirtype, description,
222                           change_number, change_currency)
223                csv_writer.writerow(row)
224
225            # Reset the date, directive type and description. Only the first
226            # line renders these; the other lines render only the amounts.
227            if date:
228                date = dirtype = ''
229
230        if verbosity >= VERBOSE:
231            for posting in leg_postings:
232                posting_str = render_posting(posting, change_format)
233                if len(posting_str) > description_width:
234                    posting_str = posting_str[:description_width-3] + '...'
235
236                if output_format is FORMAT_TEXT:
237                    oss.write(line_format.format(date='',
238                                                 dirtype='',
239                                                 description=posting_str,
240                                                 change='',
241                                                 balance=''))
242                else:
243                    row = ('', '', posting_str)
244                    csv_writer.writerow(row)
245
246        if verbosity >= NORMAL:
247            oss.write('\n')
248
249
250def render_posting(posting, number_format):
251    """Render a posting compactly, for text report rendering.
252
253    Args:
254      posting: An instance of Posting.
255    Returns:
256      A string, the rendered posting.
257    """
258    # Note: there's probably no need to redo the work of rendering here... see
259    # if you can't just simply replace this by Position.to_string().
260
261    units = posting.units
262    strings = [
263        posting.flag if posting.flag else ' ',
264        '{:32}'.format(posting.account),
265        number_format.format(units.number, units.currency)
266        ]
267
268    cost = posting.cost
269    if cost:
270        strings.append('{{{}}}'.format(number_format.format(cost.number,
271                                                            cost.currency).strip()))
272
273    price = posting.price
274    if price:
275        strings.append('@ {}'.format(number_format.format(price.number,
276                                                          price.currency).strip()))
277
278    return ' '.join(strings)
279
280
281def size_and_render_amounts(postings, at_cost, render_balance):
282    """Iterate through postings and compute sizers and render amounts.
283
284    Args:
285      postings: A list of Posting or directive instances.
286      at_cost: A boolean, if true, render the cost value, not the actual.
287      render_balance: A boolean, if true, renders a running balance column.
288    """
289
290    # Compute the maximum width required to render the change and balance
291    # columns. In order to carry this out, we will pre-compute all the data to
292    # render this and save it for later.
293    change_sizer = AmountColumnSizer('change')
294    balance_sizer = AmountColumnSizer('balance')
295
296    entry_data = []
297    for entry_line in realization.iterate_with_balance(postings):
298        entry, leg_postings, change, balance = entry_line
299
300        # Convert to cost if necessary. (Note that this agglutinates currencies,
301        # so we'd rather do make the conversion at this level (inventory) than
302        # convert the positions or amounts later.)
303        if at_cost:
304            change = change.reduce(convert.get_cost)
305            if render_balance:
306                balance = balance.reduce(convert.get_cost)
307
308        # Compute the amounts and maximum widths for the change column.
309        change_amounts = []
310        for position in change.get_positions():
311            units = position.units
312            change_amounts.append(units)
313            change_sizer.update(units.number, units.currency)
314
315        # Compute the amounts and maximum widths for the balance column.
316        balance_amounts = []
317        if render_balance:
318            for position in balance.get_positions():
319                units = position.units
320                balance_amounts.append(units)
321                balance_sizer.update(units.number, units.currency)
322
323        entry_data.append((entry, leg_postings, change_amounts, balance_amounts))
324
325    return (entry_data, change_sizer, balance_sizer)
326
327
328def get_entry_text_description(entry):
329    """Return the text of a description.
330
331    Args:
332      entry: A directive, of any type.
333    Returns:
334      A string to use for the filling the description field in text reports.
335    """
336    if isinstance(entry, data.Transaction):
337        description = ' | '.join([field
338                                  for field in [entry.payee, entry.narration]
339                                  if field is not None])
340    elif isinstance(entry, data.Balance):
341        if entry.diff_amount is None:
342            description = 'PASS - In {}'.format(entry.account)
343        else:
344            description = ('FAIL - In {}; '
345                           'expected = {}, difference = {}').format(
346                               entry.account,
347                               entry.amount,
348                               entry.diff_amount)
349    elif isinstance(entry, (data.Open, data.Close)):
350        description = entry.account
351    elif isinstance(entry, data.Note):
352        description = entry.comment
353    elif isinstance(entry, data.Document):
354        description = entry.filename
355    else:
356        description = '-'
357    return description
358