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