Source code for libb.format
import datetime
import logging
import math
import re
from titlecase import titlecase as _titlecase
logger = logging.getLogger(__name__)
__all__ = [
'Percent',
'capitalize',
'capwords',
'commafy',
'fmt',
'format',
'format_phone',
'format_secondsdelta',
'format_timedelta',
'format_timeinterval',
'splitcap',
'titlecase'
]
[docs]
def format(value, style):
"""Format a numeric value with various formatting options.
Supports commas, parens for negative values, and special cases for zeros.
:param value: Numeric value to format.
:param str style: Format specification string.
:returns: Formatted string.
:rtype: str
Style format: ``n[cpzZkKmMbBsS%#]/[kmb]`` (e.g., ``'2c'``, ``'0cpz'``, ``'1%'``, ``'1s'``)
- **n** - number of decimals
- **c** - use commas
- **p** - wrap negative numbers in parenthesis
- **z** - use a ' ' for zero values
- **Z** - use a '-' for zero values
- **K/k** - convert to thousands and add 'K' suffix
- **M/m** - convert to millions and add 'M' suffix
- **B/b** - convert to billions and add 'B' suffix
- **S/s** - convert to shorter of KMB formats
- **%** - scale by 100 and add a percent sign at the end (unless z/Z)
- **#** - scale by 10000 and add 'bps' at the end
- **/x** - divide the number by 1e3 (k), 1e6 (m), 1e9 (b) first
(does not append the units like KMB do)
Example::
>>> format(1234.56, '2c')
'1,234.56'
>>> format(-100, '0cp')
'(100)'
>>> format(0, '2z')
''
>>> format(0.5, '1%')
'50.0%'
>>> format(1500000, '1M')
'1.5M'
"""
if isinstance(value, str):
value = value.strip()
if value is None or value == '':
return ''
if isinstance(value, str) and not value.isdigit():
return value
if not style:
return value
val = float(value)
# verify the style
m = re.match(r'^\+?(\d)([cpzZkKmMbBsS%#]{0,4})(/[kKmMbB])?$', style)
if not m:
raise ValueError('Invalid style:' + style)
sign = style[0] == '+'
dps, fmt, div = m.groups()
# for a /x divisor, scale the number before doing any other formatting
if div:
val /= {'k': 1.0e3, 'm': 1.0e6, 'b': 1.0e9}[div[-1].lower()]
# check for incompatible specifications
if 'z' in fmt and 'Z' in fmt:
raise ValueError('Invalid format. Cannot contain z and Z: ' + fmt)
if 'p' in fmt and sign:
raise ValueError('Invalid format. Cannot contain p and +: ' + fmt)
if 's' in fmt.lower() and any(_ in fmt.lower() for _ in ('k', 'm', 'b')):
raise ValueError('Invalid format. Cannot contain S and K/M/B: ' + fmt)
if '%' in fmt and '#' in fmt:
raise ValueError('Invalid format. Cannot contain % and #: ' + fmt)
if ('%' in fmt or '#' in fmt) and any(_ in fmt.lower() for _ in ('s', 'k', 'm', 'b')):
raise ValueError('Invalid format. Cannot contain %/# and S/K/M/B: ' + fmt)
# now format it
try:
# handle zero specially, if requested
if val == 0:
if 'z' in fmt:
return ''
if 'Z' in fmt:
return '-'
# scale if using %, #, S, K, M or B formats
suffix = ''
factor = {'%': 1e2, '#': 1e4, 'k': 1e-3, 'K': 1e-3, 'm': 1e-6, 'M': 1e-6, 'b': 1e-9, 'B': 1e-9}
for k in factor:
if k in fmt:
suffix = ' bp' if k == '#' else k
val *= factor[k]
break
# if using S format, choose most appropriate of K, M or B
if ('S' in fmt or 's' in fmt) and val != 0:
e = math.log10(abs(val))
for s, n, f in (('B', 9, 1e-9), ('M', 6, 1e-6), ('K', 3, 1e-3)):
if e >= n:
suffix = s if 'S' in fmt else s.lower()
val *= f
break
# set prefix and parens depending on sign of value and format settings
prefix = ''
parens = False
if val < 0:
if 'p' in fmt:
parens = True
else:
prefix = '-'
val = abs(val)
elif val > 0:
if sign:
prefix = '+'
# format the number to the specified number of decimal places
s = f'%.{dps}f'
val = s % val
# check if formatted value is effectively zero
is_zero = re.match(r'^0(\.0*)?$', val)
# fix up with commas
if 'c' in fmt:
val = commafy(val)
# wrap in parens
if parens and not is_zero:
val = f'({val})'
elif 'p' in fmt:
# pad positive numbers so the last digit lines up better
val = f'{val}'
# avoid +/-0s
if prefix in {'-', '+'} and is_zero:
prefix = ''
val = prefix + val + suffix
return val
except Exception as exc:
logger.exception(exc)
return value or ''
#: Alias for :func:`format`.
fmt = format
[docs]
def format_timeinterval(start, end=None):
"""Format a time interval as human-readable string.
:param datetime start: Start datetime.
:param datetime end: End datetime (defaults to now).
:returns: Human-readable time interval string.
:rtype: str
Example::
>>> start = datetime.datetime(2020, 1, 1, 12, 0, 0)
>>> end = datetime.datetime(2020, 1, 1, 14, 30, 0)
>>> format_timeinterval(start, end)
'2.5 hrs'
"""
if not end:
end = datetime.datetime.now()
return format_timedelta(end - start)
[docs]
def format_secondsdelta(seconds):
"""Format seconds as human-readable time delta.
:param float seconds: Number of seconds.
:returns: Human-readable time string.
:rtype: str
Example::
>>> format_secondsdelta(3661)
'1.0 hrs'
>>> format_secondsdelta(90)
'1.5 min'
"""
return format_timedelta(datetime.timedelta(0, seconds, 0))
[docs]
def format_timedelta(td):
"""Format a timedelta as human-readable string.
:param timedelta td: Time delta to format.
:returns: Human-readable string (e.g., '2 hrs', '30 min').
:rtype: str
Example::
>>> format_timedelta(datetime.timedelta(days=2))
'2 days'
>>> format_timedelta(datetime.timedelta(hours=3))
'3 hrs'
>>> format_timedelta(datetime.timedelta(seconds=45))
'45 sec'
"""
def fmt_num(val, units):
if val == int(val):
return f'{int(val)} {units}'
return f'{val:.1f} {units}'
if td.days > 365:
return fmt_num(td.days / 365.0, 'yrs')
if td.days > 30:
return fmt_num(td.days / 30.0, 'mos')
if td.days > 7:
return fmt_num(td.days / 7.0, 'wks')
if td.days > 0:
return fmt_num(td.days + td.seconds / (60.0 * 60.0 * 24.0), 'days')
if td.seconds > 3600:
return fmt_num(td.seconds / 3600.0, 'hrs')
if td.seconds > 60:
return fmt_num(td.seconds / 60.0, 'min')
if td.seconds > 0:
return fmt_num(td.seconds + td.microseconds / 1000000.0, 'sec')
return fmt_num(td.microseconds / 1000.0, 'msec')
[docs]
def commafy(n):
"""Add commas to a numeric value.
:param n: Number or string to add commas to.
:returns: String with comma separators.
:rtype: str or None
Example::
>>> commafy(1)
'1'
>>> commafy(123)
'123'
>>> commafy(-123)
'-123'
>>> commafy(1234)
'1,234'
>>> commafy(1234567890)
'1,234,567,890'
>>> commafy(123.0)
'123.0'
>>> commafy(1234.5)
'1,234.5'
>>> commafy(1234.56789)
'1,234.56789'
>>> commafy(f'{-1234.5:.2f}')
'-1,234.50'
>>> commafy(None)
>>>
"""
if n is None:
return None
n = str(n).strip()
if n.startswith('-'):
prefix = '-'
n = n[1:].strip()
else:
prefix = ''
if '.' in n:
dollars, cents = n.split('.')
else:
dollars, cents = n, None
r = []
for i, c in enumerate(str(dollars)[::-1]):
if i and (not (i % 3)):
r.insert(0, ',')
r.insert(0, c)
out = ''.join(r)
if cents:
out += '.' + cents
return prefix + out
[docs]
def splitcap(s, delim=None):
"""Split and capitalize string by delimiter (or camelcase).
:param str s: String to split and capitalize.
:param str delim: Delimiter to split on (auto-detected if None).
:returns: Title-cased string with spaces.
:rtype: str
Example::
>>> splitcap("foo_bar")
'Foo Bar'
>>> splitcap("fooBar")
'Foo Bar'
"""
if not delim:
if '_' in s:
delim = '_'
elif ' ' in s:
delim = ' '
if delim:
bits = s.split(delim)
else: # camelcase
bits = re.sub(r'([a-z])([A-Z])', r'\1 \2', s).split(' ')
return ' '.join([capitalize(s) for s in bits])
[docs]
def capwords(s):
"""Capitalize words in a string, accommodating acronyms.
:param str s: String to capitalize.
:returns: Capitalized string.
:rtype: str
Example::
>>> capwords("f.o.o")
'F.O.O'
>>> capwords("bar")
'Bar'
>>> capwords("foo bar")
'Foo Bar'
"""
def _callback(match):
s = match.group(0)
if s == s.upper():
return s
return s.capitalize()
return re.sub(r"[\w'\-\_]+", _callback, s)
[docs]
def capitalize(s):
"""Capitalize with special handling for known abbreviations.
:param str s: String to capitalize.
:returns: Capitalized string or known abbreviation.
:rtype: str
Example::
>>> capitalize('goo')
'Goo'
>>> capitalize('mv')
'MV'
>>> capitalize('pct')
'%'
"""
KNOWN = {
'mtd': 'MTD',
'qtd': 'QTD',
'ytd': 'YTD',
'xtd': 'XTD',
'itd': 'ITD',
'mv': 'MV',
'lmv': 'LMV',
'smv': 'SMV',
'gmv': 'GMV',
'nmv': 'NMV',
'cs': 'CS',
'pnl': 'P&L',
'usd': '$',
'dollar': '$',
'pct': '%',
'vwap': 'VWAP',
}
return KNOWN.get(s.lower(), capwords(s))
[docs]
def titlecase(s):
"""Convert string to title case using python-titlecase library.
:param str s: String to convert.
:returns: Title-cased string.
:rtype: str
Example::
>>> titlecase('the quick brown fox')
'The Quick Brown Fox'
"""
return _titlecase(s)
[docs]
class Percent(float):
"""Float subclass that marks values for percentage formatting in display tables.
Example::
>>> p = Percent(0.25)
>>> float(p)
0.25
>>> p.pct
True
"""
def __new__(cls, val):
p = float.__new__(cls, val)
p.pct = True
return p
[docs]
def format_phone(phone):
"""Reformat phone numbers for display.
:param phone: Phone number as string or integer.
:returns: Formatted phone number with dashes.
:rtype: str
Example::
>>> format_phone('6877995559')
'687-799-5559'
"""
pstr = str(phone)
parr = [pstr[-10:-7], pstr[-7:-4], pstr[-4:]]
if len(pstr) > 10:
parr.insert(0, pstr[:-10])
formatted_phone = '-'.join(parr)
return formatted_phone
if __name__ == '__main__':
__import__('doctest').testmod(optionflags=4 | 8 | 32)