Source code for jobcalc.core

# -*- coding: utf-8 -*-

from typing import Union, Iterable, Any, Dict
import logging
# import inspect
import decimal
import collections
import contextlib
import functools

import click

from .utils import flatten, parse_input_string, colorize  # , _return_input
from .param_types import Currency, Percentage, COSTS, MARGIN, DISCOUNT, \
    DEDUCTION
from .exceptions import InvalidFormatter, HourlyRateError
from .formatters import BaseFormatter, BasicFormatter
from .config import Config, TerminalConfig

# logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

# Commonly used Type Hints
CurrencyList = Iterable[Union[Currency, Iterable[Union[Currency, str]], str]]
PercentageList = Iterable[Union[Percentage, str,
                                Iterable[Union[Percentage, str]]]]
FormatterList = Iterable[Union[BaseFormatter, Iterable[BaseFormatter]]]
HoursList = Iterable[Union[str, int, Iterable[Union[str, int]]]]


Context = collections.namedtuple('Context', ('subtotal', 'margin',
                                             'discount', 'deduction'))
"""A namedtuple that represents the args for the :py:meth:`calculate` function.

If these values are set properly then you can call ``calculate(*context)``.
and the values will be unpacked in the correct order.

:param subtotal:  The subtotal for the calculation.
:param margin:  The margin for the calculation
:param discount:  The percentage discount for the calculation.
:param deduction:  The monetary deduction for the calculation.

Example::

    >>> ctx = Context(subtotal='123', margin='50', discount='10',
    ...               deduction='0')
    >>> calculate(*ctx).formatted_string()
    '$221.40'

"""

PromptResponse = collections.namedtuple(
    'PromptRespone', ('value', 'multiple_heading_displayed',
                      'single_heading_displayed')
)
"""A namedtuple that represents the response to prompting for user input.

:param value: The parsed value from the user input.
:param multiple_heading_displayed: A boolean that indicates if we displayed
                                    the multiple value heading during a prompt.
:param single_heading_displayed: A boolean that indicates if we displayed
                                  the single value heading during a prompt.

"""


ColorKey = collections.namedtuple('ColorKey', ('margins', 'discounts', 'hours',
                                               'rate', 'deductions', 'costs'))
"""A namedtuple that can be used to declare colors to be used when prompting
for user input.

.. note::

    See ``colorclass`` for valid colors.

:param margins: Color when prompting for margin.
:param discounts:  Color when prompting for discounts.
:param hours:  Color when prompting for hours.
:param rate:  Color when prompting for rate.
:param deductions:  Color when prompting for deductions.
:param costs:  Color when prompting for costs.

"""

DEFAULT_COLOR_KEY = ColorKey(
    margins='blue',
    discounts='yellow',
    hours='magenta',
    rate='cyan',
    deductions='red',
    costs='green'
)
"""The default colors to fallback to if none are declared for a calculator."""


[docs]def calculate(subtotal: Union[Currency, str]='0', margin: Union[Percentage, str]='0', multiplier: Union[Percentage, str]='0', deduction: Union[Currency, str]='0' ) -> Currency: """Calculates a total based on the parameters. Returned as a ``Currency``. :param subtotal: An item that can be converted to a ``Currency`` to be used for the calculation. This would be the sum of all the job costs, materials, hours, etc. :param margin: An item that can be converted to a ``Perentage`` to be used as the profit margin for the calculation. Default is 0. :param multiplier: An item that can be converted to a ``Percentage`` to be used as a percentage discount in the calculation. This discount comes off after the profit margin has been calculated. Default is 0. :param deduction: An item that can be converted to a ``Currency`` to be used as a monetary discount in the calculation. This comes off after the profit margin has be calculated and any other percentage discounts have been taken off. """ return Currency( (Currency(subtotal) / (1 - Percentage(margin))) * (1 - Percentage(multiplier)) - Currency(deduction) )
[docs]class BaseCalculator(object): """The ``BaseCalculator`` class know's how to take ``costs``, a ``margin``, a ``discount`` (percentage discount), and ``deductions`` and calculate a total with those items. All items can be either single or iterables of items, but all get stored as a list. And the sum of that list of items makes up the total for a given item. :param costs: Either a single item or list of items that can be converted to a :py:class:`Currency`, used as the subtotal for a calculation. :param margins: An item or list of items that can be converted to a :py:class:`Percentage`, used as the profit margin for the calculation. :param discounts: An item or list of items that can be converted to a :py:class:`Percentage`, used as a percentage discount for the calculation. :param deductions: An item or list of items that can be converted to a :py:class:`Currency`, used as monetary deduction for the calculation. :param ignore_margins: A bool determining whether to ignore margins if any of the items in ``costs`` are other ``Calculator`` instances. Defaults to ``False``. """ def __init__(self, costs: CurrencyList=[], margins: PercentageList=[], discounts: PercentageList=[], deductions: CurrencyList=[], ignore_margins: bool=None ) -> None: self.ignore_margins = bool(ignore_margins) if ignore_margins is not \ None else False self.costs = [] # type: CurrencyList if costs: self.costs.append(costs) self.deductions = [] # type: CurrencyList if deductions: self.deductions.append(deductions) self.margins = [] # type: PercentageList if margins: self.margins.append(margins) self.discounts = [] # type: PercentageList if discounts: self.discounts.append(discounts) @contextlib.contextmanager
[docs] def ctx(self, ignore_margins: bool=None) -> Context: """A context manager that yields a ``Context`` that is properly set up to be used in a calculation. :param ignore_margins: A bool to determine whether to ignore margins in the ``subtotal`` if our ``costs`` include other ``Calculator`` instances. This will fallback to ``self.ignore_margins`` if not supplied. """ ignore_margins = bool(ignore_margins) if ignore_margins is not None \ else self.ignore_margins yield Context( subtotal=self.subtotal(ignore_margins=ignore_margins), margin=Percentage(sum(map(Percentage, flatten(self.margins)))), discount=Percentage(sum(map(Percentage, flatten(self.discounts)))), deduction=Currency(sum(map(Currency, flatten(self.deductions)))) )
[docs] def subtotal(self, ignore_margins: bool=None) -> Currency: """Calculate the subtotal of the ``costs``. This is used because ``costs`` can also consist of other calculators, so we call either :py:meth:`total` or :py:meth:`subtotal` accordingly on those items. :param ignore_margins: A boolean, if ``True``, then we call subtotal on child calculators, if it's ``False`` then we call total. We fallback to ``self.ignore_margins`` if this is not passed in. """ ignore_margins = bool(ignore_margins) if ignore_margins is not None \ else self.ignore_margins totals = [] for cost in flatten(self.costs): # call either ``subtotal`` or ``total`` appropriately, depending # on ``ignore_margins`` or ``self.ignore_margins`` setting. if isinstance(cost, BaseCalculator): if ignore_margins is True: # add the subtotal totals.append(cost.subtotal(True)) else: # add the total, using the margin of the child calculator. totals.append(cost.total()) else: # just append the value totals.append(cost) # return value of all the totals return Currency(sum(map(Currency, totals)))
[docs] def total(self) -> Currency: """Calculates the total for the current settings of the instance. This method will convert all the items in to their appropriate type, which can cause errors if the items can not be converted properly. The most common error will be ``decimal.InvalidOperation``. """ # convert all costs and deductions to Currency items, and let # errors propagate up. with self.ctx() as ctx: return self.calculate(*ctx)
@staticmethod
[docs] def calculate(*args, **kwargs) -> Currency: """Just attaches the :py:func:`calculate` function as a staticmethod. This is the method called in :py:meth:`total`, so if a sub-class would like to implement a custom calculation, they can override this method. """ return calculate(*args, **kwargs)
[docs]class Calculator(BaseCalculator): """Extends :py:class:`BaseCalculator`. Adds the ability to attach formatters, to ``render`` a formatted output. Adds ``hours`` and a ``rate`` option. The ``hours`` will be summed and multiplied by the ``rate`` and added to the ``subtotal`` of the job. Also adds the ability to pass in a :py:class:`Config` instance for common configuration of a ``Calculator``. :param formatters: A single or iterable of :py:class:`BaseFormatter`'s to format the output. :param hours: A single or iterable of items that can be converted to a ``decimal.Decimal``. :param rate: An single item that can be converted to a ``decimal.Decimal`` that represents an hourly rate. :param config: A :py:class:`Config` instance to use for values, either set or loaded from the environment. """ def __init__(self, *, formatters: FormatterList=[], hours: HoursList=[], rate: Union[str, int]=None, config: Config=None, **kwargs) -> None: super().__init__(**kwargs) self.config = config if config is not None else Config() self.formatters = [] # type: FormatterList if formatters: self.formatters.append(formatters) self.hours = [] # type: HoursList if hours: self.hours.append(hours) # check in the config for default hours to add. if self.config.default_hours != '0': self.hours.append(self.config.default_hours) self._rate = '0' self.rate = rate if rate is not None else self.config.rate
[docs] def subtotal(self, **kwargs) -> Currency: """Add's sum of ``costs`` + (``rate`` * ``hours``) for the subtotal. :param kwargs: Get passed to ``super``'s subtotal method. """ return Currency( super().subtotal(**kwargs) + (self.rate * self._hours()) )
@property def rate(self) -> decimal.Decimal: """Used as the hourly rate for a calculator. Defaults to '0'. This will not accept anything that is not greater or equal to 0 or anything that can not be converted to a ``decimal.Decimal``. """ return decimal.Decimal(self._rate) @rate.setter def rate(self, value: Union[str, int, decimal.Decimal, None]) -> None: try: rate = decimal.Decimal(value) if rate >= 0: self._rate = rate else: logger.debug( 'rate is not greater than 0, not changing: {}'.format(rate) ) except (decimal.InvalidOperation, TypeError) as exc: logger.debug('Invalid rate, not changing: {}'.format(exc))
[docs] def render(self, seperator: str='\n\n') -> str: """Return a string output of all the formatters for an instance. Joined by the seperator. If no formatters have been set, then we fall back to :py:class`BasicFormatter`, which will just output the :py:meth:`total` as a formatted currency string. :param seperator: A string to use as the seperator. Defaults to a double new-line. """ formatters = list(flatten(self.formatters)) # encase no formatters have been set, just return the # total if len(formatters) == 0: formatters.append(BasicFormatter) try: # join all the formatters, seperated by ``seperator`` return str(seperator).join( map(lambda formatter: formatter.render(self), formatters) ) except AttributeError as exc: # if render failed on an item in formatters, then we had # an invalid formatter. logger.debug('invalid formatter: {}, exc: {}'.format( self.formatters, exc) ) raise InvalidFormatter(self.formatters)
def _hours(self) -> decimal.Decimal: """Helper to return the sum of the hours.""" return decimal.Decimal(sum(map(decimal.Decimal, flatten(self.hours)))) def _costs(self) -> decimal.Decimal: """Helper to return the sum of the costs, not including hours and rate. """ return decimal.Decimal(sum(map(decimal.Decimal, flatten(self.costs)))) @contextlib.contextmanager
[docs] def ctx(self, strict: bool=False) -> Context: """Return a properly configured :py:class:`Context` to be used. .. note:: This can also raise errors if ``hours`` or ``rate`` can not be converted to a ``decimal.Decimal``. Most common error will be a ``decimal.InvalidOperation`` error. :param strict: If ``True`` an error will be raised if ``hours`` are set on an instance, but no :py:attr:`rate` has been set. Default is ``False`` :raises HourlyRateError: If ``strict`` is ``True`` and no hourly rate has been set. """ # these can raise errors, if the values can not be # converted to ``Decimal``'s rate = decimal.Decimal(self.rate) hours = self._hours() if hours > 0 and not rate > 0: # log a warning that hours have been set, but no hourly rate # has been set. logger.debug( 'hours: {}, are set but rate: {} has not been set'.format( hours, rate) ) if strict is True: # raise an error, if they want one. raise HourlyRateError() with super().ctx() as ctx: yield ctx
[docs] def update(self, updates: Dict[str, Any]=None, append: bool=True, **kwargs ) -> None: """A convenience method to update the common items of an instance. :param updates: Optional dict used for the updates where the keys are attribute names and the values are the items to set for the attribute. :param append: A bool, if ``True`` then we add the items to the existing attribute, if ``False`` then we remove any items already set with the new items. Default is ``True``. :param kwargs: Same as ``updates``. Example:: >>> calc = Calculator() >>> calc.update({'margins': '50'}) >>> assert calc.margins[-1] == '50' # True >>> calc.update(costs='123') >>> assert calc.costs[-1] == '123' # True """ if updates is not None and not isinstance(updates, dict): raise TypeError("'{}' should be a dict.") if updates is not None: kwargs.update(updates) for key in kwargs: logger.debug( 'updating key: {}, value: {}'.format(key, kwargs[key]) ) inconfig = False attr = getattr(self, key, None) # check if it's in the config. if attr is None: attr = getattr(self.config, key, None) inconfig = True if isinstance(attr, list) and append is True: attr.append(kwargs[key]) elif isinstance(attr, list) and append is False: setattr(self, key, [kwargs[key]]) else: if key == 'rate': self.rate = kwargs[key] elif inconfig is False: setattr(self, key, kwargs[key]) else: setattr(self.config, key, kwargs[key])
# TODO: Add an error if colors does not convert to a ``ColorKey``
[docs]class TerminalCalculator(Calculator): """Extends :py:class:`Calculator` for use in the command line interface. :param colors: A 6 tuple or :py:class:`ColorKey` of colors :param kwargs: Extra args to pass to :py:class:``Calculator`` """ # the valid prompt key's and the order we want to prompt _prompts = ( # multiple values accepted 'margin', 'discount', 'hours', 'deduction', 'cost', # single values accepted 'rate' ) def __init__(self, *, colors: Union[Iterable[str], ColorKey, None]=None, **kwargs) -> None: kwargs.setdefault('config', TerminalConfig()) super().__init__(**kwargs) self.colors = ColorKey(*colors) if colors is not None else \ DEFAULT_COLOR_KEY ''' @staticmethod def _confirm_prompt(category: str) -> bool: msg = "Would you like to add this to {}?".format(category) return click.confirm(msg) ''' def _multiple_display_header(self) -> str: """Formats and returns the header shown when prompting for multiple values from the user. """ rv = \ "\nMultiples accepted, they can be seperated by '{}'\n\n".format( self.config.prompt_seperator) return rv def _single_display_header(self) -> str: return '\nSingle value only.\n\n' def _prompt_for(self, attr: str, default: Any=None, type: Any=None, is_single: bool=False, current: Any=None, display_multiple_header: bool=True, display_single_header: bool=True ) -> PromptResponse: """A helper to prompt a user for extra information for an attribute. """ # search for color in ``self.colors`` attr_string_color = getattr(self.colors, str(attr), 'red') # colorize the string of the attribute we're prompting for. attr_string = colorize(str(attr), attr_string_color) # get the seperator used to split multiple values in user input. seperator = self.config.prompt_seperator # whether we are/have displayed the multiple header multiple_heading_displayed = not display_multiple_header # whether we are/have displayed the single header single_heading_displayed = not display_single_header # validate that we are prompting for a valid attribute. if getattr(self, str(attr), None) is None: raise AttributeError(attr) # start building our display message. msg = '' if multiple_heading_displayed is False and is_single is False: # add the multiple display header to inform a user that # multiple values are accepted and what should be used as # the seperator. multiple_heading_displayed = True msg += self._multiple_display_header() if single_heading_displayed is False and is_single is True: # add the single display heading to inform the user that # only single values are accepted. single_heading_displayed = True msg += self._single_display_header() msg += 'Please enter {} for the job'.format( 'a ' + attr_string if is_single is True else attr_string ) # show the current value, if applicable. if current is not None: msg += " Current value is: '{}'".format(current) # prompt for the value(s) from user. _value = click.prompt(msg, default=default, type=str) logger.debug('pre-parsed value: {}'.format(_value)) # parse the input value, converting to the expected type, # if applicable. # # parse_input_string is always a tuple return value. rv = parse_input_string(_value, seperator=seperator, convert=type) if is_single is False: # then a tuple is ok to return, so return rv return PromptResponse( value=rv, multiple_heading_displayed=multiple_heading_displayed, single_heading_displayed=single_heading_displayed ) else: # just return the first value of the tuple return PromptResponse( value=rv[0], multiple_heading_displayed=multiple_heading_displayed, single_heading_displayed=single_heading_displayed ) prompt_for_cost = functools.partialmethod(_prompt_for, 'costs', type=COSTS) """Prompt the user for cost(s) for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """ prompt_for_margin = functools.partialmethod(_prompt_for, 'margins', type=MARGIN) """Prompt the user for margin(s) for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """ prompt_for_discount = functools.partialmethod(_prompt_for, 'discounts', type=DISCOUNT) """Prompt the user for discount(s) for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """ prompt_for_deduction = functools.partialmethod(_prompt_for, 'deductions', type=DEDUCTION) """Prompt the user for deduction(s) for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """ prompt_for_hours = functools.partialmethod(_prompt_for, 'hours', type=decimal.Decimal) """Prompt the user for hour(s) for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """ prompt_for_rate = functools.partialmethod(_prompt_for, 'rate', type=decimal.Decimal, is_single=True) """Prompt the user for a rate for the calculation. :param default: Optional value to use as default for no input. :param current: Optional value to display as the current value. :param is_single: If input is single or accepts multiple values. :param type: A type to convert the value(s) to. :param display_multiple_header: If ``True`` then show the multiple value header. :param display_single_header: If ``True`` then show the single value header. :rtype: :py:class:`.PromptResponse` """
[docs] def key_for_prompt(self, prompt: str) -> str: """A helper to return the correct key (attribute name) to use for a prompt. This is the opposite of the :py:meth:`normalize` method. In which it ensures the return value is pluralized for most cases as that's the key that can be used in the :py:meth:`update` method for an instance. This is helpful when making multiple prompts that save their values in a dict, that later is used to update the attributes on an instance. :param prompt: The attribute the prompt is for. Example:: >>> calc = TerminalCalculator() >>> calc.key_for_prompt('discount') 'discounts' >>> calc.key_for_prompt('rates') # handle accidental plural's 'rate' """ prompt = str(prompt) if prompt not in self._prompts and prompt.endswith('s'): # check if someone is asking for a key that could be valid, just # used the plural form. # chop of the 's' and check again. prompt = prompt[:-1] elif prompt not in self._prompts and not prompt.endswith('s'): # try adding an 's' to see what happens. prompt += 's' if prompt not in self._prompts: raise AttributeError(prompt) if prompt == 'rate' or prompt == 'hours': return prompt else: # all other attribute names end in 's', so we add it to the # value we return. return prompt + 's'
[docs] def normalize(self, attr: str) -> str: """A helper to normalize an attribute name, as most context's expect the name to not be pluralized (except ``hours``), so we chop off the 's' if applicable. This is also helpful if using :py:meth:`prompt_for` method, to make sure the name for the prompt is correct. :param attr: The attribute name to normalize. """ attr = str(attr) if attr != 'hours' and attr.endswith('s'): attr = attr[:-1] return attr
[docs] def is_empty(self, attr: str) -> bool: """Determines if an attribute is considered empty, or equal to '0'. If there are default hours set by an environment variable, then hours are considered empty if the sum of (hours - default_hours) are '0'. :param attr: The name of the attribute to check. """ attr = self.normalize(attr) if attr == 'rate': # check rate against ``self.rate()`` return self.rate == 0 if attr == 'hours': # check hours, which can also have ``default_hours`` that are # loaded from an envrionment variable, so we check that as well. # hours is considered empty if hours - default_hours == 0. hours = self._hours() default_hours = int(self.config.default_hours) return (hours - default_hours) == 0 if attr == 'cost': # check cost against ``self._costs()``. return self._costs() == 0 # everything else get's checked in ``self.ctx`` with self.ctx() as ctx: value = getattr(ctx, attr, None) if value is None: # if we are None, then someone is checking for unsupported # attribute. raise AttributeError(attr) return value == 0
@contextlib.contextmanager
[docs] def prompt_for(self, prompt: str, **kwargs) -> PromptResponse: """A context manager that prompt's a user for input, and yields a ``PromptResponse``. Valid prompts are 'margin', 'discount', 'deduction', 'cost', 'hours', and 'rate'. We will also do/ call the right method if use the plural form of prompt (ex. 'margins'). :param prompt: The attribute to prompt for (ex. 'margin') :param kwargs: They get passed to the prompt_for_{prompt} command. Example:: >>> calc = TerminalCalculator() >>> with calc.prompt_for('margin', default='0') as result: ... calc.update(margins=result.value) """ prompt = str(prompt) if prompt not in self._prompts and prompt.endswith('s'): # perhaps a user added an 's' # ex. prompt_for('margins') instead of prompt_for('margin'), # so let's check. # chop off the 's' and see what happens. prompt = prompt[:-1] func = getattr(self, 'prompt_for_' + prompt, None) if prompt not in self._prompts or func is None: raise AttributeError(prompt) yield func(**kwargs)
[docs] def prompt_for_empty(self) -> None: """Prompt the user for all the values that are determined to be empty or '0', and add them to instance appropriately. """ # we only want to display the headings once, so these # determine if we have displayed the headings or not already. multiple_heading_displayed = False single_heading_displayed = False strict = False # if ``strict`` is True, then errors get raised if ``hours`` have # been set with no ``rate`` set. with self.ctx(strict=strict) as ctx: # show the values before any prompts, if applicable. logger.debug('ctx before prompts: {}, hours: {}, rate: {}'.format( ctx, self._hours(), self.rate)) # ``self._prompts`` contain the valid prompt key's to use, # and the order in which we prompt for empty's. for prompt in self._prompts: # ``current`` only get's set for hours in this context, if # there are ``default_hours`` added from an environment # variable. current = None # check if the value is empty or not. if self.is_empty(prompt) is True: # if it's empty and our prompt is for hours, # set current, if applicable. if prompt == 'hours' and int(self.config.default_hours) > 0: current = self.config.default_hours # set-up kwargs to be passed to ``prompt_for`` kwargs = dict( default='0', current=current, display_multiple_header=not multiple_heading_displayed, display_single_header=not single_heading_displayed ) # prompt the user for input, and check the result. with self.prompt_for(prompt, **kwargs) as result: logger.debug( 'result from prompt: {}, key: {}: {}'.format( prompt, self.key_for_prompt(prompt), result) ) # update ``self`` with the value of the result. self.update( {self.key_for_prompt(prompt): result.value} ) # set which/if heading's were displayed. multiple_heading_displayed = \ result.multiple_heading_displayed single_heading_displayed = \ result.single_heading_displayed # show the changes that have been applied if ``debug`` is True. if self.config.debug is True: with self.ctx(strict=False) as ctx: logger.debug( 'ctx after prompts: {}, hours: {}, rate: {}'.format( ctx, self._hours(), self.rate) )
[docs] def prompt_all(self) -> None: """Prompt the user for all input's, also showing the current value, and add the values to this instance appropriately. """ if self.config.debug is True: with self.ctx() as ctx: logger.debug( 'before prompts: {}, hours: {}, rate: {}'.format( ctx, self._hours(), self.rate) ) multiple_heading_displayed = False single_heading_displayed = False for prompt in self._prompts: # set current value up appropriately. if prompt == 'cost': current = self._costs() elif prompt == 'rate': current = self.rate elif prompt == 'hours': current = self._hours() else: with self.ctx(strict=False) as ctx: current = getattr(ctx, prompt, None) # set kwargs to pass to ``prompt_for`` method. kwargs = dict( default='0', current=current, display_multiple_header=not multiple_heading_displayed, display_single_header=not single_heading_displayed ) # prompt the user for input and use the result. with self.prompt_for(prompt, **kwargs) as result: # update self with the value. self.update({self.key_for_prompt(prompt): result.value}) # set which/if we displayed any headings multiple_heading_displayed = \ result.multiple_heading_displayed single_heading_displayed = \ result.single_heading_displayed if self.config.debug is True: with self.ctx() as ctx: logger.debug( 'after prompts: {}, hours: {}, rate: {}'.format( ctx, self._hours(), self.rate) )