Source code for jobcalc.formatters
# -*- coding: utf-8 -*-
from typing import Any # , Any
import logging
from collections import namedtuple
import contextlib
import decimal
import terminaltables
import colorclass
from .utils import colorize
from .param_types import Currency, Percentage
# logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
ColorContext = namedtuple('ColorContext', ('subtotal', 'margin', 'discount',
'deduction', 'total'))
"""A named tuple used to hold colors for items when rendered.
:param subtotal: Color for subtotal's
:param margin: Color for margin's
:param discount: Color for discount's
:param deduction: Color for deduction's
:param total: Color for total's
"""
TotaledContext = namedtuple('TotaledContext', ColorContext._fields)
"""Holds all the values to be rendered by a formatter.
:param subtotal: The subtotal of all the costs, hours, etc. for a calculation.
:param discount: The sum of all the percentage discounts for a calculation.
:param deduction: The sum of all the monetary deductions for a calculation.
:param total: The total of the calculation.
"""
DEFAULT_COLORS = ColorContext(
subtotal='magenta',
margin='blue',
discount='yellow',
deduction='red',
total='green'
)
"""Default colors to use as the ``ColorContext``."""
DEFAULT_FORMULA_STRING = """
color key: {header}
{subtotal_string}
(
(({subtotal} / (1 - {margin}) * (1 - {discount})) - {deduction}) = {total}
)
"""
"""A basic formula string to be formatted and rendered, to show the formula for
a calculation.
"""
[docs]class BaseFormatter(object):
"""All formatter's should sub-class this object, and override the
:py:meth:`render` method.
"""
@staticmethod
[docs] def colorize(item: Any, color: str) -> colorclass.Color:
"""If an item is a :py:class:`Currency` or :py:class:`Percentage`, then
call it's ``formatted_string`` method, before colorizing the
value.
:param item: A string, Currency, or Percentage to colorize.
:param color: The color to use on the item.
"""
if isinstance(item, (Currency, Percentage)):
item = item.formatted_string()
return colorize(item, color)
@staticmethod
[docs] def render(calculator) -> str:
"""The method all sub-classes should override to render a calculator.
:raises NotImplementedError: If a sub-class does not implement this
method.
"""
raise NotImplementedError()
@staticmethod
@contextlib.contextmanager
[docs] def totaled_ctx(calculator: Any) -> TotaledContext:
"""A context manager that yields the ``TotaledContext`` for a
calculator.
"""
with calculator.ctx() as ctx:
yield TotaledContext(*list(ctx) + [calculator.total()])
[docs]class BasicFormatter(BaseFormatter):
"""A basic formatter that renders the total as a formatted string.
"""
@staticmethod
[docs] def render(calculator: Any) -> str:
"""Return the total as formatted currency string."""
try:
return calculator.total().formatted_string()
except AttributeError as exc:
logger.debug('failed render for calculator: {}, exc: {}'.format(
calculator, exc)
)
raise TypeError("'{}' should inherit from BaseCalculator".format(
calculator)
)
[docs]class TerminalFormatter(terminaltables.AsciiTable, BaseFormatter):
"""A ``terminaltables.AsciiTable``, that supports colors and
a title.
:param colors: A 5 tuple or :py:class:`ColorContext` of strings that can
be used by to convert an item to a ``colorclass.Color``.
Defaults to (subtotal='magenta', margin='blue',
discount='yellow', deduction='red', total='green').
:param title: A title for the table. Defaults to ``'DETAILED'``. If you
do not want a title, then this can be set to ``None``
:param no_colors: If ``True``, turns off colored output for the table.
Default is ``False``.
:param color_header: If ``True`` then colorize the header as well.
Default is ``False``.
"""
def __init__(self, *colors, title: str='DETAILED', no_colors: bool=False,
color_header: bool=False):
super().__init__([], title=title)
if colors and no_colors is False:
self.colors = ColorContext(*colors)
elif no_colors is False:
self.colors = DEFAULT_COLORS
self.no_colors = no_colors
self.color_header = color_header
[docs] def render(self, calculator: Any) -> str:
"""Set's up the table, and returns it as a string, to be rendered.
:param calculator: The calculator to create a table for. Should
be a :py:class:`BaseCalculator` or sub-class.
"""
with self.totaled_ctx(calculator) as ctx:
headers = TotaledContext(*map(lambda x: x.upper(),
TotaledContext._fields))
if self.color_header is True:
headers = list(map(lambda x: self.colorize(*x),
zip(headers, self.colors)))
body = list(ctx)
if self.no_colors is False:
body = list(map(lambda items: self.colorize(*items),
zip(body, self.colors)))
logger.debug('body: {}'.format(body))
self.table_data = [headers, body]
return self.table
# TODO: Create a ``ColorContextError`` to raise instead of ``TypeError``
# if colors is the wrong size.
[docs]class FormulaFormatter(BaseFormatter):
"""Prints the formula used for the calculations.
:param colors: A 5 tuple or ``ColorContext`` of strings that can
be used by to convert an item to a ``colorclass.Color``.
Defaults to (subtotal='magenta', margin='blue',
discount='yellow', deduction='red', total='green').
:param formula_string: A string to use for the format. This should be a
a string that we call ``format``, that accepts
kwargs (header, subtotal, margin, discount,
deduction, and total). Defaults to
``DEFAULT_FORMULA_STRING``.
:param no_color: Turns colored output off.
:param title: Title to display before the output.
:raises TypeError: If colors is not a 5 tuple.
"""
def __init__(self, *colors, formula_string: str=None, no_color: bool=False,
title: str='FORMULA') -> None:
self.title = title
if colors and no_color is False:
# convert the colors to a ``ColorContext``
# this can raise ``TypeError`` if not enough or too many values.
self.colors = ColorContext(*colors)
elif no_color is False:
# set the colors to the default
self.colors = DEFAULT_COLORS
self.no_color = no_color
# set the formula string to use for this instance.
if formula_string:
self.formula_string = formula_string
else:
self.formula_string = DEFAULT_FORMULA_STRING
[docs] def render(self, calculator: Any) -> str:
"""Render a formula string used for a ``BaseCalculator`` instance or
sub-class.
:param calculator: A ``BaseCalculator`` or sub-class to use as the
context for the output.
"""
with self.totaled_ctx(calculator) as ctx:
# find the hours and rate to build the subtotal
# formula
hours = calculator._hours()
rate = decimal.Decimal(str(calculator.rate))
# format a subtotal formula string.
subtotal_string = \
'subtotal = ({costs} + ({hours} * {rate}))'.format(
costs=ctx.subtotal - (hours * rate),
hours=hours,
rate=rate)
# colorize if applicable.
if self.no_color is False:
ctx = TotaledContext(*map(lambda x: self.colorize(*x),
zip(ctx, self.colors)))
header = ' '.join(map(lambda x: self.colorize(*x),
zip(ctx._fields, self.colors)))
else:
ctx = TotaledContext(*map(lambda x: x.formatted_string(), ctx))
header = ' '.join(ctx._fields)
# get the values from the context, either colored or not,
# and add our headers.
values = ctx._asdict()
values['header'] = header
values['subtotal_string'] = subtotal_string
# format the string (``self.formual_string``)
formatted = self.formula_string.format(**values)
# build and return the final output string.
return '\n'.join((self.title, '-' * len(self.title),
formatted))