switching to high quality piper tts and added label translations
This commit is contained in:
@@ -0,0 +1,838 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: September 17, 2021
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""The main module of the `humanfriendly` package."""
|
||||
|
||||
# Standard library modules.
|
||||
import collections
|
||||
import datetime
|
||||
import decimal
|
||||
import numbers
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import time
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import is_string, monotonic
|
||||
from humanfriendly.deprecation import define_aliases
|
||||
from humanfriendly.text import concatenate, format, pluralize, tokenize
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'CombinedUnit',
|
||||
'InvalidDate',
|
||||
'InvalidLength',
|
||||
'InvalidSize',
|
||||
'InvalidTimespan',
|
||||
'SizeUnit',
|
||||
'Timer',
|
||||
'__version__',
|
||||
'coerce_boolean',
|
||||
'coerce_pattern',
|
||||
'coerce_seconds',
|
||||
'disk_size_units',
|
||||
'format_length',
|
||||
'format_number',
|
||||
'format_path',
|
||||
'format_size',
|
||||
'format_timespan',
|
||||
'length_size_units',
|
||||
'parse_date',
|
||||
'parse_length',
|
||||
'parse_path',
|
||||
'parse_size',
|
||||
'parse_timespan',
|
||||
'round_number',
|
||||
'time_units',
|
||||
)
|
||||
|
||||
# Semi-standard module versioning.
|
||||
__version__ = '10.0'
|
||||
|
||||
# Named tuples to define units of size.
|
||||
SizeUnit = collections.namedtuple('SizeUnit', 'divider, symbol, name')
|
||||
CombinedUnit = collections.namedtuple('CombinedUnit', 'decimal, binary')
|
||||
|
||||
# Common disk size units in binary (base-2) and decimal (base-10) multiples.
|
||||
disk_size_units = (
|
||||
CombinedUnit(SizeUnit(1000**1, 'KB', 'kilobyte'), SizeUnit(1024**1, 'KiB', 'kibibyte')),
|
||||
CombinedUnit(SizeUnit(1000**2, 'MB', 'megabyte'), SizeUnit(1024**2, 'MiB', 'mebibyte')),
|
||||
CombinedUnit(SizeUnit(1000**3, 'GB', 'gigabyte'), SizeUnit(1024**3, 'GiB', 'gibibyte')),
|
||||
CombinedUnit(SizeUnit(1000**4, 'TB', 'terabyte'), SizeUnit(1024**4, 'TiB', 'tebibyte')),
|
||||
CombinedUnit(SizeUnit(1000**5, 'PB', 'petabyte'), SizeUnit(1024**5, 'PiB', 'pebibyte')),
|
||||
CombinedUnit(SizeUnit(1000**6, 'EB', 'exabyte'), SizeUnit(1024**6, 'EiB', 'exbibyte')),
|
||||
CombinedUnit(SizeUnit(1000**7, 'ZB', 'zettabyte'), SizeUnit(1024**7, 'ZiB', 'zebibyte')),
|
||||
CombinedUnit(SizeUnit(1000**8, 'YB', 'yottabyte'), SizeUnit(1024**8, 'YiB', 'yobibyte')),
|
||||
)
|
||||
|
||||
# Common length size units, used for formatting and parsing.
|
||||
length_size_units = (dict(prefix='nm', divider=1e-09, singular='nm', plural='nm'),
|
||||
dict(prefix='mm', divider=1e-03, singular='mm', plural='mm'),
|
||||
dict(prefix='cm', divider=1e-02, singular='cm', plural='cm'),
|
||||
dict(prefix='m', divider=1, singular='metre', plural='metres'),
|
||||
dict(prefix='km', divider=1000, singular='km', plural='km'))
|
||||
|
||||
# Common time units, used for formatting of time spans.
|
||||
time_units = (dict(divider=1e-9, singular='nanosecond', plural='nanoseconds', abbreviations=['ns']),
|
||||
dict(divider=1e-6, singular='microsecond', plural='microseconds', abbreviations=['us']),
|
||||
dict(divider=1e-3, singular='millisecond', plural='milliseconds', abbreviations=['ms']),
|
||||
dict(divider=1, singular='second', plural='seconds', abbreviations=['s', 'sec', 'secs']),
|
||||
dict(divider=60, singular='minute', plural='minutes', abbreviations=['m', 'min', 'mins']),
|
||||
dict(divider=60 * 60, singular='hour', plural='hours', abbreviations=['h']),
|
||||
dict(divider=60 * 60 * 24, singular='day', plural='days', abbreviations=['d']),
|
||||
dict(divider=60 * 60 * 24 * 7, singular='week', plural='weeks', abbreviations=['w']),
|
||||
dict(divider=60 * 60 * 24 * 7 * 52, singular='year', plural='years', abbreviations=['y']))
|
||||
|
||||
|
||||
def coerce_boolean(value):
|
||||
"""
|
||||
Coerce any value to a boolean.
|
||||
|
||||
:param value: Any Python value. If the value is a string:
|
||||
|
||||
- The strings '1', 'yes', 'true' and 'on' are coerced to :data:`True`.
|
||||
- The strings '0', 'no', 'false' and 'off' are coerced to :data:`False`.
|
||||
- Other strings raise an exception.
|
||||
|
||||
Other Python values are coerced using :class:`bool`.
|
||||
:returns: A proper boolean value.
|
||||
:raises: :exc:`exceptions.ValueError` when the value is a string but
|
||||
cannot be coerced with certainty.
|
||||
"""
|
||||
if is_string(value):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ('1', 'yes', 'true', 'on'):
|
||||
return True
|
||||
elif normalized in ('0', 'no', 'false', 'off', ''):
|
||||
return False
|
||||
else:
|
||||
msg = "Failed to coerce string to boolean! (%r)"
|
||||
raise ValueError(format(msg, value))
|
||||
else:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def coerce_pattern(value, flags=0):
|
||||
"""
|
||||
Coerce strings to compiled regular expressions.
|
||||
|
||||
:param value: A string containing a regular expression pattern
|
||||
or a compiled regular expression.
|
||||
:param flags: The flags used to compile the pattern (an integer).
|
||||
:returns: A compiled regular expression.
|
||||
:raises: :exc:`~exceptions.ValueError` when `value` isn't a string
|
||||
and also isn't a compiled regular expression.
|
||||
"""
|
||||
if is_string(value):
|
||||
value = re.compile(value, flags)
|
||||
else:
|
||||
empty_pattern = re.compile('')
|
||||
pattern_type = type(empty_pattern)
|
||||
if not isinstance(value, pattern_type):
|
||||
msg = "Failed to coerce value to compiled regular expression! (%r)"
|
||||
raise ValueError(format(msg, value))
|
||||
return value
|
||||
|
||||
|
||||
def coerce_seconds(value):
|
||||
"""
|
||||
Coerce a value to the number of seconds.
|
||||
|
||||
:param value: An :class:`int`, :class:`float` or
|
||||
:class:`datetime.timedelta` object.
|
||||
:returns: An :class:`int` or :class:`float` value.
|
||||
|
||||
When `value` is a :class:`datetime.timedelta` object the
|
||||
:meth:`~datetime.timedelta.total_seconds()` method is called.
|
||||
"""
|
||||
if isinstance(value, datetime.timedelta):
|
||||
return value.total_seconds()
|
||||
if not isinstance(value, numbers.Number):
|
||||
msg = "Failed to coerce value to number of seconds! (%r)"
|
||||
raise ValueError(format(msg, value))
|
||||
return value
|
||||
|
||||
|
||||
def format_size(num_bytes, keep_width=False, binary=False):
|
||||
"""
|
||||
Format a byte count as a human readable file size.
|
||||
|
||||
:param num_bytes: The size to format in bytes (an integer).
|
||||
:param keep_width: :data:`True` if trailing zeros should not be stripped,
|
||||
:data:`False` if they can be stripped.
|
||||
:param binary: :data:`True` to use binary multiples of bytes (base-2),
|
||||
:data:`False` to use decimal multiples of bytes (base-10).
|
||||
:returns: The corresponding human readable file size (a string).
|
||||
|
||||
This function knows how to format sizes in bytes, kilobytes, megabytes,
|
||||
gigabytes, terabytes and petabytes. Some examples:
|
||||
|
||||
>>> from humanfriendly import format_size
|
||||
>>> format_size(0)
|
||||
'0 bytes'
|
||||
>>> format_size(1)
|
||||
'1 byte'
|
||||
>>> format_size(5)
|
||||
'5 bytes'
|
||||
> format_size(1000)
|
||||
'1 KB'
|
||||
> format_size(1024, binary=True)
|
||||
'1 KiB'
|
||||
>>> format_size(1000 ** 3 * 4)
|
||||
'4 GB'
|
||||
"""
|
||||
for unit in reversed(disk_size_units):
|
||||
if num_bytes >= unit.binary.divider and binary:
|
||||
number = round_number(float(num_bytes) / unit.binary.divider, keep_width=keep_width)
|
||||
return pluralize(number, unit.binary.symbol, unit.binary.symbol)
|
||||
elif num_bytes >= unit.decimal.divider and not binary:
|
||||
number = round_number(float(num_bytes) / unit.decimal.divider, keep_width=keep_width)
|
||||
return pluralize(number, unit.decimal.symbol, unit.decimal.symbol)
|
||||
return pluralize(num_bytes, 'byte')
|
||||
|
||||
|
||||
def parse_size(size, binary=False):
|
||||
"""
|
||||
Parse a human readable data size and return the number of bytes.
|
||||
|
||||
:param size: The human readable file size to parse (a string).
|
||||
:param binary: :data:`True` to use binary multiples of bytes (base-2) for
|
||||
ambiguous unit symbols and names, :data:`False` to use
|
||||
decimal multiples of bytes (base-10).
|
||||
:returns: The corresponding size in bytes (an integer).
|
||||
:raises: :exc:`InvalidSize` when the input can't be parsed.
|
||||
|
||||
This function knows how to parse sizes in bytes, kilobytes, megabytes,
|
||||
gigabytes, terabytes and petabytes. Some examples:
|
||||
|
||||
>>> from humanfriendly import parse_size
|
||||
>>> parse_size('42')
|
||||
42
|
||||
>>> parse_size('13b')
|
||||
13
|
||||
>>> parse_size('5 bytes')
|
||||
5
|
||||
>>> parse_size('1 KB')
|
||||
1000
|
||||
>>> parse_size('1 kilobyte')
|
||||
1000
|
||||
>>> parse_size('1 KiB')
|
||||
1024
|
||||
>>> parse_size('1 KB', binary=True)
|
||||
1024
|
||||
>>> parse_size('1.5 GB')
|
||||
1500000000
|
||||
>>> parse_size('1.5 GB', binary=True)
|
||||
1610612736
|
||||
"""
|
||||
tokens = tokenize(size)
|
||||
if tokens and isinstance(tokens[0], numbers.Number):
|
||||
# Get the normalized unit (if any) from the tokenized input.
|
||||
normalized_unit = tokens[1].lower() if len(tokens) == 2 and is_string(tokens[1]) else ''
|
||||
# If the input contains only a number, it's assumed to be the number of
|
||||
# bytes. The second token can also explicitly reference the unit bytes.
|
||||
if len(tokens) == 1 or normalized_unit.startswith('b'):
|
||||
return int(tokens[0])
|
||||
# Otherwise we expect two tokens: A number and a unit.
|
||||
if normalized_unit:
|
||||
# Convert plural units to singular units, for details:
|
||||
# https://github.com/xolox/python-humanfriendly/issues/26
|
||||
normalized_unit = normalized_unit.rstrip('s')
|
||||
for unit in disk_size_units:
|
||||
# First we check for unambiguous symbols (KiB, MiB, GiB, etc)
|
||||
# and names (kibibyte, mebibyte, gibibyte, etc) because their
|
||||
# handling is always the same.
|
||||
if normalized_unit in (unit.binary.symbol.lower(), unit.binary.name.lower()):
|
||||
return int(tokens[0] * unit.binary.divider)
|
||||
# Now we will deal with ambiguous prefixes (K, M, G, etc),
|
||||
# symbols (KB, MB, GB, etc) and names (kilobyte, megabyte,
|
||||
# gigabyte, etc) according to the caller's preference.
|
||||
if (normalized_unit in (unit.decimal.symbol.lower(), unit.decimal.name.lower()) or
|
||||
normalized_unit.startswith(unit.decimal.symbol[0].lower())):
|
||||
return int(tokens[0] * (unit.binary.divider if binary else unit.decimal.divider))
|
||||
# We failed to parse the size specification.
|
||||
msg = "Failed to parse size! (input %r was tokenized as %r)"
|
||||
raise InvalidSize(format(msg, size, tokens))
|
||||
|
||||
|
||||
def format_length(num_metres, keep_width=False):
|
||||
"""
|
||||
Format a metre count as a human readable length.
|
||||
|
||||
:param num_metres: The length to format in metres (float / integer).
|
||||
:param keep_width: :data:`True` if trailing zeros should not be stripped,
|
||||
:data:`False` if they can be stripped.
|
||||
:returns: The corresponding human readable length (a string).
|
||||
|
||||
This function supports ranges from nanometres to kilometres.
|
||||
|
||||
Some examples:
|
||||
|
||||
>>> from humanfriendly import format_length
|
||||
>>> format_length(0)
|
||||
'0 metres'
|
||||
>>> format_length(1)
|
||||
'1 metre'
|
||||
>>> format_length(5)
|
||||
'5 metres'
|
||||
>>> format_length(1000)
|
||||
'1 km'
|
||||
>>> format_length(0.004)
|
||||
'4 mm'
|
||||
"""
|
||||
for unit in reversed(length_size_units):
|
||||
if num_metres >= unit['divider']:
|
||||
number = round_number(float(num_metres) / unit['divider'], keep_width=keep_width)
|
||||
return pluralize(number, unit['singular'], unit['plural'])
|
||||
return pluralize(num_metres, 'metre')
|
||||
|
||||
|
||||
def parse_length(length):
|
||||
"""
|
||||
Parse a human readable length and return the number of metres.
|
||||
|
||||
:param length: The human readable length to parse (a string).
|
||||
:returns: The corresponding length in metres (a float).
|
||||
:raises: :exc:`InvalidLength` when the input can't be parsed.
|
||||
|
||||
Some examples:
|
||||
|
||||
>>> from humanfriendly import parse_length
|
||||
>>> parse_length('42')
|
||||
42
|
||||
>>> parse_length('1 km')
|
||||
1000
|
||||
>>> parse_length('5mm')
|
||||
0.005
|
||||
>>> parse_length('15.3cm')
|
||||
0.153
|
||||
"""
|
||||
tokens = tokenize(length)
|
||||
if tokens and isinstance(tokens[0], numbers.Number):
|
||||
# If the input contains only a number, it's assumed to be the number of metres.
|
||||
if len(tokens) == 1:
|
||||
return tokens[0]
|
||||
# Otherwise we expect to find two tokens: A number and a unit.
|
||||
if len(tokens) == 2 and is_string(tokens[1]):
|
||||
normalized_unit = tokens[1].lower()
|
||||
# Try to match the first letter of the unit.
|
||||
for unit in length_size_units:
|
||||
if normalized_unit.startswith(unit['prefix']):
|
||||
return tokens[0] * unit['divider']
|
||||
# We failed to parse the length specification.
|
||||
msg = "Failed to parse length! (input %r was tokenized as %r)"
|
||||
raise InvalidLength(format(msg, length, tokens))
|
||||
|
||||
|
||||
def format_number(number, num_decimals=2):
|
||||
"""
|
||||
Format a number as a string including thousands separators.
|
||||
|
||||
:param number: The number to format (a number like an :class:`int`,
|
||||
:class:`long` or :class:`float`).
|
||||
:param num_decimals: The number of decimals to render (2 by default). If no
|
||||
decimal places are required to represent the number
|
||||
they will be omitted regardless of this argument.
|
||||
:returns: The formatted number (a string).
|
||||
|
||||
This function is intended to make it easier to recognize the order of size
|
||||
of the number being formatted.
|
||||
|
||||
Here's an example:
|
||||
|
||||
>>> from humanfriendly import format_number
|
||||
>>> print(format_number(6000000))
|
||||
6,000,000
|
||||
> print(format_number(6000000000.42))
|
||||
6,000,000,000.42
|
||||
> print(format_number(6000000000.42, num_decimals=0))
|
||||
6,000,000,000
|
||||
"""
|
||||
integer_part, _, decimal_part = str(float(number)).partition('.')
|
||||
negative_sign = integer_part.startswith('-')
|
||||
reversed_digits = ''.join(reversed(integer_part.lstrip('-')))
|
||||
parts = []
|
||||
while reversed_digits:
|
||||
parts.append(reversed_digits[:3])
|
||||
reversed_digits = reversed_digits[3:]
|
||||
formatted_number = ''.join(reversed(','.join(parts)))
|
||||
decimals_to_add = decimal_part[:num_decimals].rstrip('0')
|
||||
if decimals_to_add:
|
||||
formatted_number += '.' + decimals_to_add
|
||||
if negative_sign:
|
||||
formatted_number = '-' + formatted_number
|
||||
return formatted_number
|
||||
|
||||
|
||||
def round_number(count, keep_width=False):
|
||||
"""
|
||||
Round a floating point number to two decimal places in a human friendly format.
|
||||
|
||||
:param count: The number to format.
|
||||
:param keep_width: :data:`True` if trailing zeros should not be stripped,
|
||||
:data:`False` if they can be stripped.
|
||||
:returns: The formatted number as a string. If no decimal places are
|
||||
required to represent the number, they will be omitted.
|
||||
|
||||
The main purpose of this function is to be used by functions like
|
||||
:func:`format_length()`, :func:`format_size()` and
|
||||
:func:`format_timespan()`.
|
||||
|
||||
Here are some examples:
|
||||
|
||||
>>> from humanfriendly import round_number
|
||||
>>> round_number(1)
|
||||
'1'
|
||||
>>> round_number(math.pi)
|
||||
'3.14'
|
||||
>>> round_number(5.001)
|
||||
'5'
|
||||
"""
|
||||
text = '%.2f' % float(count)
|
||||
if not keep_width:
|
||||
text = re.sub('0+$', '', text)
|
||||
text = re.sub(r'\.$', '', text)
|
||||
return text
|
||||
|
||||
|
||||
def format_timespan(num_seconds, detailed=False, max_units=3):
|
||||
"""
|
||||
Format a timespan in seconds as a human readable string.
|
||||
|
||||
:param num_seconds: Any value accepted by :func:`coerce_seconds()`.
|
||||
:param detailed: If :data:`True` milliseconds are represented separately
|
||||
instead of being represented as fractional seconds
|
||||
(defaults to :data:`False`).
|
||||
:param max_units: The maximum number of units to show in the formatted time
|
||||
span (an integer, defaults to three).
|
||||
:returns: The formatted timespan as a string.
|
||||
:raise: See :func:`coerce_seconds()`.
|
||||
|
||||
Some examples:
|
||||
|
||||
>>> from humanfriendly import format_timespan
|
||||
>>> format_timespan(0)
|
||||
'0 seconds'
|
||||
>>> format_timespan(1)
|
||||
'1 second'
|
||||
>>> import math
|
||||
>>> format_timespan(math.pi)
|
||||
'3.14 seconds'
|
||||
>>> hour = 60 * 60
|
||||
>>> day = hour * 24
|
||||
>>> week = day * 7
|
||||
>>> format_timespan(week * 52 + day * 2 + hour * 3)
|
||||
'1 year, 2 days and 3 hours'
|
||||
"""
|
||||
num_seconds = coerce_seconds(num_seconds)
|
||||
if num_seconds < 60 and not detailed:
|
||||
# Fast path.
|
||||
return pluralize(round_number(num_seconds), 'second')
|
||||
else:
|
||||
# Slow path.
|
||||
result = []
|
||||
num_seconds = decimal.Decimal(str(num_seconds))
|
||||
relevant_units = list(reversed(time_units[0 if detailed else 3:]))
|
||||
for unit in relevant_units:
|
||||
# Extract the unit count from the remaining time.
|
||||
divider = decimal.Decimal(str(unit['divider']))
|
||||
count = num_seconds / divider
|
||||
num_seconds %= divider
|
||||
# Round the unit count appropriately.
|
||||
if unit != relevant_units[-1]:
|
||||
# Integer rounding for all but the smallest unit.
|
||||
count = int(count)
|
||||
else:
|
||||
# Floating point rounding for the smallest unit.
|
||||
count = round_number(count)
|
||||
# Only include relevant units in the result.
|
||||
if count not in (0, '0'):
|
||||
result.append(pluralize(count, unit['singular'], unit['plural']))
|
||||
if len(result) == 1:
|
||||
# A single count/unit combination.
|
||||
return result[0]
|
||||
else:
|
||||
if not detailed:
|
||||
# Remove `insignificant' data from the formatted timespan.
|
||||
result = result[:max_units]
|
||||
# Format the timespan in a readable way.
|
||||
return concatenate(result)
|
||||
|
||||
|
||||
def parse_timespan(timespan):
|
||||
"""
|
||||
Parse a "human friendly" timespan into the number of seconds.
|
||||
|
||||
:param value: A string like ``5h`` (5 hours), ``10m`` (10 minutes) or
|
||||
``42s`` (42 seconds).
|
||||
:returns: The number of seconds as a floating point number.
|
||||
:raises: :exc:`InvalidTimespan` when the input can't be parsed.
|
||||
|
||||
Note that the :func:`parse_timespan()` function is not meant to be the
|
||||
"mirror image" of the :func:`format_timespan()` function. Instead it's
|
||||
meant to allow humans to easily and succinctly specify a timespan with a
|
||||
minimal amount of typing. It's very useful to accept easy to write time
|
||||
spans as e.g. command line arguments to programs.
|
||||
|
||||
The time units (and abbreviations) supported by this function are:
|
||||
|
||||
- ms, millisecond, milliseconds
|
||||
- s, sec, secs, second, seconds
|
||||
- m, min, mins, minute, minutes
|
||||
- h, hour, hours
|
||||
- d, day, days
|
||||
- w, week, weeks
|
||||
- y, year, years
|
||||
|
||||
Some examples:
|
||||
|
||||
>>> from humanfriendly import parse_timespan
|
||||
>>> parse_timespan('42')
|
||||
42.0
|
||||
>>> parse_timespan('42s')
|
||||
42.0
|
||||
>>> parse_timespan('1m')
|
||||
60.0
|
||||
>>> parse_timespan('1h')
|
||||
3600.0
|
||||
>>> parse_timespan('1d')
|
||||
86400.0
|
||||
"""
|
||||
tokens = tokenize(timespan)
|
||||
if tokens and isinstance(tokens[0], numbers.Number):
|
||||
# If the input contains only a number, it's assumed to be the number of seconds.
|
||||
if len(tokens) == 1:
|
||||
return float(tokens[0])
|
||||
# Otherwise we expect to find two tokens: A number and a unit.
|
||||
if len(tokens) == 2 and is_string(tokens[1]):
|
||||
normalized_unit = tokens[1].lower()
|
||||
for unit in time_units:
|
||||
if (normalized_unit == unit['singular'] or
|
||||
normalized_unit == unit['plural'] or
|
||||
normalized_unit in unit['abbreviations']):
|
||||
return float(tokens[0]) * unit['divider']
|
||||
# We failed to parse the timespan specification.
|
||||
msg = "Failed to parse timespan! (input %r was tokenized as %r)"
|
||||
raise InvalidTimespan(format(msg, timespan, tokens))
|
||||
|
||||
|
||||
def parse_date(datestring):
|
||||
"""
|
||||
Parse a date/time string into a tuple of integers.
|
||||
|
||||
:param datestring: The date/time string to parse.
|
||||
:returns: A tuple with the numbers ``(year, month, day, hour, minute,
|
||||
second)`` (all numbers are integers).
|
||||
:raises: :exc:`InvalidDate` when the date cannot be parsed.
|
||||
|
||||
Supported date/time formats:
|
||||
|
||||
- ``YYYY-MM-DD``
|
||||
- ``YYYY-MM-DD HH:MM:SS``
|
||||
|
||||
.. note:: If you want to parse date/time strings with a fixed, known
|
||||
format and :func:`parse_date()` isn't useful to you, consider
|
||||
:func:`time.strptime()` or :meth:`datetime.datetime.strptime()`,
|
||||
both of which are included in the Python standard library.
|
||||
Alternatively for more complex tasks consider using the date/time
|
||||
parsing module in the dateutil_ package.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> from humanfriendly import parse_date
|
||||
>>> parse_date('2013-06-17')
|
||||
(2013, 6, 17, 0, 0, 0)
|
||||
>>> parse_date('2013-06-17 02:47:42')
|
||||
(2013, 6, 17, 2, 47, 42)
|
||||
|
||||
Here's how you convert the result to a number (`Unix time`_):
|
||||
|
||||
>>> from humanfriendly import parse_date
|
||||
>>> from time import mktime
|
||||
>>> mktime(parse_date('2013-06-17 02:47:42') + (-1, -1, -1))
|
||||
1371430062.0
|
||||
|
||||
And here's how you convert it to a :class:`datetime.datetime` object:
|
||||
|
||||
>>> from humanfriendly import parse_date
|
||||
>>> from datetime import datetime
|
||||
>>> datetime(*parse_date('2013-06-17 02:47:42'))
|
||||
datetime.datetime(2013, 6, 17, 2, 47, 42)
|
||||
|
||||
Here's an example that combines :func:`format_timespan()` and
|
||||
:func:`parse_date()` to calculate a human friendly timespan since a
|
||||
given date:
|
||||
|
||||
>>> from humanfriendly import format_timespan, parse_date
|
||||
>>> from time import mktime, time
|
||||
>>> unix_time = mktime(parse_date('2013-06-17 02:47:42') + (-1, -1, -1))
|
||||
>>> seconds_since_then = time() - unix_time
|
||||
>>> print(format_timespan(seconds_since_then))
|
||||
1 year, 43 weeks and 1 day
|
||||
|
||||
.. _dateutil: https://dateutil.readthedocs.io/en/latest/parser.html
|
||||
.. _Unix time: http://en.wikipedia.org/wiki/Unix_time
|
||||
"""
|
||||
try:
|
||||
tokens = [t.strip() for t in datestring.split()]
|
||||
if len(tokens) >= 2:
|
||||
date_parts = list(map(int, tokens[0].split('-'))) + [1, 1]
|
||||
time_parts = list(map(int, tokens[1].split(':'))) + [0, 0, 0]
|
||||
return tuple(date_parts[0:3] + time_parts[0:3])
|
||||
else:
|
||||
year, month, day = (list(map(int, datestring.split('-'))) + [1, 1])[0:3]
|
||||
return (year, month, day, 0, 0, 0)
|
||||
except Exception:
|
||||
msg = "Invalid date! (expected 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' but got: %r)"
|
||||
raise InvalidDate(format(msg, datestring))
|
||||
|
||||
|
||||
def format_path(pathname):
|
||||
"""
|
||||
Shorten a pathname to make it more human friendly.
|
||||
|
||||
:param pathname: An absolute pathname (a string).
|
||||
:returns: The pathname with the user's home directory abbreviated.
|
||||
|
||||
Given an absolute pathname, this function abbreviates the user's home
|
||||
directory to ``~/`` in order to shorten the pathname without losing
|
||||
information. It is not an error if the pathname is not relative to the
|
||||
current user's home directory.
|
||||
|
||||
Here's an example of its usage:
|
||||
|
||||
>>> from os import environ
|
||||
>>> from os.path import join
|
||||
>>> vimrc = join(environ['HOME'], '.vimrc')
|
||||
>>> vimrc
|
||||
'/home/peter/.vimrc'
|
||||
>>> from humanfriendly import format_path
|
||||
>>> format_path(vimrc)
|
||||
'~/.vimrc'
|
||||
"""
|
||||
pathname = os.path.abspath(pathname)
|
||||
home = os.environ.get('HOME')
|
||||
if home:
|
||||
home = os.path.abspath(home)
|
||||
if pathname.startswith(home):
|
||||
pathname = os.path.join('~', os.path.relpath(pathname, home))
|
||||
return pathname
|
||||
|
||||
|
||||
def parse_path(pathname):
|
||||
"""
|
||||
Convert a human friendly pathname to an absolute pathname.
|
||||
|
||||
Expands leading tildes using :func:`os.path.expanduser()` and
|
||||
environment variables using :func:`os.path.expandvars()` and makes the
|
||||
resulting pathname absolute using :func:`os.path.abspath()`.
|
||||
|
||||
:param pathname: A human friendly pathname (a string).
|
||||
:returns: An absolute pathname (a string).
|
||||
"""
|
||||
return os.path.abspath(os.path.expanduser(os.path.expandvars(pathname)))
|
||||
|
||||
|
||||
class Timer(object):
|
||||
|
||||
"""
|
||||
Easy to use timer to keep track of long during operations.
|
||||
"""
|
||||
|
||||
def __init__(self, start_time=None, resumable=False):
|
||||
"""
|
||||
Remember the time when the :class:`Timer` was created.
|
||||
|
||||
:param start_time: The start time (a float, defaults to the current time).
|
||||
:param resumable: Create a resumable timer (defaults to :data:`False`).
|
||||
|
||||
When `start_time` is given :class:`Timer` uses :func:`time.time()` as a
|
||||
clock source, otherwise it uses :func:`humanfriendly.compat.monotonic()`.
|
||||
"""
|
||||
if resumable:
|
||||
self.monotonic = True
|
||||
self.resumable = True
|
||||
self.start_time = 0.0
|
||||
self.total_time = 0.0
|
||||
elif start_time:
|
||||
self.monotonic = False
|
||||
self.resumable = False
|
||||
self.start_time = start_time
|
||||
else:
|
||||
self.monotonic = True
|
||||
self.resumable = False
|
||||
self.start_time = monotonic()
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Start or resume counting elapsed time.
|
||||
|
||||
:returns: The :class:`Timer` object.
|
||||
:raises: :exc:`~exceptions.ValueError` when the timer isn't resumable.
|
||||
"""
|
||||
if not self.resumable:
|
||||
raise ValueError("Timer is not resumable!")
|
||||
self.start_time = monotonic()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""
|
||||
Stop counting elapsed time.
|
||||
|
||||
:raises: :exc:`~exceptions.ValueError` when the timer isn't resumable.
|
||||
"""
|
||||
if not self.resumable:
|
||||
raise ValueError("Timer is not resumable!")
|
||||
if self.start_time:
|
||||
self.total_time += monotonic() - self.start_time
|
||||
self.start_time = 0.0
|
||||
|
||||
def sleep(self, seconds):
|
||||
"""
|
||||
Easy to use rate limiting of repeating actions.
|
||||
|
||||
:param seconds: The number of seconds to sleep (an
|
||||
integer or floating point number).
|
||||
|
||||
This method sleeps for the given number of seconds minus the
|
||||
:attr:`elapsed_time`. If the resulting duration is negative
|
||||
:func:`time.sleep()` will still be called, but the argument
|
||||
given to it will be the number 0 (negative numbers cause
|
||||
:func:`time.sleep()` to raise an exception).
|
||||
|
||||
The use case for this is to initialize a :class:`Timer` inside
|
||||
the body of a :keyword:`for` or :keyword:`while` loop and call
|
||||
:func:`Timer.sleep()` at the end of the loop body to rate limit
|
||||
whatever it is that is being done inside the loop body.
|
||||
|
||||
For posterity: Although the implementation of :func:`sleep()` only
|
||||
requires a single line of code I've added it to :mod:`humanfriendly`
|
||||
anyway because now that I've thought about how to tackle this once I
|
||||
never want to have to think about it again :-P (unless I find ways to
|
||||
improve this).
|
||||
"""
|
||||
time.sleep(max(0, seconds - self.elapsed_time))
|
||||
|
||||
@property
|
||||
def elapsed_time(self):
|
||||
"""
|
||||
Get the number of seconds counted so far.
|
||||
"""
|
||||
elapsed_time = 0
|
||||
if self.resumable:
|
||||
elapsed_time += self.total_time
|
||||
if self.start_time:
|
||||
current_time = monotonic() if self.monotonic else time.time()
|
||||
elapsed_time += current_time - self.start_time
|
||||
return elapsed_time
|
||||
|
||||
@property
|
||||
def rounded(self):
|
||||
"""Human readable timespan rounded to seconds (a string)."""
|
||||
return format_timespan(round(self.elapsed_time))
|
||||
|
||||
def __str__(self):
|
||||
"""Show the elapsed time since the :class:`Timer` was created."""
|
||||
return format_timespan(self.elapsed_time)
|
||||
|
||||
|
||||
class InvalidDate(Exception):
|
||||
|
||||
"""
|
||||
Raised when a string cannot be parsed into a date.
|
||||
|
||||
For example:
|
||||
|
||||
>>> from humanfriendly import parse_date
|
||||
>>> parse_date('2013-06-XY')
|
||||
Traceback (most recent call last):
|
||||
File "humanfriendly.py", line 206, in parse_date
|
||||
raise InvalidDate(format(msg, datestring))
|
||||
humanfriendly.InvalidDate: Invalid date! (expected 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' but got: '2013-06-XY')
|
||||
"""
|
||||
|
||||
|
||||
class InvalidSize(Exception):
|
||||
|
||||
"""
|
||||
Raised when a string cannot be parsed into a file size.
|
||||
|
||||
For example:
|
||||
|
||||
>>> from humanfriendly import parse_size
|
||||
>>> parse_size('5 Z')
|
||||
Traceback (most recent call last):
|
||||
File "humanfriendly/__init__.py", line 267, in parse_size
|
||||
raise InvalidSize(format(msg, size, tokens))
|
||||
humanfriendly.InvalidSize: Failed to parse size! (input '5 Z' was tokenized as [5, 'Z'])
|
||||
"""
|
||||
|
||||
|
||||
class InvalidLength(Exception):
|
||||
|
||||
"""
|
||||
Raised when a string cannot be parsed into a length.
|
||||
|
||||
For example:
|
||||
|
||||
>>> from humanfriendly import parse_length
|
||||
>>> parse_length('5 Z')
|
||||
Traceback (most recent call last):
|
||||
File "humanfriendly/__init__.py", line 267, in parse_length
|
||||
raise InvalidLength(format(msg, length, tokens))
|
||||
humanfriendly.InvalidLength: Failed to parse length! (input '5 Z' was tokenized as [5, 'Z'])
|
||||
"""
|
||||
|
||||
|
||||
class InvalidTimespan(Exception):
|
||||
|
||||
"""
|
||||
Raised when a string cannot be parsed into a timespan.
|
||||
|
||||
For example:
|
||||
|
||||
>>> from humanfriendly import parse_timespan
|
||||
>>> parse_timespan('1 age')
|
||||
Traceback (most recent call last):
|
||||
File "humanfriendly/__init__.py", line 419, in parse_timespan
|
||||
raise InvalidTimespan(format(msg, timespan, tokens))
|
||||
humanfriendly.InvalidTimespan: Failed to parse timespan! (input '1 age' was tokenized as [1, 'age'])
|
||||
"""
|
||||
|
||||
|
||||
# Define aliases for backwards compatibility.
|
||||
define_aliases(
|
||||
module_name=__name__,
|
||||
# In humanfriendly 1.23 the format_table() function was added to render a
|
||||
# table using characters like dashes and vertical bars to emulate borders.
|
||||
# Since then support for other tables has been added and the name of
|
||||
# format_table() has changed.
|
||||
format_table='humanfriendly.tables.format_pretty_table',
|
||||
# In humanfriendly 1.30 the following text manipulation functions were
|
||||
# moved out into a separate module to enable their usage in other modules
|
||||
# of the humanfriendly package (without causing circular imports).
|
||||
compact='humanfriendly.text.compact',
|
||||
concatenate='humanfriendly.text.concatenate',
|
||||
dedent='humanfriendly.text.dedent',
|
||||
format='humanfriendly.text.format',
|
||||
is_empty_line='humanfriendly.text.is_empty_line',
|
||||
pluralize='humanfriendly.text.pluralize',
|
||||
tokenize='humanfriendly.text.tokenize',
|
||||
trim_empty_lines='humanfriendly.text.trim_empty_lines',
|
||||
# In humanfriendly 1.38 the prompt_for_choice() function was moved out into a
|
||||
# separate module because several variants of interactive prompts were added.
|
||||
prompt_for_choice='humanfriendly.prompts.prompt_for_choice',
|
||||
# In humanfriendly 8.0 the Spinner class and minimum_spinner_interval
|
||||
# variable were extracted to a new module and the erase_line_code,
|
||||
# hide_cursor_code and show_cursor_code variables were moved.
|
||||
AutomaticSpinner='humanfriendly.terminal.spinners.AutomaticSpinner',
|
||||
Spinner='humanfriendly.terminal.spinners.Spinner',
|
||||
erase_line_code='humanfriendly.terminal.ANSI_ERASE_LINE',
|
||||
hide_cursor_code='humanfriendly.terminal.ANSI_SHOW_CURSOR',
|
||||
minimum_spinner_interval='humanfriendly.terminal.spinners.MINIMUM_INTERVAL',
|
||||
show_cursor_code='humanfriendly.terminal.ANSI_HIDE_CURSOR',
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: April 19, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Simple case insensitive dictionaries.
|
||||
|
||||
The :class:`CaseInsensitiveDict` class is a dictionary whose string keys
|
||||
are case insensitive. It works by automatically coercing string keys to
|
||||
:class:`CaseInsensitiveKey` objects. Keys that are not strings are
|
||||
supported as well, just without case insensitivity.
|
||||
|
||||
At its core this module works by normalizing strings to lowercase before
|
||||
comparing or hashing them. It doesn't support proper case folding nor
|
||||
does it support Unicode normalization, hence the word "simple".
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import collections
|
||||
|
||||
try:
|
||||
# Python >= 3.3.
|
||||
from collections.abc import Iterable, Mapping
|
||||
except ImportError:
|
||||
# Python 2.7.
|
||||
from collections import Iterable, Mapping
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import basestring, unicode
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = ("CaseInsensitiveDict", "CaseInsensitiveKey")
|
||||
|
||||
|
||||
class CaseInsensitiveDict(collections.OrderedDict):
|
||||
|
||||
"""
|
||||
Simple case insensitive dictionary implementation (that remembers insertion order).
|
||||
|
||||
This class works by overriding methods that deal with dictionary keys to
|
||||
coerce string keys to :class:`CaseInsensitiveKey` objects before calling
|
||||
down to the regular dictionary handling methods. While intended to be
|
||||
complete this class has not been extensively tested yet.
|
||||
"""
|
||||
|
||||
def __init__(self, other=None, **kw):
|
||||
"""Initialize a :class:`CaseInsensitiveDict` object."""
|
||||
# Initialize our superclass.
|
||||
super(CaseInsensitiveDict, self).__init__()
|
||||
# Handle the initializer arguments.
|
||||
self.update(other, **kw)
|
||||
|
||||
def coerce_key(self, key):
|
||||
"""
|
||||
Coerce string keys to :class:`CaseInsensitiveKey` objects.
|
||||
|
||||
:param key: The value to coerce (any type).
|
||||
:returns: If `key` is a string then a :class:`CaseInsensitiveKey`
|
||||
object is returned, otherwise the value of `key` is
|
||||
returned unmodified.
|
||||
"""
|
||||
if isinstance(key, basestring):
|
||||
key = CaseInsensitiveKey(key)
|
||||
return key
|
||||
|
||||
@classmethod
|
||||
def fromkeys(cls, iterable, value=None):
|
||||
"""Create a case insensitive dictionary with keys from `iterable` and values set to `value`."""
|
||||
return cls((k, value) for k in iterable)
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""Get the value of an existing item."""
|
||||
return super(CaseInsensitiveDict, self).get(self.coerce_key(key), default)
|
||||
|
||||
def pop(self, key, default=None):
|
||||
"""Remove an item from a case insensitive dictionary."""
|
||||
return super(CaseInsensitiveDict, self).pop(self.coerce_key(key), default)
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
"""Get the value of an existing item or add a new item."""
|
||||
return super(CaseInsensitiveDict, self).setdefault(self.coerce_key(key), default)
|
||||
|
||||
def update(self, other=None, **kw):
|
||||
"""Update a case insensitive dictionary with new items."""
|
||||
if isinstance(other, Mapping):
|
||||
# Copy the items from the given mapping.
|
||||
for key, value in other.items():
|
||||
self[key] = value
|
||||
elif isinstance(other, Iterable):
|
||||
# Copy the items from the given iterable.
|
||||
for key, value in other:
|
||||
self[key] = value
|
||||
elif other is not None:
|
||||
# Complain about unsupported values.
|
||||
msg = "'%s' object is not iterable"
|
||||
type_name = type(value).__name__
|
||||
raise TypeError(msg % type_name)
|
||||
# Copy the keyword arguments (if any).
|
||||
for key, value in kw.items():
|
||||
self[key] = value
|
||||
|
||||
def __contains__(self, key):
|
||||
"""Check if a case insensitive dictionary contains the given key."""
|
||||
return super(CaseInsensitiveDict, self).__contains__(self.coerce_key(key))
|
||||
|
||||
def __delitem__(self, key):
|
||||
"""Delete an item in a case insensitive dictionary."""
|
||||
return super(CaseInsensitiveDict, self).__delitem__(self.coerce_key(key))
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Get the value of an item in a case insensitive dictionary."""
|
||||
return super(CaseInsensitiveDict, self).__getitem__(self.coerce_key(key))
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""Set the value of an item in a case insensitive dictionary."""
|
||||
return super(CaseInsensitiveDict, self).__setitem__(self.coerce_key(key), value)
|
||||
|
||||
|
||||
class CaseInsensitiveKey(unicode):
|
||||
|
||||
"""
|
||||
Simple case insensitive dictionary key implementation.
|
||||
|
||||
The :class:`CaseInsensitiveKey` class provides an intentionally simple
|
||||
implementation of case insensitive strings to be used as dictionary keys.
|
||||
|
||||
If you need features like Unicode normalization or proper case folding
|
||||
please consider using a more advanced implementation like the :pypi:`istr`
|
||||
package instead.
|
||||
"""
|
||||
|
||||
def __new__(cls, value):
|
||||
"""Create a :class:`CaseInsensitiveKey` object."""
|
||||
# Delegate string object creation to our superclass.
|
||||
obj = unicode.__new__(cls, value)
|
||||
# Store the lowercased string and its hash value.
|
||||
normalized = obj.lower()
|
||||
obj._normalized = normalized
|
||||
obj._hash_value = hash(normalized)
|
||||
return obj
|
||||
|
||||
def __hash__(self):
|
||||
"""Get the hash value of the lowercased string."""
|
||||
return self._hash_value
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Compare two strings as lowercase."""
|
||||
if isinstance(other, CaseInsensitiveKey):
|
||||
# Fast path (and the most common case): Comparison with same type.
|
||||
return self._normalized == other._normalized
|
||||
elif isinstance(other, unicode):
|
||||
# Slow path: Comparison with strings that need lowercasing.
|
||||
return self._normalized == other.lower()
|
||||
else:
|
||||
return NotImplemented
|
||||
@@ -0,0 +1,291 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 1, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Usage: humanfriendly [OPTIONS]
|
||||
|
||||
Human friendly input/output (text formatting) on the command
|
||||
line based on the Python package with the same name.
|
||||
|
||||
Supported options:
|
||||
|
||||
-c, --run-command
|
||||
|
||||
Execute an external command (given as the positional arguments) and render
|
||||
a spinner and timer while the command is running. The exit status of the
|
||||
command is propagated.
|
||||
|
||||
--format-table
|
||||
|
||||
Read tabular data from standard input (each line is a row and each
|
||||
whitespace separated field is a column), format the data as a table and
|
||||
print the resulting table to standard output. See also the --delimiter
|
||||
option.
|
||||
|
||||
-d, --delimiter=VALUE
|
||||
|
||||
Change the delimiter used by --format-table to VALUE (a string). By default
|
||||
all whitespace is treated as a delimiter.
|
||||
|
||||
-l, --format-length=LENGTH
|
||||
|
||||
Convert a length count (given as the integer or float LENGTH) into a human
|
||||
readable string and print that string to standard output.
|
||||
|
||||
-n, --format-number=VALUE
|
||||
|
||||
Format a number (given as the integer or floating point number VALUE) with
|
||||
thousands separators and two decimal places (if needed) and print the
|
||||
formatted number to standard output.
|
||||
|
||||
-s, --format-size=BYTES
|
||||
|
||||
Convert a byte count (given as the integer BYTES) into a human readable
|
||||
string and print that string to standard output.
|
||||
|
||||
-b, --binary
|
||||
|
||||
Change the output of -s, --format-size to use binary multiples of bytes
|
||||
(base-2) instead of the default decimal multiples of bytes (base-10).
|
||||
|
||||
-t, --format-timespan=SECONDS
|
||||
|
||||
Convert a number of seconds (given as the floating point number SECONDS)
|
||||
into a human readable timespan and print that string to standard output.
|
||||
|
||||
--parse-length=VALUE
|
||||
|
||||
Parse a human readable length (given as the string VALUE) and print the
|
||||
number of metres to standard output.
|
||||
|
||||
--parse-size=VALUE
|
||||
|
||||
Parse a human readable data size (given as the string VALUE) and print the
|
||||
number of bytes to standard output.
|
||||
|
||||
--demo
|
||||
|
||||
Demonstrate changing the style and color of the terminal font using ANSI
|
||||
escape sequences.
|
||||
|
||||
-h, --help
|
||||
|
||||
Show this message and exit.
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import functools
|
||||
import getopt
|
||||
import pipes
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly import (
|
||||
Timer,
|
||||
format_length,
|
||||
format_number,
|
||||
format_size,
|
||||
format_timespan,
|
||||
parse_length,
|
||||
parse_size,
|
||||
)
|
||||
from humanfriendly.tables import format_pretty_table, format_smart_table
|
||||
from humanfriendly.terminal import (
|
||||
ANSI_COLOR_CODES,
|
||||
ANSI_TEXT_STYLES,
|
||||
HIGHLIGHT_COLOR,
|
||||
ansi_strip,
|
||||
ansi_wrap,
|
||||
enable_ansi_support,
|
||||
find_terminal_size,
|
||||
output,
|
||||
usage,
|
||||
warning,
|
||||
)
|
||||
from humanfriendly.terminal.spinners import Spinner
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'demonstrate_256_colors',
|
||||
'demonstrate_ansi_formatting',
|
||||
'main',
|
||||
'print_formatted_length',
|
||||
'print_formatted_number',
|
||||
'print_formatted_size',
|
||||
'print_formatted_table',
|
||||
'print_formatted_timespan',
|
||||
'print_parsed_length',
|
||||
'print_parsed_size',
|
||||
'run_command',
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Command line interface for the ``humanfriendly`` program."""
|
||||
enable_ansi_support()
|
||||
try:
|
||||
options, arguments = getopt.getopt(sys.argv[1:], 'cd:l:n:s:bt:h', [
|
||||
'run-command', 'format-table', 'delimiter=', 'format-length=',
|
||||
'format-number=', 'format-size=', 'binary', 'format-timespan=',
|
||||
'parse-length=', 'parse-size=', 'demo', 'help',
|
||||
])
|
||||
except Exception as e:
|
||||
warning("Error: %s", e)
|
||||
sys.exit(1)
|
||||
actions = []
|
||||
delimiter = None
|
||||
should_format_table = False
|
||||
binary = any(o in ('-b', '--binary') for o, v in options)
|
||||
for option, value in options:
|
||||
if option in ('-d', '--delimiter'):
|
||||
delimiter = value
|
||||
elif option == '--parse-size':
|
||||
actions.append(functools.partial(print_parsed_size, value))
|
||||
elif option == '--parse-length':
|
||||
actions.append(functools.partial(print_parsed_length, value))
|
||||
elif option in ('-c', '--run-command'):
|
||||
actions.append(functools.partial(run_command, arguments))
|
||||
elif option in ('-l', '--format-length'):
|
||||
actions.append(functools.partial(print_formatted_length, value))
|
||||
elif option in ('-n', '--format-number'):
|
||||
actions.append(functools.partial(print_formatted_number, value))
|
||||
elif option in ('-s', '--format-size'):
|
||||
actions.append(functools.partial(print_formatted_size, value, binary))
|
||||
elif option == '--format-table':
|
||||
should_format_table = True
|
||||
elif option in ('-t', '--format-timespan'):
|
||||
actions.append(functools.partial(print_formatted_timespan, value))
|
||||
elif option == '--demo':
|
||||
actions.append(demonstrate_ansi_formatting)
|
||||
elif option in ('-h', '--help'):
|
||||
usage(__doc__)
|
||||
return
|
||||
if should_format_table:
|
||||
actions.append(functools.partial(print_formatted_table, delimiter))
|
||||
if not actions:
|
||||
usage(__doc__)
|
||||
return
|
||||
for partial in actions:
|
||||
partial()
|
||||
|
||||
|
||||
def run_command(command_line):
|
||||
"""Run an external command and show a spinner while the command is running."""
|
||||
timer = Timer()
|
||||
spinner_label = "Waiting for command: %s" % " ".join(map(pipes.quote, command_line))
|
||||
with Spinner(label=spinner_label, timer=timer) as spinner:
|
||||
process = subprocess.Popen(command_line)
|
||||
while True:
|
||||
spinner.step()
|
||||
spinner.sleep()
|
||||
if process.poll() is not None:
|
||||
break
|
||||
sys.exit(process.returncode)
|
||||
|
||||
|
||||
def print_formatted_length(value):
|
||||
"""Print a human readable length."""
|
||||
if '.' in value:
|
||||
output(format_length(float(value)))
|
||||
else:
|
||||
output(format_length(int(value)))
|
||||
|
||||
|
||||
def print_formatted_number(value):
|
||||
"""Print large numbers in a human readable format."""
|
||||
output(format_number(float(value)))
|
||||
|
||||
|
||||
def print_formatted_size(value, binary):
|
||||
"""Print a human readable size."""
|
||||
output(format_size(int(value), binary=binary))
|
||||
|
||||
|
||||
def print_formatted_table(delimiter):
|
||||
"""Read tabular data from standard input and print a table."""
|
||||
data = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip()
|
||||
data.append(line.split(delimiter))
|
||||
output(format_pretty_table(data))
|
||||
|
||||
|
||||
def print_formatted_timespan(value):
|
||||
"""Print a human readable timespan."""
|
||||
output(format_timespan(float(value)))
|
||||
|
||||
|
||||
def print_parsed_length(value):
|
||||
"""Parse a human readable length and print the number of metres."""
|
||||
output(parse_length(value))
|
||||
|
||||
|
||||
def print_parsed_size(value):
|
||||
"""Parse a human readable data size and print the number of bytes."""
|
||||
output(parse_size(value))
|
||||
|
||||
|
||||
def demonstrate_ansi_formatting():
|
||||
"""Demonstrate the use of ANSI escape sequences."""
|
||||
# First we demonstrate the supported text styles.
|
||||
output('%s', ansi_wrap('Text styles:', bold=True))
|
||||
styles = ['normal', 'bright']
|
||||
styles.extend(ANSI_TEXT_STYLES.keys())
|
||||
for style_name in sorted(styles):
|
||||
options = dict(color=HIGHLIGHT_COLOR)
|
||||
if style_name != 'normal':
|
||||
options[style_name] = True
|
||||
style_label = style_name.replace('_', ' ').capitalize()
|
||||
output(' - %s', ansi_wrap(style_label, **options))
|
||||
# Now we demonstrate named foreground and background colors.
|
||||
for color_type, color_label in (('color', 'Foreground colors'),
|
||||
('background', 'Background colors')):
|
||||
intensities = [
|
||||
('normal', dict()),
|
||||
('bright', dict(bright=True)),
|
||||
]
|
||||
if color_type != 'background':
|
||||
intensities.insert(0, ('faint', dict(faint=True)))
|
||||
output('\n%s' % ansi_wrap('%s:' % color_label, bold=True))
|
||||
output(format_smart_table([
|
||||
[color_name] + [
|
||||
ansi_wrap(
|
||||
'XXXXXX' if color_type != 'background' else (' ' * 6),
|
||||
**dict(list(kw.items()) + [(color_type, color_name)])
|
||||
) for label, kw in intensities
|
||||
] for color_name in sorted(ANSI_COLOR_CODES.keys())
|
||||
], column_names=['Color'] + [
|
||||
label.capitalize() for label, kw in intensities
|
||||
]))
|
||||
# Demonstrate support for 256 colors as well.
|
||||
demonstrate_256_colors(0, 7, 'standard colors')
|
||||
demonstrate_256_colors(8, 15, 'high-intensity colors')
|
||||
demonstrate_256_colors(16, 231, '216 colors')
|
||||
demonstrate_256_colors(232, 255, 'gray scale colors')
|
||||
|
||||
|
||||
def demonstrate_256_colors(i, j, group=None):
|
||||
"""Demonstrate 256 color mode support."""
|
||||
# Generate the label.
|
||||
label = '256 color mode'
|
||||
if group:
|
||||
label += ' (%s)' % group
|
||||
output('\n' + ansi_wrap('%s:' % label, bold=True))
|
||||
# Generate a simple rendering of the colors in the requested range and
|
||||
# check if it will fit on a single line (given the terminal's width).
|
||||
single_line = ''.join(' ' + ansi_wrap(str(n), color=n) for n in range(i, j + 1))
|
||||
lines, columns = find_terminal_size()
|
||||
if columns >= len(ansi_strip(single_line)):
|
||||
output(single_line)
|
||||
else:
|
||||
# Generate a more complex rendering of the colors that will nicely wrap
|
||||
# over multiple lines without using too many lines.
|
||||
width = len(str(j)) + 1
|
||||
colors_per_line = int(columns / width)
|
||||
colors = [ansi_wrap(str(n).rjust(width), color=n) for n in range(i, j + 1)]
|
||||
blocks = [colors[n:n + colors_per_line] for n in range(0, len(colors), colors_per_line)]
|
||||
output('\n'.join(''.join(b) for b in blocks))
|
||||
@@ -0,0 +1,146 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: September 17, 2021
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Compatibility with Python 2 and 3.
|
||||
|
||||
This module exposes aliases and functions that make it easier to write Python
|
||||
code that is compatible with Python 2 and Python 3.
|
||||
|
||||
.. data:: basestring
|
||||
|
||||
Alias for :func:`python2:basestring` (in Python 2) or :class:`python3:str`
|
||||
(in Python 3). See also :func:`is_string()`.
|
||||
|
||||
.. data:: HTMLParser
|
||||
|
||||
Alias for :class:`python2:HTMLParser.HTMLParser` (in Python 2) or
|
||||
:class:`python3:html.parser.HTMLParser` (in Python 3).
|
||||
|
||||
.. data:: interactive_prompt
|
||||
|
||||
Alias for :func:`python2:raw_input()` (in Python 2) or
|
||||
:func:`python3:input()` (in Python 3).
|
||||
|
||||
.. data:: StringIO
|
||||
|
||||
Alias for :class:`python2:StringIO.StringIO` (in Python 2) or
|
||||
:class:`python3:io.StringIO` (in Python 3).
|
||||
|
||||
.. data:: unicode
|
||||
|
||||
Alias for :func:`python2:unicode` (in Python 2) or :class:`python3:str` (in
|
||||
Python 3). See also :func:`coerce_string()`.
|
||||
|
||||
.. data:: monotonic
|
||||
|
||||
Alias for :func:`python3:time.monotonic()` (in Python 3.3 and higher) or
|
||||
`monotonic.monotonic()` (a `conditional dependency
|
||||
<https://pypi.org/project/monotonic/>`_ on older Python versions).
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
'HTMLParser',
|
||||
'StringIO',
|
||||
'basestring',
|
||||
'coerce_string',
|
||||
'interactive_prompt',
|
||||
'is_string',
|
||||
'is_unicode',
|
||||
'monotonic',
|
||||
'name2codepoint',
|
||||
'on_macos',
|
||||
'on_windows',
|
||||
'unichr',
|
||||
'unicode',
|
||||
'which',
|
||||
)
|
||||
|
||||
# Standard library modules.
|
||||
import sys
|
||||
|
||||
# Differences between Python 2 and 3.
|
||||
try:
|
||||
# Python 2.
|
||||
unicode = unicode
|
||||
unichr = unichr
|
||||
basestring = basestring
|
||||
interactive_prompt = raw_input
|
||||
from distutils.spawn import find_executable as which
|
||||
from HTMLParser import HTMLParser
|
||||
from StringIO import StringIO
|
||||
from htmlentitydefs import name2codepoint
|
||||
except (ImportError, NameError):
|
||||
# Python 3.
|
||||
unicode = str
|
||||
unichr = chr
|
||||
basestring = str
|
||||
interactive_prompt = input
|
||||
from shutil import which
|
||||
from html.parser import HTMLParser
|
||||
from io import StringIO
|
||||
from html.entities import name2codepoint
|
||||
|
||||
try:
|
||||
# Python 3.3 and higher.
|
||||
from time import monotonic
|
||||
except ImportError:
|
||||
# A replacement for older Python versions:
|
||||
# https://pypi.org/project/monotonic/
|
||||
try:
|
||||
from monotonic import monotonic
|
||||
except (ImportError, RuntimeError):
|
||||
# We fall back to the old behavior of using time.time() instead of
|
||||
# failing when {time,monotonic}.monotonic() are both missing.
|
||||
from time import time as monotonic
|
||||
|
||||
|
||||
def coerce_string(value):
|
||||
"""
|
||||
Coerce any value to a Unicode string (:func:`python2:unicode` in Python 2 and :class:`python3:str` in Python 3).
|
||||
|
||||
:param value: The value to coerce.
|
||||
:returns: The value coerced to a Unicode string.
|
||||
"""
|
||||
return value if is_string(value) else unicode(value)
|
||||
|
||||
|
||||
def is_string(value):
|
||||
"""
|
||||
Check if a value is a :func:`python2:basestring` (in Python 2) or :class:`python3:str` (in Python 3) object.
|
||||
|
||||
:param value: The value to check.
|
||||
:returns: :data:`True` if the value is a string, :data:`False` otherwise.
|
||||
"""
|
||||
return isinstance(value, basestring)
|
||||
|
||||
|
||||
def is_unicode(value):
|
||||
"""
|
||||
Check if a value is a :func:`python2:unicode` (in Python 2) or :class:`python2:str` (in Python 3) object.
|
||||
|
||||
:param value: The value to check.
|
||||
:returns: :data:`True` if the value is a Unicode string, :data:`False` otherwise.
|
||||
"""
|
||||
return isinstance(value, unicode)
|
||||
|
||||
|
||||
def on_macos():
|
||||
"""
|
||||
Check if we're running on Apple MacOS.
|
||||
|
||||
:returns: :data:`True` if running MacOS, :data:`False` otherwise.
|
||||
"""
|
||||
return sys.platform.startswith('darwin')
|
||||
|
||||
|
||||
def on_windows():
|
||||
"""
|
||||
Check if we're running on the Microsoft Windows OS.
|
||||
|
||||
:returns: :data:`True` if running Windows, :data:`False` otherwise.
|
||||
"""
|
||||
return sys.platform.startswith('win')
|
||||
@@ -0,0 +1,43 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 2, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""Simple function decorators to make Python programming easier."""
|
||||
|
||||
# Standard library modules.
|
||||
import functools
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = ('RESULTS_ATTRIBUTE', 'cached')
|
||||
|
||||
RESULTS_ATTRIBUTE = 'cached_results'
|
||||
"""The name of the property used to cache the return values of functions (a string)."""
|
||||
|
||||
|
||||
def cached(function):
|
||||
"""
|
||||
Rudimentary caching decorator for functions.
|
||||
|
||||
:param function: The function whose return value should be cached.
|
||||
:returns: The decorated function.
|
||||
|
||||
The given function will only be called once, the first time the wrapper
|
||||
function is called. The return value is cached by the wrapper function as
|
||||
an attribute of the given function and returned on each subsequent call.
|
||||
|
||||
.. note:: Currently no function arguments are supported because only a
|
||||
single return value can be cached. Accepting any function
|
||||
arguments at all would imply that the cache is parametrized on
|
||||
function arguments, which is not currently the case.
|
||||
"""
|
||||
@functools.wraps(function)
|
||||
def wrapper():
|
||||
try:
|
||||
return getattr(wrapper, RESULTS_ATTRIBUTE)
|
||||
except AttributeError:
|
||||
result = function()
|
||||
setattr(wrapper, RESULTS_ATTRIBUTE, result)
|
||||
return result
|
||||
return wrapper
|
||||
@@ -0,0 +1,251 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 2, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Support for deprecation warnings when importing names from old locations.
|
||||
|
||||
When software evolves, things tend to move around. This is usually detrimental
|
||||
to backwards compatibility (in Python this primarily manifests itself as
|
||||
:exc:`~exceptions.ImportError` exceptions).
|
||||
|
||||
While backwards compatibility is very important, it should not get in the way
|
||||
of progress. It would be great to have the agility to move things around
|
||||
without breaking backwards compatibility.
|
||||
|
||||
This is where the :mod:`humanfriendly.deprecation` module comes in: It enables
|
||||
the definition of backwards compatible aliases that emit a deprecation warning
|
||||
when they are accessed.
|
||||
|
||||
The way it works is that it wraps the original module in an :class:`DeprecationProxy`
|
||||
object that defines a :func:`~DeprecationProxy.__getattr__()` special method to
|
||||
override attribute access of the module.
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import collections
|
||||
import functools
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
import warnings
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.text import format
|
||||
|
||||
# Registry of known aliases (used by humanfriendly.sphinx).
|
||||
REGISTRY = collections.defaultdict(dict)
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = ("DeprecationProxy", "define_aliases", "deprecated_args", "get_aliases", "is_method")
|
||||
|
||||
|
||||
def define_aliases(module_name, **aliases):
|
||||
"""
|
||||
Update a module with backwards compatible aliases.
|
||||
|
||||
:param module_name: The ``__name__`` of the module (a string).
|
||||
:param aliases: Each keyword argument defines an alias. The values
|
||||
are expected to be "dotted paths" (strings).
|
||||
|
||||
The behavior of this function depends on whether the Sphinx documentation
|
||||
generator is active, because the use of :class:`DeprecationProxy` to shadow the
|
||||
real module in :data:`sys.modules` has the unintended side effect of
|
||||
breaking autodoc support for ``:data:`` members (module variables).
|
||||
|
||||
To avoid breaking Sphinx the proxy object is omitted and instead the
|
||||
aliased names are injected into the original module namespace, to make sure
|
||||
that imports can be satisfied when the documentation is being rendered.
|
||||
|
||||
If you run into cyclic dependencies caused by :func:`define_aliases()` when
|
||||
running Sphinx, you can try moving the call to :func:`define_aliases()` to
|
||||
the bottom of the Python module you're working on.
|
||||
"""
|
||||
module = sys.modules[module_name]
|
||||
proxy = DeprecationProxy(module, aliases)
|
||||
# Populate the registry of aliases.
|
||||
for name, target in aliases.items():
|
||||
REGISTRY[module.__name__][name] = target
|
||||
# Avoid confusing Sphinx.
|
||||
if "sphinx" in sys.modules:
|
||||
for name, target in aliases.items():
|
||||
setattr(module, name, proxy.resolve(target))
|
||||
else:
|
||||
# Install a proxy object to raise DeprecationWarning.
|
||||
sys.modules[module_name] = proxy
|
||||
|
||||
|
||||
def get_aliases(module_name):
|
||||
"""
|
||||
Get the aliases defined by a module.
|
||||
|
||||
:param module_name: The ``__name__`` of the module (a string).
|
||||
:returns: A dictionary with string keys and values:
|
||||
|
||||
1. Each key gives the name of an alias
|
||||
created for backwards compatibility.
|
||||
|
||||
2. Each value gives the dotted path of
|
||||
the proper location of the identifier.
|
||||
|
||||
An empty dictionary is returned for modules that
|
||||
don't define any backwards compatible aliases.
|
||||
"""
|
||||
return REGISTRY.get(module_name, {})
|
||||
|
||||
|
||||
def deprecated_args(*names):
|
||||
"""
|
||||
Deprecate positional arguments without dropping backwards compatibility.
|
||||
|
||||
:param names:
|
||||
|
||||
The positional arguments to :func:`deprecated_args()` give the names of
|
||||
the positional arguments that the to-be-decorated function should warn
|
||||
about being deprecated and translate to keyword arguments.
|
||||
|
||||
:returns: A decorator function specialized to `names`.
|
||||
|
||||
The :func:`deprecated_args()` decorator function was created to make it
|
||||
easy to switch from positional arguments to keyword arguments [#]_ while
|
||||
preserving backwards compatibility [#]_ and informing call sites
|
||||
about the change.
|
||||
|
||||
.. [#] Increased flexibility is the main reason why I find myself switching
|
||||
from positional arguments to (optional) keyword arguments as my code
|
||||
evolves to support more use cases.
|
||||
|
||||
.. [#] In my experience positional argument order implicitly becomes part
|
||||
of API compatibility whether intended or not. While this makes sense
|
||||
for functions that over time adopt more and more optional arguments,
|
||||
at a certain point it becomes an inconvenience to code maintenance.
|
||||
|
||||
Here's an example of how to use the decorator::
|
||||
|
||||
@deprecated_args('text')
|
||||
def report_choice(**options):
|
||||
print(options['text'])
|
||||
|
||||
When the decorated function is called with positional arguments
|
||||
a deprecation warning is given::
|
||||
|
||||
>>> report_choice('this will give a deprecation warning')
|
||||
DeprecationWarning: report_choice has deprecated positional arguments, please switch to keyword arguments
|
||||
this will give a deprecation warning
|
||||
|
||||
But when the function is called with keyword arguments no deprecation
|
||||
warning is emitted::
|
||||
|
||||
>>> report_choice(text='this will not give a deprecation warning')
|
||||
this will not give a deprecation warning
|
||||
"""
|
||||
def decorator(function):
|
||||
def translate(args, kw):
|
||||
# Raise TypeError when too many positional arguments are passed to the decorated function.
|
||||
if len(args) > len(names):
|
||||
raise TypeError(
|
||||
format(
|
||||
"{name} expected at most {limit} arguments, got {count}",
|
||||
name=function.__name__,
|
||||
limit=len(names),
|
||||
count=len(args),
|
||||
)
|
||||
)
|
||||
# Emit a deprecation warning when positional arguments are used.
|
||||
if args:
|
||||
warnings.warn(
|
||||
format(
|
||||
"{name} has deprecated positional arguments, please switch to keyword arguments",
|
||||
name=function.__name__,
|
||||
),
|
||||
category=DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
# Translate positional arguments to keyword arguments.
|
||||
for name, value in zip(names, args):
|
||||
kw[name] = value
|
||||
if is_method(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(*args, **kw):
|
||||
"""Wrapper for instance methods."""
|
||||
args = list(args)
|
||||
self = args.pop(0)
|
||||
translate(args, kw)
|
||||
return function(self, **kw)
|
||||
else:
|
||||
@functools.wraps(function)
|
||||
def wrapper(*args, **kw):
|
||||
"""Wrapper for module level functions."""
|
||||
translate(args, kw)
|
||||
return function(**kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def is_method(function):
|
||||
"""Check if the expected usage of the given function is as an instance method."""
|
||||
try:
|
||||
# Python 3.3 and newer.
|
||||
signature = inspect.signature(function)
|
||||
return "self" in signature.parameters
|
||||
except AttributeError:
|
||||
# Python 3.2 and older.
|
||||
metadata = inspect.getargspec(function)
|
||||
return "self" in metadata.args
|
||||
|
||||
|
||||
class DeprecationProxy(types.ModuleType):
|
||||
|
||||
"""Emit deprecation warnings for imports that should be updated."""
|
||||
|
||||
def __init__(self, module, aliases):
|
||||
"""
|
||||
Initialize an :class:`DeprecationProxy` object.
|
||||
|
||||
:param module: The original module object.
|
||||
:param aliases: A dictionary of aliases.
|
||||
"""
|
||||
# Initialize our superclass.
|
||||
super(DeprecationProxy, self).__init__(name=module.__name__)
|
||||
# Store initializer arguments.
|
||||
self.module = module
|
||||
self.aliases = aliases
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""
|
||||
Override module attribute lookup.
|
||||
|
||||
:param name: The name to look up (a string).
|
||||
:returns: The attribute value.
|
||||
"""
|
||||
# Check if the given name is an alias.
|
||||
target = self.aliases.get(name)
|
||||
if target is not None:
|
||||
# Emit the deprecation warning.
|
||||
warnings.warn(
|
||||
format("%s.%s was moved to %s, please update your imports", self.module.__name__, name, target),
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# Resolve the dotted path.
|
||||
return self.resolve(target)
|
||||
# Look up the name in the original module namespace.
|
||||
value = getattr(self.module, name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
# Fall back to the default behavior.
|
||||
raise AttributeError(format("module '%s' has no attribute '%s'", self.module.__name__, name))
|
||||
|
||||
def resolve(self, target):
|
||||
"""
|
||||
Look up the target of an alias.
|
||||
|
||||
:param target: The fully qualified dotted path (a string).
|
||||
:returns: The value of the given target.
|
||||
"""
|
||||
module_name, _, member = target.rpartition(".")
|
||||
module = importlib.import_module(module_name)
|
||||
return getattr(module, member)
|
||||
@@ -0,0 +1,376 @@
|
||||
# vim: fileencoding=utf-8
|
||||
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: February 9, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Interactive terminal prompts.
|
||||
|
||||
The :mod:`~humanfriendly.prompts` module enables interaction with the user
|
||||
(operator) by asking for confirmation (:func:`prompt_for_confirmation()`) and
|
||||
asking to choose from a list of options (:func:`prompt_for_choice()`). It works
|
||||
by rendering interactive prompts on the terminal.
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import interactive_prompt
|
||||
from humanfriendly.terminal import (
|
||||
HIGHLIGHT_COLOR,
|
||||
ansi_strip,
|
||||
ansi_wrap,
|
||||
connected_to_terminal,
|
||||
terminal_supports_colors,
|
||||
warning,
|
||||
)
|
||||
from humanfriendly.text import format, concatenate
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'MAX_ATTEMPTS',
|
||||
'TooManyInvalidReplies',
|
||||
'logger',
|
||||
'prepare_friendly_prompts',
|
||||
'prepare_prompt_text',
|
||||
'prompt_for_choice',
|
||||
'prompt_for_confirmation',
|
||||
'prompt_for_input',
|
||||
'retry_limit',
|
||||
)
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
"""The number of times an interactive prompt is shown on invalid input (an integer)."""
|
||||
|
||||
# Initialize a logger for this module.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def prompt_for_confirmation(question, default=None, padding=True):
|
||||
"""
|
||||
Prompt the user for confirmation.
|
||||
|
||||
:param question: The text that explains what the user is confirming (a string).
|
||||
:param default: The default value (a boolean) or :data:`None`.
|
||||
:param padding: Refer to the documentation of :func:`prompt_for_input()`.
|
||||
:returns: - If the user enters 'yes' or 'y' then :data:`True` is returned.
|
||||
- If the user enters 'no' or 'n' then :data:`False` is returned.
|
||||
- If the user doesn't enter any text or standard input is not
|
||||
connected to a terminal (which makes it impossible to prompt
|
||||
the user) the value of the keyword argument ``default`` is
|
||||
returned (if that value is not :data:`None`).
|
||||
:raises: - Any exceptions raised by :func:`retry_limit()`.
|
||||
- Any exceptions raised by :func:`prompt_for_input()`.
|
||||
|
||||
When `default` is :data:`False` and the user doesn't enter any text an
|
||||
error message is printed and the prompt is repeated:
|
||||
|
||||
>>> prompt_for_confirmation("Are you sure?")
|
||||
<BLANKLINE>
|
||||
Are you sure? [y/n]
|
||||
<BLANKLINE>
|
||||
Error: Please enter 'yes' or 'no' (there's no default choice).
|
||||
<BLANKLINE>
|
||||
Are you sure? [y/n]
|
||||
|
||||
The same thing happens when the user enters text that isn't recognized:
|
||||
|
||||
>>> prompt_for_confirmation("Are you sure?")
|
||||
<BLANKLINE>
|
||||
Are you sure? [y/n] about what?
|
||||
<BLANKLINE>
|
||||
Error: Please enter 'yes' or 'no' (the text 'about what?' is not recognized).
|
||||
<BLANKLINE>
|
||||
Are you sure? [y/n]
|
||||
"""
|
||||
# Generate the text for the prompt.
|
||||
prompt_text = prepare_prompt_text(question, bold=True)
|
||||
# Append the valid replies (and default reply) to the prompt text.
|
||||
hint = "[Y/n]" if default else "[y/N]" if default is not None else "[y/n]"
|
||||
prompt_text += " %s " % prepare_prompt_text(hint, color=HIGHLIGHT_COLOR)
|
||||
# Loop until a valid response is given.
|
||||
logger.debug("Requesting interactive confirmation from terminal: %r", ansi_strip(prompt_text).rstrip())
|
||||
for attempt in retry_limit():
|
||||
reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
|
||||
if reply.lower() in ('y', 'yes'):
|
||||
logger.debug("Confirmation granted by reply (%r).", reply)
|
||||
return True
|
||||
elif reply.lower() in ('n', 'no'):
|
||||
logger.debug("Confirmation denied by reply (%r).", reply)
|
||||
return False
|
||||
elif (not reply) and default is not None:
|
||||
logger.debug("Default choice selected by empty reply (%r).",
|
||||
"granted" if default else "denied")
|
||||
return default
|
||||
else:
|
||||
details = ("the text '%s' is not recognized" % reply
|
||||
if reply else "there's no default choice")
|
||||
logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
|
||||
"invalid" if reply else "empty", details,
|
||||
attempt, MAX_ATTEMPTS)
|
||||
warning("{indent}Error: Please enter 'yes' or 'no' ({details}).",
|
||||
indent=' ' if padding else '', details=details)
|
||||
|
||||
|
||||
def prompt_for_choice(choices, default=None, padding=True):
|
||||
"""
|
||||
Prompt the user to select a choice from a group of options.
|
||||
|
||||
:param choices: A sequence of strings with available options.
|
||||
:param default: The default choice if the user simply presses Enter
|
||||
(expected to be a string, defaults to :data:`None`).
|
||||
:param padding: Refer to the documentation of
|
||||
:func:`~humanfriendly.prompts.prompt_for_input()`.
|
||||
:returns: The string corresponding to the user's choice.
|
||||
:raises: - :exc:`~exceptions.ValueError` if `choices` is an empty sequence.
|
||||
- Any exceptions raised by
|
||||
:func:`~humanfriendly.prompts.retry_limit()`.
|
||||
- Any exceptions raised by
|
||||
:func:`~humanfriendly.prompts.prompt_for_input()`.
|
||||
|
||||
When no options are given an exception is raised:
|
||||
|
||||
>>> prompt_for_choice([])
|
||||
Traceback (most recent call last):
|
||||
File "humanfriendly/prompts.py", line 148, in prompt_for_choice
|
||||
raise ValueError("Can't prompt for choice without any options!")
|
||||
ValueError: Can't prompt for choice without any options!
|
||||
|
||||
If a single option is given the user isn't prompted:
|
||||
|
||||
>>> prompt_for_choice(['only one choice'])
|
||||
'only one choice'
|
||||
|
||||
Here's what the actual prompt looks like by default:
|
||||
|
||||
>>> prompt_for_choice(['first option', 'second option'])
|
||||
<BLANKLINE>
|
||||
1. first option
|
||||
2. second option
|
||||
<BLANKLINE>
|
||||
Enter your choice as a number or unique substring (Control-C aborts): second
|
||||
<BLANKLINE>
|
||||
'second option'
|
||||
|
||||
If you don't like the whitespace (empty lines and indentation):
|
||||
|
||||
>>> prompt_for_choice(['first option', 'second option'], padding=False)
|
||||
1. first option
|
||||
2. second option
|
||||
Enter your choice as a number or unique substring (Control-C aborts): first
|
||||
'first option'
|
||||
"""
|
||||
indent = ' ' if padding else ''
|
||||
# Make sure we can use 'choices' more than once (i.e. not a generator).
|
||||
choices = list(choices)
|
||||
if len(choices) == 1:
|
||||
# If there's only one option there's no point in prompting the user.
|
||||
logger.debug("Skipping interactive prompt because there's only option (%r).", choices[0])
|
||||
return choices[0]
|
||||
elif not choices:
|
||||
# We can't render a choice prompt without any options.
|
||||
raise ValueError("Can't prompt for choice without any options!")
|
||||
# Generate the prompt text.
|
||||
prompt_text = ('\n\n' if padding else '\n').join([
|
||||
# Present the available choices in a user friendly way.
|
||||
"\n".join([
|
||||
(u" %i. %s" % (i, choice)) + (" (default choice)" if choice == default else "")
|
||||
for i, choice in enumerate(choices, start=1)
|
||||
]),
|
||||
# Instructions for the user.
|
||||
"Enter your choice as a number or unique substring (Control-C aborts): ",
|
||||
])
|
||||
prompt_text = prepare_prompt_text(prompt_text, bold=True)
|
||||
# Loop until a valid choice is made.
|
||||
logger.debug("Requesting interactive choice on terminal (options are %s) ..",
|
||||
concatenate(map(repr, choices)))
|
||||
for attempt in retry_limit():
|
||||
reply = prompt_for_input(prompt_text, '', padding=padding, strip=True)
|
||||
if not reply and default is not None:
|
||||
logger.debug("Default choice selected by empty reply (%r).", default)
|
||||
return default
|
||||
elif reply.isdigit():
|
||||
index = int(reply) - 1
|
||||
if 0 <= index < len(choices):
|
||||
logger.debug("Option (%r) selected by numeric reply (%s).", choices[index], reply)
|
||||
return choices[index]
|
||||
# Check for substring matches.
|
||||
matches = []
|
||||
for choice in choices:
|
||||
lower_reply = reply.lower()
|
||||
lower_choice = choice.lower()
|
||||
if lower_reply == lower_choice:
|
||||
# If we have an 'exact' match we return it immediately.
|
||||
logger.debug("Option (%r) selected by reply (exact match).", choice)
|
||||
return choice
|
||||
elif lower_reply in lower_choice and len(lower_reply) > 0:
|
||||
# Otherwise we gather substring matches.
|
||||
matches.append(choice)
|
||||
if len(matches) == 1:
|
||||
# If a single choice was matched we return it.
|
||||
logger.debug("Option (%r) selected by reply (substring match on %r).", matches[0], reply)
|
||||
return matches[0]
|
||||
else:
|
||||
# Give the user a hint about what went wrong.
|
||||
if matches:
|
||||
details = format("text '%s' matches more than one choice: %s", reply, concatenate(matches))
|
||||
elif reply.isdigit():
|
||||
details = format("number %i is not a valid choice", int(reply))
|
||||
elif reply and not reply.isspace():
|
||||
details = format("text '%s' doesn't match any choices", reply)
|
||||
else:
|
||||
details = "there's no default choice"
|
||||
logger.debug("Got %s reply (%s), retrying (%i/%i) ..",
|
||||
"invalid" if reply else "empty", details,
|
||||
attempt, MAX_ATTEMPTS)
|
||||
warning("%sError: Invalid input (%s).", indent, details)
|
||||
|
||||
|
||||
def prompt_for_input(question, default=None, padding=True, strip=True):
|
||||
"""
|
||||
Prompt the user for input (free form text).
|
||||
|
||||
:param question: An explanation of what is expected from the user (a string).
|
||||
:param default: The return value if the user doesn't enter any text or
|
||||
standard input is not connected to a terminal (which
|
||||
makes it impossible to prompt the user).
|
||||
:param padding: Render empty lines before and after the prompt to make it
|
||||
stand out from the surrounding text? (a boolean, defaults
|
||||
to :data:`True`)
|
||||
:param strip: Strip leading/trailing whitespace from the user's reply?
|
||||
:returns: The text entered by the user (a string) or the value of the
|
||||
`default` argument.
|
||||
:raises: - :exc:`~exceptions.KeyboardInterrupt` when the program is
|
||||
interrupted_ while the prompt is active, for example
|
||||
because the user presses Control-C_.
|
||||
- :exc:`~exceptions.EOFError` when reading from `standard input`_
|
||||
fails, for example because the user presses Control-D_ or
|
||||
because the standard input stream is redirected (only if
|
||||
`default` is :data:`None`).
|
||||
|
||||
.. _Control-C: https://en.wikipedia.org/wiki/Control-C#In_command-line_environments
|
||||
.. _Control-D: https://en.wikipedia.org/wiki/End-of-transmission_character#Meaning_in_Unix
|
||||
.. _interrupted: https://en.wikipedia.org/wiki/Unix_signal#SIGINT
|
||||
.. _standard input: https://en.wikipedia.org/wiki/Standard_streams#Standard_input_.28stdin.29
|
||||
"""
|
||||
prepare_friendly_prompts()
|
||||
reply = None
|
||||
try:
|
||||
# Prefix an empty line to the text and indent by one space?
|
||||
if padding:
|
||||
question = '\n' + question
|
||||
question = question.replace('\n', '\n ')
|
||||
# Render the prompt and wait for the user's reply.
|
||||
try:
|
||||
reply = interactive_prompt(question)
|
||||
finally:
|
||||
if reply is None:
|
||||
# If the user terminated the prompt using Control-C or
|
||||
# Control-D instead of pressing Enter no newline will be
|
||||
# rendered after the prompt's text. The result looks kind of
|
||||
# weird:
|
||||
#
|
||||
# $ python -c 'print(raw_input("Are you sure? "))'
|
||||
# Are you sure? ^CTraceback (most recent call last):
|
||||
# File "<string>", line 1, in <module>
|
||||
# KeyboardInterrupt
|
||||
#
|
||||
# We can avoid this by emitting a newline ourselves if an
|
||||
# exception was raised (signaled by `reply' being None).
|
||||
sys.stderr.write('\n')
|
||||
if padding:
|
||||
# If the caller requested (didn't opt out of) `padding' then we'll
|
||||
# emit a newline regardless of whether an exception is being
|
||||
# handled. This helps to make interactive prompts `stand out' from
|
||||
# a surrounding `wall of text' on the terminal.
|
||||
sys.stderr.write('\n')
|
||||
except BaseException as e:
|
||||
if isinstance(e, EOFError) and default is not None:
|
||||
# If standard input isn't connected to an interactive terminal
|
||||
# but the caller provided a default we'll return that.
|
||||
logger.debug("Got EOF from terminal, returning default value (%r) ..", default)
|
||||
return default
|
||||
else:
|
||||
# Otherwise we log that the prompt was interrupted but propagate
|
||||
# the exception to the caller.
|
||||
logger.warning("Interactive prompt was interrupted by exception!", exc_info=True)
|
||||
raise
|
||||
if default is not None and not reply:
|
||||
# If the reply is empty and `default' is None we don't want to return
|
||||
# None because it's nicer for callers to be able to assume that the
|
||||
# return value is always a string.
|
||||
return default
|
||||
else:
|
||||
return reply.strip()
|
||||
|
||||
|
||||
def prepare_prompt_text(prompt_text, **options):
|
||||
"""
|
||||
Wrap a text to be rendered as an interactive prompt in ANSI escape sequences.
|
||||
|
||||
:param prompt_text: The text to render on the prompt (a string).
|
||||
:param options: Any keyword arguments are passed on to :func:`.ansi_wrap()`.
|
||||
:returns: The resulting prompt text (a string).
|
||||
|
||||
ANSI escape sequences are only used when the standard output stream is
|
||||
connected to a terminal. When the standard input stream is connected to a
|
||||
terminal any escape sequences are wrapped in "readline hints".
|
||||
"""
|
||||
return (ansi_wrap(prompt_text, readline_hints=connected_to_terminal(sys.stdin), **options)
|
||||
if terminal_supports_colors(sys.stdout)
|
||||
else prompt_text)
|
||||
|
||||
|
||||
def prepare_friendly_prompts():
|
||||
u"""
|
||||
Make interactive prompts more user friendly.
|
||||
|
||||
The prompts presented by :func:`python2:raw_input()` (in Python 2) and
|
||||
:func:`python3:input()` (in Python 3) are not very user friendly by
|
||||
default, for example the cursor keys (:kbd:`←`, :kbd:`↑`, :kbd:`→` and
|
||||
:kbd:`↓`) and the :kbd:`Home` and :kbd:`End` keys enter characters instead
|
||||
of performing the action you would expect them to. By simply importing the
|
||||
:mod:`readline` module these prompts become much friendlier (as mentioned
|
||||
in the Python standard library documentation).
|
||||
|
||||
This function is called by the other functions in this module to enable
|
||||
user friendly prompts.
|
||||
"""
|
||||
try:
|
||||
import readline # NOQA
|
||||
except ImportError:
|
||||
# might not be available on Windows if pyreadline isn't installed
|
||||
pass
|
||||
|
||||
|
||||
def retry_limit(limit=MAX_ATTEMPTS):
|
||||
"""
|
||||
Allow the user to provide valid input up to `limit` times.
|
||||
|
||||
:param limit: The maximum number of attempts (a number,
|
||||
defaults to :data:`MAX_ATTEMPTS`).
|
||||
:returns: A generator of numbers starting from one.
|
||||
:raises: :exc:`TooManyInvalidReplies` when an interactive prompt
|
||||
receives repeated invalid input (:data:`MAX_ATTEMPTS`).
|
||||
|
||||
This function returns a generator for interactive prompts that want to
|
||||
repeat on invalid input without getting stuck in infinite loops.
|
||||
"""
|
||||
for i in range(limit):
|
||||
yield i + 1
|
||||
msg = "Received too many invalid replies on interactive prompt, giving up! (tried %i times)"
|
||||
formatted_msg = msg % limit
|
||||
# Make sure the event is logged.
|
||||
logger.warning(formatted_msg)
|
||||
# Force the caller to decide what to do now.
|
||||
raise TooManyInvalidReplies(formatted_msg)
|
||||
|
||||
|
||||
class TooManyInvalidReplies(Exception):
|
||||
|
||||
"""Raised by interactive prompts when they've received too many invalid inputs."""
|
||||
@@ -0,0 +1,315 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: June 11, 2021
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Customizations for and integration with the Sphinx_ documentation generator.
|
||||
|
||||
The :mod:`humanfriendly.sphinx` module uses the `Sphinx extension API`_ to
|
||||
customize the process of generating Sphinx based Python documentation. To
|
||||
explore the functionality this module offers its best to start reading
|
||||
from the :func:`setup()` function.
|
||||
|
||||
.. _Sphinx: http://www.sphinx-doc.org/
|
||||
.. _Sphinx extension API: http://sphinx-doc.org/extdev/appapi.html
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import logging
|
||||
import types
|
||||
|
||||
# External dependencies (if Sphinx is installed docutils will be installed).
|
||||
import docutils.nodes
|
||||
import docutils.utils
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.deprecation import get_aliases
|
||||
from humanfriendly.text import compact, dedent, format
|
||||
from humanfriendly.usage import USAGE_MARKER, render_usage
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
"deprecation_note_callback",
|
||||
"enable_deprecation_notes",
|
||||
"enable_man_role",
|
||||
"enable_pypi_role",
|
||||
"enable_special_methods",
|
||||
"enable_usage_formatting",
|
||||
"logger",
|
||||
"man_role",
|
||||
"pypi_role",
|
||||
"setup",
|
||||
"special_methods_callback",
|
||||
"usage_message_callback",
|
||||
)
|
||||
|
||||
# Initialize a logger for this module.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def deprecation_note_callback(app, what, name, obj, options, lines):
|
||||
"""
|
||||
Automatically document aliases defined using :func:`~humanfriendly.deprecation.define_aliases()`.
|
||||
|
||||
Refer to :func:`enable_deprecation_notes()` to enable the use of this
|
||||
function (you probably don't want to call :func:`deprecation_note_callback()`
|
||||
directly).
|
||||
|
||||
This function implements a callback for ``autodoc-process-docstring`` that
|
||||
reformats module docstrings to append an overview of aliases defined by the
|
||||
module.
|
||||
|
||||
The parameters expected by this function are those defined for Sphinx event
|
||||
callback functions (i.e. I'm not going to document them here :-).
|
||||
"""
|
||||
if isinstance(obj, types.ModuleType) and lines:
|
||||
aliases = get_aliases(obj.__name__)
|
||||
if aliases:
|
||||
# Convert the existing docstring to a string and remove leading
|
||||
# indentation from that string, otherwise our generated content
|
||||
# would have to match the existing indentation in order not to
|
||||
# break docstring parsing (because indentation is significant
|
||||
# in the reStructuredText format).
|
||||
blocks = [dedent("\n".join(lines))]
|
||||
# Use an admonition to group the deprecated aliases together and
|
||||
# to distinguish them from the autodoc entries that follow.
|
||||
blocks.append(".. note:: Deprecated names")
|
||||
indent = " " * 3
|
||||
if len(aliases) == 1:
|
||||
explanation = """
|
||||
The following alias exists to preserve backwards compatibility,
|
||||
however a :exc:`~exceptions.DeprecationWarning` is triggered
|
||||
when it is accessed, because this alias will be removed
|
||||
in a future release.
|
||||
"""
|
||||
else:
|
||||
explanation = """
|
||||
The following aliases exist to preserve backwards compatibility,
|
||||
however a :exc:`~exceptions.DeprecationWarning` is triggered
|
||||
when they are accessed, because these aliases will be
|
||||
removed in a future release.
|
||||
"""
|
||||
blocks.append(indent + compact(explanation))
|
||||
for name, target in aliases.items():
|
||||
blocks.append(format("%s.. data:: %s", indent, name))
|
||||
blocks.append(format("%sAlias for :obj:`%s`.", indent * 2, target))
|
||||
update_lines(lines, "\n\n".join(blocks))
|
||||
|
||||
|
||||
def enable_deprecation_notes(app):
|
||||
"""
|
||||
Enable documenting backwards compatibility aliases using the autodoc_ extension.
|
||||
|
||||
:param app: The Sphinx application object.
|
||||
|
||||
This function connects the :func:`deprecation_note_callback()` function to
|
||||
``autodoc-process-docstring`` events.
|
||||
|
||||
.. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html
|
||||
"""
|
||||
app.connect("autodoc-process-docstring", deprecation_note_callback)
|
||||
|
||||
|
||||
def enable_man_role(app):
|
||||
"""
|
||||
Enable the ``:man:`` role for linking to Debian Linux manual pages.
|
||||
|
||||
:param app: The Sphinx application object.
|
||||
|
||||
This function registers the :func:`man_role()` function to handle the
|
||||
``:man:`` role.
|
||||
"""
|
||||
app.add_role("man", man_role)
|
||||
|
||||
|
||||
def enable_pypi_role(app):
|
||||
"""
|
||||
Enable the ``:pypi:`` role for linking to the Python Package Index.
|
||||
|
||||
:param app: The Sphinx application object.
|
||||
|
||||
This function registers the :func:`pypi_role()` function to handle the
|
||||
``:pypi:`` role.
|
||||
"""
|
||||
app.add_role("pypi", pypi_role)
|
||||
|
||||
|
||||
def enable_special_methods(app):
|
||||
"""
|
||||
Enable documenting "special methods" using the autodoc_ extension.
|
||||
|
||||
:param app: The Sphinx application object.
|
||||
|
||||
This function connects the :func:`special_methods_callback()` function to
|
||||
``autodoc-skip-member`` events.
|
||||
|
||||
.. _autodoc: http://www.sphinx-doc.org/en/stable/ext/autodoc.html
|
||||
"""
|
||||
app.connect("autodoc-skip-member", special_methods_callback)
|
||||
|
||||
|
||||
def enable_usage_formatting(app):
|
||||
"""
|
||||
Reformat human friendly usage messages to reStructuredText_.
|
||||
|
||||
:param app: The Sphinx application object (as given to ``setup()``).
|
||||
|
||||
This function connects the :func:`usage_message_callback()` function to
|
||||
``autodoc-process-docstring`` events.
|
||||
|
||||
.. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText
|
||||
"""
|
||||
app.connect("autodoc-process-docstring", usage_message_callback)
|
||||
|
||||
|
||||
def man_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
|
||||
"""
|
||||
Convert a Linux manual topic to a hyperlink.
|
||||
|
||||
Using the ``:man:`` role is very simple, here's an example:
|
||||
|
||||
.. code-block:: rst
|
||||
|
||||
See the :man:`python` documentation.
|
||||
|
||||
This results in the following:
|
||||
|
||||
See the :man:`python` documentation.
|
||||
|
||||
As the example shows you can use the role inline, embedded in sentences of
|
||||
text. In the generated documentation the ``:man:`` text is omitted and a
|
||||
hyperlink pointing to the Debian Linux manual pages is emitted.
|
||||
"""
|
||||
man_url = "https://manpages.debian.org/%s" % text
|
||||
reference = docutils.nodes.reference(rawtext, docutils.utils.unescape(text), refuri=man_url, **options)
|
||||
return [reference], []
|
||||
|
||||
|
||||
def pypi_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
|
||||
"""
|
||||
Generate hyperlinks to the Python Package Index.
|
||||
|
||||
Using the ``:pypi:`` role is very simple, here's an example:
|
||||
|
||||
.. code-block:: rst
|
||||
|
||||
See the :pypi:`humanfriendly` package.
|
||||
|
||||
This results in the following:
|
||||
|
||||
See the :pypi:`humanfriendly` package.
|
||||
|
||||
As the example shows you can use the role inline, embedded in sentences of
|
||||
text. In the generated documentation the ``:pypi:`` text is omitted and a
|
||||
hyperlink pointing to the Python Package Index is emitted.
|
||||
"""
|
||||
pypi_url = "https://pypi.org/project/%s/" % text
|
||||
reference = docutils.nodes.reference(rawtext, docutils.utils.unescape(text), refuri=pypi_url, **options)
|
||||
return [reference], []
|
||||
|
||||
|
||||
def setup(app):
|
||||
"""
|
||||
Enable all of the provided Sphinx_ customizations.
|
||||
|
||||
:param app: The Sphinx application object.
|
||||
|
||||
The :func:`setup()` function makes it easy to enable all of the Sphinx
|
||||
customizations provided by the :mod:`humanfriendly.sphinx` module with the
|
||||
least amount of code. All you need to do is to add the module name to the
|
||||
``extensions`` variable in your ``conf.py`` file:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Sphinx extension module names.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.doctest',
|
||||
'sphinx.ext.intersphinx',
|
||||
'humanfriendly.sphinx',
|
||||
]
|
||||
|
||||
When Sphinx sees the :mod:`humanfriendly.sphinx` name it will import the
|
||||
module and call its :func:`setup()` function. This function will then call
|
||||
the following:
|
||||
|
||||
- :func:`enable_deprecation_notes()`
|
||||
- :func:`enable_man_role()`
|
||||
- :func:`enable_pypi_role()`
|
||||
- :func:`enable_special_methods()`
|
||||
- :func:`enable_usage_formatting()`
|
||||
|
||||
Of course more functionality may be added at a later stage. If you don't
|
||||
like that idea you may be better of calling the individual functions from
|
||||
your own ``setup()`` function.
|
||||
"""
|
||||
from humanfriendly import __version__
|
||||
|
||||
enable_deprecation_notes(app)
|
||||
enable_man_role(app)
|
||||
enable_pypi_role(app)
|
||||
enable_special_methods(app)
|
||||
enable_usage_formatting(app)
|
||||
|
||||
return dict(parallel_read_safe=True, parallel_write_safe=True, version=__version__)
|
||||
|
||||
|
||||
def special_methods_callback(app, what, name, obj, skip, options):
|
||||
"""
|
||||
Enable documenting "special methods" using the autodoc_ extension.
|
||||
|
||||
Refer to :func:`enable_special_methods()` to enable the use of this
|
||||
function (you probably don't want to call
|
||||
:func:`special_methods_callback()` directly).
|
||||
|
||||
This function implements a callback for ``autodoc-skip-member`` events to
|
||||
include documented "special methods" (method names with two leading and two
|
||||
trailing underscores) in your documentation. The result is similar to the
|
||||
use of the ``special-members`` flag with one big difference: Special
|
||||
methods are included but other types of members are ignored. This means
|
||||
that attributes like ``__weakref__`` will always be ignored (this was my
|
||||
main annoyance with the ``special-members`` flag).
|
||||
|
||||
The parameters expected by this function are those defined for Sphinx event
|
||||
callback functions (i.e. I'm not going to document them here :-).
|
||||
"""
|
||||
if getattr(obj, "__doc__", None) and isinstance(obj, (types.FunctionType, types.MethodType)):
|
||||
return False
|
||||
else:
|
||||
return skip
|
||||
|
||||
|
||||
def update_lines(lines, text):
|
||||
"""Private helper for ``autodoc-process-docstring`` callbacks."""
|
||||
while lines:
|
||||
lines.pop()
|
||||
lines.extend(text.splitlines())
|
||||
|
||||
|
||||
def usage_message_callback(app, what, name, obj, options, lines):
|
||||
"""
|
||||
Reformat human friendly usage messages to reStructuredText_.
|
||||
|
||||
Refer to :func:`enable_usage_formatting()` to enable the use of this
|
||||
function (you probably don't want to call :func:`usage_message_callback()`
|
||||
directly).
|
||||
|
||||
This function implements a callback for ``autodoc-process-docstring`` that
|
||||
reformats module docstrings using :func:`.render_usage()` so that Sphinx
|
||||
doesn't mangle usage messages that were written to be human readable
|
||||
instead of machine readable. Only module docstrings whose first line starts
|
||||
with :data:`.USAGE_MARKER` are reformatted.
|
||||
|
||||
The parameters expected by this function are those defined for Sphinx event
|
||||
callback functions (i.e. I'm not going to document them here :-).
|
||||
"""
|
||||
# Make sure we only modify the docstrings of modules.
|
||||
if isinstance(obj, types.ModuleType) and lines:
|
||||
# Make sure we only modify docstrings containing a usage message.
|
||||
if lines[0].startswith(USAGE_MARKER):
|
||||
# Convert the usage message to reStructuredText.
|
||||
text = render_usage("\n".join(lines))
|
||||
# Fill up the buffer with our modified docstring.
|
||||
update_lines(lines, text)
|
||||
@@ -0,0 +1,341 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: February 16, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Functions that render ASCII tables.
|
||||
|
||||
Some generic notes about the table formatting functions in this module:
|
||||
|
||||
- These functions were not written with performance in mind (*at all*) because
|
||||
they're intended to format tabular data to be presented on a terminal. If
|
||||
someone were to run into a performance problem using these functions, they'd
|
||||
be printing so much tabular data to the terminal that a human wouldn't be
|
||||
able to digest the tabular data anyway, so the point is moot :-).
|
||||
|
||||
- These functions ignore ANSI escape sequences (at least the ones generated by
|
||||
the :mod:`~humanfriendly.terminal` module) in the calculation of columns
|
||||
widths. On reason for this is that column names are highlighted in color when
|
||||
connected to a terminal. It also means that you can use ANSI escape sequences
|
||||
to highlight certain column's values if you feel like it (for example to
|
||||
highlight deviations from the norm in an overview of calculated values).
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import collections
|
||||
import re
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import coerce_string
|
||||
from humanfriendly.terminal import (
|
||||
ansi_strip,
|
||||
ansi_width,
|
||||
ansi_wrap,
|
||||
terminal_supports_colors,
|
||||
find_terminal_size,
|
||||
HIGHLIGHT_COLOR,
|
||||
)
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'format_pretty_table',
|
||||
'format_robust_table',
|
||||
'format_rst_table',
|
||||
'format_smart_table',
|
||||
)
|
||||
|
||||
# Compiled regular expression pattern to recognize table columns containing
|
||||
# numeric data (integer and/or floating point numbers). Used to right-align the
|
||||
# contents of such columns.
|
||||
#
|
||||
# Pre-emptive snarky comment: This pattern doesn't match every possible
|
||||
# floating point number notation!?!1!1
|
||||
#
|
||||
# Response: I know, that's intentional. The use of this regular expression
|
||||
# pattern has a very high DWIM level and weird floating point notations do not
|
||||
# fall under the DWIM umbrella :-).
|
||||
NUMERIC_DATA_PATTERN = re.compile(r'^\d+(\.\d+)?$')
|
||||
|
||||
|
||||
def format_smart_table(data, column_names):
|
||||
"""
|
||||
Render tabular data using the most appropriate representation.
|
||||
|
||||
:param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
|
||||
containing the rows of the table, where each row is an
|
||||
iterable containing the columns of the table (strings).
|
||||
:param column_names: An iterable of column names (strings).
|
||||
:returns: The rendered table (a string).
|
||||
|
||||
If you want an easy way to render tabular data on a terminal in a human
|
||||
friendly format then this function is for you! It works as follows:
|
||||
|
||||
- If the input data doesn't contain any line breaks the function
|
||||
:func:`format_pretty_table()` is used to render a pretty table. If the
|
||||
resulting table fits in the terminal without wrapping the rendered pretty
|
||||
table is returned.
|
||||
|
||||
- If the input data does contain line breaks or if a pretty table would
|
||||
wrap (given the width of the terminal) then the function
|
||||
:func:`format_robust_table()` is used to render a more robust table that
|
||||
can deal with data containing line breaks and long text.
|
||||
"""
|
||||
# Normalize the input in case we fall back from a pretty table to a robust
|
||||
# table (in which case we'll definitely iterate the input more than once).
|
||||
data = [normalize_columns(r) for r in data]
|
||||
column_names = normalize_columns(column_names)
|
||||
# Make sure the input data doesn't contain any line breaks (because pretty
|
||||
# tables break horribly when a column's text contains a line break :-).
|
||||
if not any(any('\n' in c for c in r) for r in data):
|
||||
# Render a pretty table.
|
||||
pretty_table = format_pretty_table(data, column_names)
|
||||
# Check if the pretty table fits in the terminal.
|
||||
table_width = max(map(ansi_width, pretty_table.splitlines()))
|
||||
num_rows, num_columns = find_terminal_size()
|
||||
if table_width <= num_columns:
|
||||
# The pretty table fits in the terminal without wrapping!
|
||||
return pretty_table
|
||||
# Fall back to a robust table when a pretty table won't work.
|
||||
return format_robust_table(data, column_names)
|
||||
|
||||
|
||||
def format_pretty_table(data, column_names=None, horizontal_bar='-', vertical_bar='|'):
|
||||
"""
|
||||
Render a table using characters like dashes and vertical bars to emulate borders.
|
||||
|
||||
:param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
|
||||
containing the rows of the table, where each row is an
|
||||
iterable containing the columns of the table (strings).
|
||||
:param column_names: An iterable of column names (strings).
|
||||
:param horizontal_bar: The character used to represent a horizontal bar (a
|
||||
string).
|
||||
:param vertical_bar: The character used to represent a vertical bar (a
|
||||
string).
|
||||
:returns: The rendered table (a string).
|
||||
|
||||
Here's an example:
|
||||
|
||||
>>> from humanfriendly.tables import format_pretty_table
|
||||
>>> column_names = ['Version', 'Uploaded on', 'Downloads']
|
||||
>>> humanfriendly_releases = [
|
||||
... ['1.23', '2015-05-25', '218'],
|
||||
... ['1.23.1', '2015-05-26', '1354'],
|
||||
... ['1.24', '2015-05-26', '223'],
|
||||
... ['1.25', '2015-05-26', '4319'],
|
||||
... ['1.25.1', '2015-06-02', '197'],
|
||||
... ]
|
||||
>>> print(format_pretty_table(humanfriendly_releases, column_names))
|
||||
-------------------------------------
|
||||
| Version | Uploaded on | Downloads |
|
||||
-------------------------------------
|
||||
| 1.23 | 2015-05-25 | 218 |
|
||||
| 1.23.1 | 2015-05-26 | 1354 |
|
||||
| 1.24 | 2015-05-26 | 223 |
|
||||
| 1.25 | 2015-05-26 | 4319 |
|
||||
| 1.25.1 | 2015-06-02 | 197 |
|
||||
-------------------------------------
|
||||
|
||||
Notes about the resulting table:
|
||||
|
||||
- If a column contains numeric data (integer and/or floating point
|
||||
numbers) in all rows (ignoring column names of course) then the content
|
||||
of that column is right-aligned, as can be seen in the example above. The
|
||||
idea here is to make it easier to compare the numbers in different
|
||||
columns to each other.
|
||||
|
||||
- The column names are highlighted in color so they stand out a bit more
|
||||
(see also :data:`.HIGHLIGHT_COLOR`). The following screen shot shows what
|
||||
that looks like (my terminals are always set to white text on a black
|
||||
background):
|
||||
|
||||
.. image:: images/pretty-table.png
|
||||
"""
|
||||
# Normalize the input because we'll have to iterate it more than once.
|
||||
data = [normalize_columns(r, expandtabs=True) for r in data]
|
||||
if column_names is not None:
|
||||
column_names = normalize_columns(column_names)
|
||||
if column_names:
|
||||
if terminal_supports_colors():
|
||||
column_names = [highlight_column_name(n) for n in column_names]
|
||||
data.insert(0, column_names)
|
||||
# Calculate the maximum width of each column.
|
||||
widths = collections.defaultdict(int)
|
||||
numeric_data = collections.defaultdict(list)
|
||||
for row_index, row in enumerate(data):
|
||||
for column_index, column in enumerate(row):
|
||||
widths[column_index] = max(widths[column_index], ansi_width(column))
|
||||
if not (column_names and row_index == 0):
|
||||
numeric_data[column_index].append(bool(NUMERIC_DATA_PATTERN.match(ansi_strip(column))))
|
||||
# Create a horizontal bar of dashes as a delimiter.
|
||||
line_delimiter = horizontal_bar * (sum(widths.values()) + len(widths) * 3 + 1)
|
||||
# Start the table with a vertical bar.
|
||||
lines = [line_delimiter]
|
||||
# Format the rows and columns.
|
||||
for row_index, row in enumerate(data):
|
||||
line = [vertical_bar]
|
||||
for column_index, column in enumerate(row):
|
||||
padding = ' ' * (widths[column_index] - ansi_width(column))
|
||||
if all(numeric_data[column_index]):
|
||||
line.append(' ' + padding + column + ' ')
|
||||
else:
|
||||
line.append(' ' + column + padding + ' ')
|
||||
line.append(vertical_bar)
|
||||
lines.append(u''.join(line))
|
||||
if column_names and row_index == 0:
|
||||
lines.append(line_delimiter)
|
||||
# End the table with a vertical bar.
|
||||
lines.append(line_delimiter)
|
||||
# Join the lines, returning a single string.
|
||||
return u'\n'.join(lines)
|
||||
|
||||
|
||||
def format_robust_table(data, column_names):
|
||||
"""
|
||||
Render tabular data with one column per line (allowing columns with line breaks).
|
||||
|
||||
:param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
|
||||
containing the rows of the table, where each row is an
|
||||
iterable containing the columns of the table (strings).
|
||||
:param column_names: An iterable of column names (strings).
|
||||
:returns: The rendered table (a string).
|
||||
|
||||
Here's an example:
|
||||
|
||||
>>> from humanfriendly.tables import format_robust_table
|
||||
>>> column_names = ['Version', 'Uploaded on', 'Downloads']
|
||||
>>> humanfriendly_releases = [
|
||||
... ['1.23', '2015-05-25', '218'],
|
||||
... ['1.23.1', '2015-05-26', '1354'],
|
||||
... ['1.24', '2015-05-26', '223'],
|
||||
... ['1.25', '2015-05-26', '4319'],
|
||||
... ['1.25.1', '2015-06-02', '197'],
|
||||
... ]
|
||||
>>> print(format_robust_table(humanfriendly_releases, column_names))
|
||||
-----------------------
|
||||
Version: 1.23
|
||||
Uploaded on: 2015-05-25
|
||||
Downloads: 218
|
||||
-----------------------
|
||||
Version: 1.23.1
|
||||
Uploaded on: 2015-05-26
|
||||
Downloads: 1354
|
||||
-----------------------
|
||||
Version: 1.24
|
||||
Uploaded on: 2015-05-26
|
||||
Downloads: 223
|
||||
-----------------------
|
||||
Version: 1.25
|
||||
Uploaded on: 2015-05-26
|
||||
Downloads: 4319
|
||||
-----------------------
|
||||
Version: 1.25.1
|
||||
Uploaded on: 2015-06-02
|
||||
Downloads: 197
|
||||
-----------------------
|
||||
|
||||
The column names are highlighted in bold font and color so they stand out a
|
||||
bit more (see :data:`.HIGHLIGHT_COLOR`).
|
||||
"""
|
||||
blocks = []
|
||||
column_names = ["%s:" % n for n in normalize_columns(column_names)]
|
||||
if terminal_supports_colors():
|
||||
column_names = [highlight_column_name(n) for n in column_names]
|
||||
# Convert each row into one or more `name: value' lines (one per column)
|
||||
# and group each `row of lines' into a block (i.e. rows become blocks).
|
||||
for row in data:
|
||||
lines = []
|
||||
for column_index, column_text in enumerate(normalize_columns(row)):
|
||||
stripped_column = column_text.strip()
|
||||
if '\n' not in stripped_column:
|
||||
# Columns without line breaks are formatted inline.
|
||||
lines.append("%s %s" % (column_names[column_index], stripped_column))
|
||||
else:
|
||||
# Columns with line breaks could very well contain indented
|
||||
# lines, so we'll put the column name on a separate line. This
|
||||
# way any indentation remains intact, and it's easier to
|
||||
# copy/paste the text.
|
||||
lines.append(column_names[column_index])
|
||||
lines.extend(column_text.rstrip().splitlines())
|
||||
blocks.append(lines)
|
||||
# Calculate the width of the row delimiter.
|
||||
num_rows, num_columns = find_terminal_size()
|
||||
longest_line = max(max(map(ansi_width, lines)) for lines in blocks)
|
||||
delimiter = u"\n%s\n" % ('-' * min(longest_line, num_columns))
|
||||
# Force a delimiter at the start and end of the table.
|
||||
blocks.insert(0, "")
|
||||
blocks.append("")
|
||||
# Embed the row delimiter between every two blocks.
|
||||
return delimiter.join(u"\n".join(b) for b in blocks).strip()
|
||||
|
||||
|
||||
def format_rst_table(data, column_names=None):
|
||||
"""
|
||||
Render a table in reStructuredText_ format.
|
||||
|
||||
:param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
|
||||
containing the rows of the table, where each row is an
|
||||
iterable containing the columns of the table (strings).
|
||||
:param column_names: An iterable of column names (strings).
|
||||
:returns: The rendered table (a string).
|
||||
|
||||
Here's an example:
|
||||
|
||||
>>> from humanfriendly.tables import format_rst_table
|
||||
>>> column_names = ['Version', 'Uploaded on', 'Downloads']
|
||||
>>> humanfriendly_releases = [
|
||||
... ['1.23', '2015-05-25', '218'],
|
||||
... ['1.23.1', '2015-05-26', '1354'],
|
||||
... ['1.24', '2015-05-26', '223'],
|
||||
... ['1.25', '2015-05-26', '4319'],
|
||||
... ['1.25.1', '2015-06-02', '197'],
|
||||
... ]
|
||||
>>> print(format_rst_table(humanfriendly_releases, column_names))
|
||||
======= =========== =========
|
||||
Version Uploaded on Downloads
|
||||
======= =========== =========
|
||||
1.23 2015-05-25 218
|
||||
1.23.1 2015-05-26 1354
|
||||
1.24 2015-05-26 223
|
||||
1.25 2015-05-26 4319
|
||||
1.25.1 2015-06-02 197
|
||||
======= =========== =========
|
||||
|
||||
.. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText
|
||||
"""
|
||||
data = [normalize_columns(r) for r in data]
|
||||
if column_names:
|
||||
data.insert(0, normalize_columns(column_names))
|
||||
# Calculate the maximum width of each column.
|
||||
widths = collections.defaultdict(int)
|
||||
for row in data:
|
||||
for index, column in enumerate(row):
|
||||
widths[index] = max(widths[index], len(column))
|
||||
# Pad the columns using whitespace.
|
||||
for row in data:
|
||||
for index, column in enumerate(row):
|
||||
if index < (len(row) - 1):
|
||||
row[index] = column.ljust(widths[index])
|
||||
# Add table markers.
|
||||
delimiter = ['=' * w for i, w in sorted(widths.items())]
|
||||
if column_names:
|
||||
data.insert(1, delimiter)
|
||||
data.insert(0, delimiter)
|
||||
data.append(delimiter)
|
||||
# Join the lines and columns together.
|
||||
return '\n'.join(' '.join(r) for r in data)
|
||||
|
||||
|
||||
def normalize_columns(row, expandtabs=False):
|
||||
results = []
|
||||
for value in row:
|
||||
text = coerce_string(value)
|
||||
if expandtabs:
|
||||
text = text.expandtabs()
|
||||
results.append(text)
|
||||
return results
|
||||
|
||||
|
||||
def highlight_column_name(name):
|
||||
return ansi_wrap(name, bold=True, color=HIGHLIGHT_COLOR)
|
||||
@@ -0,0 +1,776 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 1, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Interaction with interactive text terminals.
|
||||
|
||||
The :mod:`~humanfriendly.terminal` module makes it easy to interact with
|
||||
interactive text terminals and format text for rendering on such terminals. If
|
||||
the terms used in the documentation of this module don't make sense to you then
|
||||
please refer to the `Wikipedia article on ANSI escape sequences`_ for details
|
||||
about how ANSI escape sequences work.
|
||||
|
||||
This module was originally developed for use on UNIX systems, but since then
|
||||
Windows 10 gained native support for ANSI escape sequences and this module was
|
||||
enhanced to recognize and support this. For details please refer to the
|
||||
:func:`enable_ansi_support()` function.
|
||||
|
||||
.. _Wikipedia article on ANSI escape sequences: http://en.wikipedia.org/wiki/ANSI_escape_code#Sequence_elements
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import codecs
|
||||
import numbers
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# The `fcntl' module is platform specific so importing it may give an error. We
|
||||
# hide this implementation detail from callers by handling the import error and
|
||||
# setting a flag instead.
|
||||
try:
|
||||
import fcntl
|
||||
import termios
|
||||
import struct
|
||||
HAVE_IOCTL = True
|
||||
except ImportError:
|
||||
HAVE_IOCTL = False
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import coerce_string, is_unicode, on_windows, which
|
||||
from humanfriendly.decorators import cached
|
||||
from humanfriendly.deprecation import define_aliases
|
||||
from humanfriendly.text import concatenate, format
|
||||
from humanfriendly.usage import format_usage
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'ANSI_COLOR_CODES',
|
||||
'ANSI_CSI',
|
||||
'ANSI_ERASE_LINE',
|
||||
'ANSI_HIDE_CURSOR',
|
||||
'ANSI_RESET',
|
||||
'ANSI_SGR',
|
||||
'ANSI_SHOW_CURSOR',
|
||||
'ANSI_TEXT_STYLES',
|
||||
'CLEAN_OUTPUT_PATTERN',
|
||||
'DEFAULT_COLUMNS',
|
||||
'DEFAULT_ENCODING',
|
||||
'DEFAULT_LINES',
|
||||
'HIGHLIGHT_COLOR',
|
||||
'ansi_strip',
|
||||
'ansi_style',
|
||||
'ansi_width',
|
||||
'ansi_wrap',
|
||||
'auto_encode',
|
||||
'clean_terminal_output',
|
||||
'connected_to_terminal',
|
||||
'enable_ansi_support',
|
||||
'find_terminal_size',
|
||||
'find_terminal_size_using_ioctl',
|
||||
'find_terminal_size_using_stty',
|
||||
'get_pager_command',
|
||||
'have_windows_native_ansi_support',
|
||||
'message',
|
||||
'output',
|
||||
'readline_strip',
|
||||
'readline_wrap',
|
||||
'show_pager',
|
||||
'terminal_supports_colors',
|
||||
'usage',
|
||||
'warning',
|
||||
)
|
||||
|
||||
ANSI_CSI = '\x1b['
|
||||
"""The ANSI "Control Sequence Introducer" (a string)."""
|
||||
|
||||
ANSI_SGR = 'm'
|
||||
"""The ANSI "Select Graphic Rendition" sequence (a string)."""
|
||||
|
||||
ANSI_ERASE_LINE = '%sK' % ANSI_CSI
|
||||
"""The ANSI escape sequence to erase the current line (a string)."""
|
||||
|
||||
ANSI_RESET = '%s0%s' % (ANSI_CSI, ANSI_SGR)
|
||||
"""The ANSI escape sequence to reset styling (a string)."""
|
||||
|
||||
ANSI_HIDE_CURSOR = '%s?25l' % ANSI_CSI
|
||||
"""The ANSI escape sequence to hide the text cursor (a string)."""
|
||||
|
||||
ANSI_SHOW_CURSOR = '%s?25h' % ANSI_CSI
|
||||
"""The ANSI escape sequence to show the text cursor (a string)."""
|
||||
|
||||
ANSI_COLOR_CODES = dict(black=0, red=1, green=2, yellow=3, blue=4, magenta=5, cyan=6, white=7)
|
||||
"""
|
||||
A dictionary with (name, number) pairs of `portable color codes`_. Used by
|
||||
:func:`ansi_style()` to generate ANSI escape sequences that change font color.
|
||||
|
||||
.. _portable color codes: http://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
||||
"""
|
||||
|
||||
ANSI_TEXT_STYLES = dict(bold=1, faint=2, italic=3, underline=4, inverse=7, strike_through=9)
|
||||
"""
|
||||
A dictionary with (name, number) pairs of text styles (effects). Used by
|
||||
:func:`ansi_style()` to generate ANSI escape sequences that change text
|
||||
styles. Only widely supported text styles are included here.
|
||||
"""
|
||||
|
||||
CLEAN_OUTPUT_PATTERN = re.compile(u'(\r|\n|\b|%s)' % re.escape(ANSI_ERASE_LINE))
|
||||
"""
|
||||
A compiled regular expression used to separate significant characters from other text.
|
||||
|
||||
This pattern is used by :func:`clean_terminal_output()` to split terminal
|
||||
output into regular text versus backspace, carriage return and line feed
|
||||
characters and ANSI 'erase line' escape sequences.
|
||||
"""
|
||||
|
||||
DEFAULT_LINES = 25
|
||||
"""The default number of lines in a terminal (an integer)."""
|
||||
|
||||
DEFAULT_COLUMNS = 80
|
||||
"""The default number of columns in a terminal (an integer)."""
|
||||
|
||||
DEFAULT_ENCODING = 'UTF-8'
|
||||
"""The output encoding for Unicode strings."""
|
||||
|
||||
HIGHLIGHT_COLOR = os.environ.get('HUMANFRIENDLY_HIGHLIGHT_COLOR', 'green')
|
||||
"""
|
||||
The color used to highlight important tokens in formatted text (e.g. the usage
|
||||
message of the ``humanfriendly`` program). If the environment variable
|
||||
``$HUMANFRIENDLY_HIGHLIGHT_COLOR`` is set it determines the value of
|
||||
:data:`HIGHLIGHT_COLOR`.
|
||||
"""
|
||||
|
||||
|
||||
def ansi_strip(text, readline_hints=True):
|
||||
"""
|
||||
Strip ANSI escape sequences from the given string.
|
||||
|
||||
:param text: The text from which ANSI escape sequences should be removed (a
|
||||
string).
|
||||
:param readline_hints: If :data:`True` then :func:`readline_strip()` is
|
||||
used to remove `readline hints`_ from the string.
|
||||
:returns: The text without ANSI escape sequences (a string).
|
||||
"""
|
||||
pattern = '%s.*?%s' % (re.escape(ANSI_CSI), re.escape(ANSI_SGR))
|
||||
text = re.sub(pattern, '', text)
|
||||
if readline_hints:
|
||||
text = readline_strip(text)
|
||||
return text
|
||||
|
||||
|
||||
def ansi_style(**kw):
|
||||
"""
|
||||
Generate ANSI escape sequences for the given color and/or style(s).
|
||||
|
||||
:param color: The foreground color. Three types of values are supported:
|
||||
|
||||
- The name of a color (one of the strings 'black', 'red',
|
||||
'green', 'yellow', 'blue', 'magenta', 'cyan' or 'white').
|
||||
- An integer that refers to the 256 color mode palette.
|
||||
- A tuple or list with three integers representing an RGB
|
||||
(red, green, blue) value.
|
||||
|
||||
The value :data:`None` (the default) means no escape
|
||||
sequence to switch color will be emitted.
|
||||
:param background: The background color (see the description
|
||||
of the `color` argument).
|
||||
:param bright: Use high intensity colors instead of default colors
|
||||
(a boolean, defaults to :data:`False`).
|
||||
:param readline_hints: If :data:`True` then :func:`readline_wrap()` is
|
||||
applied to the generated ANSI escape sequences (the
|
||||
default is :data:`False`).
|
||||
:param kw: Any additional keyword arguments are expected to match a key
|
||||
in the :data:`ANSI_TEXT_STYLES` dictionary. If the argument's
|
||||
value evaluates to :data:`True` the respective style will be
|
||||
enabled.
|
||||
:returns: The ANSI escape sequences to enable the requested text styles or
|
||||
an empty string if no styles were requested.
|
||||
:raises: :exc:`~exceptions.ValueError` when an invalid color name is given.
|
||||
|
||||
Even though only eight named colors are supported, the use of `bright=True`
|
||||
and `faint=True` increases the number of available colors to around 24 (it
|
||||
may be slightly lower, for example because faint black is just black).
|
||||
|
||||
**Support for 8-bit colors**
|
||||
|
||||
In `release 4.7`_ support for 256 color mode was added. While this
|
||||
significantly increases the available colors it's not very human friendly
|
||||
in usage because you need to look up color codes in the `256 color mode
|
||||
palette <https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit>`_.
|
||||
|
||||
You can use the ``humanfriendly --demo`` command to get a demonstration of
|
||||
the available colors, see also the screen shot below. Note that the small
|
||||
font size in the screen shot was so that the demonstration of 256 color
|
||||
mode support would fit into a single screen shot without scrolling :-)
|
||||
(I wasn't feeling very creative).
|
||||
|
||||
.. image:: images/ansi-demo.png
|
||||
|
||||
**Support for 24-bit colors**
|
||||
|
||||
In `release 4.14`_ support for 24-bit colors was added by accepting a tuple
|
||||
or list with three integers representing the RGB (red, green, blue) value
|
||||
of a color. This is not included in the demo because rendering millions of
|
||||
colors was deemed unpractical ;-).
|
||||
|
||||
.. _release 4.7: http://humanfriendly.readthedocs.io/en/latest/changelog.html#release-4-7-2018-01-14
|
||||
.. _release 4.14: http://humanfriendly.readthedocs.io/en/latest/changelog.html#release-4-14-2018-07-13
|
||||
"""
|
||||
# Start with sequences that change text styles.
|
||||
sequences = [ANSI_TEXT_STYLES[k] for k, v in kw.items() if k in ANSI_TEXT_STYLES and v]
|
||||
# Append the color code (if any).
|
||||
for color_type in 'color', 'background':
|
||||
color_value = kw.get(color_type)
|
||||
if isinstance(color_value, (tuple, list)):
|
||||
if len(color_value) != 3:
|
||||
msg = "Invalid color value %r! (expected tuple or list with three numbers)"
|
||||
raise ValueError(msg % color_value)
|
||||
sequences.append(48 if color_type == 'background' else 38)
|
||||
sequences.append(2)
|
||||
sequences.extend(map(int, color_value))
|
||||
elif isinstance(color_value, numbers.Number):
|
||||
# Numeric values are assumed to be 256 color codes.
|
||||
sequences.extend((
|
||||
39 if color_type == 'background' else 38,
|
||||
5, int(color_value)
|
||||
))
|
||||
elif color_value:
|
||||
# Other values are assumed to be strings containing one of the known color names.
|
||||
if color_value not in ANSI_COLOR_CODES:
|
||||
msg = "Invalid color value %r! (expected an integer or one of the strings %s)"
|
||||
raise ValueError(msg % (color_value, concatenate(map(repr, sorted(ANSI_COLOR_CODES)))))
|
||||
# Pick the right offset for foreground versus background
|
||||
# colors and regular intensity versus bright colors.
|
||||
offset = (
|
||||
(100 if kw.get('bright') else 40)
|
||||
if color_type == 'background'
|
||||
else (90 if kw.get('bright') else 30)
|
||||
)
|
||||
# Combine the offset and color code into a single integer.
|
||||
sequences.append(offset + ANSI_COLOR_CODES[color_value])
|
||||
if sequences:
|
||||
encoded = ANSI_CSI + ';'.join(map(str, sequences)) + ANSI_SGR
|
||||
return readline_wrap(encoded) if kw.get('readline_hints') else encoded
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def ansi_width(text):
|
||||
"""
|
||||
Calculate the effective width of the given text (ignoring ANSI escape sequences).
|
||||
|
||||
:param text: The text whose width should be calculated (a string).
|
||||
:returns: The width of the text without ANSI escape sequences (an
|
||||
integer).
|
||||
|
||||
This function uses :func:`ansi_strip()` to strip ANSI escape sequences from
|
||||
the given string and returns the length of the resulting string.
|
||||
"""
|
||||
return len(ansi_strip(text))
|
||||
|
||||
|
||||
def ansi_wrap(text, **kw):
|
||||
"""
|
||||
Wrap text in ANSI escape sequences for the given color and/or style(s).
|
||||
|
||||
:param text: The text to wrap (a string).
|
||||
:param kw: Any keyword arguments are passed to :func:`ansi_style()`.
|
||||
:returns: The result of this function depends on the keyword arguments:
|
||||
|
||||
- If :func:`ansi_style()` generates an ANSI escape sequence based
|
||||
on the keyword arguments, the given text is prefixed with the
|
||||
generated ANSI escape sequence and suffixed with
|
||||
:data:`ANSI_RESET`.
|
||||
|
||||
- If :func:`ansi_style()` returns an empty string then the text
|
||||
given by the caller is returned unchanged.
|
||||
"""
|
||||
start_sequence = ansi_style(**kw)
|
||||
if start_sequence:
|
||||
end_sequence = ANSI_RESET
|
||||
if kw.get('readline_hints'):
|
||||
end_sequence = readline_wrap(end_sequence)
|
||||
return start_sequence + text + end_sequence
|
||||
else:
|
||||
return text
|
||||
|
||||
|
||||
def auto_encode(stream, text, *args, **kw):
|
||||
"""
|
||||
Reliably write Unicode strings to the terminal.
|
||||
|
||||
:param stream: The file-like object to write to (a value like
|
||||
:data:`sys.stdout` or :data:`sys.stderr`).
|
||||
:param text: The text to write to the stream (a string).
|
||||
:param args: Refer to :func:`~humanfriendly.text.format()`.
|
||||
:param kw: Refer to :func:`~humanfriendly.text.format()`.
|
||||
|
||||
Renders the text using :func:`~humanfriendly.text.format()` and writes it
|
||||
to the given stream. If an :exc:`~exceptions.UnicodeEncodeError` is
|
||||
encountered in doing so, the text is encoded using :data:`DEFAULT_ENCODING`
|
||||
and the write is retried. The reasoning behind this rather blunt approach
|
||||
is that it's preferable to get output on the command line in the wrong
|
||||
encoding then to have the Python program blow up with a
|
||||
:exc:`~exceptions.UnicodeEncodeError` exception.
|
||||
"""
|
||||
text = format(text, *args, **kw)
|
||||
try:
|
||||
stream.write(text)
|
||||
except UnicodeEncodeError:
|
||||
stream.write(codecs.encode(text, DEFAULT_ENCODING))
|
||||
|
||||
|
||||
def clean_terminal_output(text):
|
||||
"""
|
||||
Clean up the terminal output of a command.
|
||||
|
||||
:param text: The raw text with special characters (a Unicode string).
|
||||
:returns: A list of Unicode strings (one for each line).
|
||||
|
||||
This function emulates the effect of backspace (0x08), carriage return
|
||||
(0x0D) and line feed (0x0A) characters and the ANSI 'erase line' escape
|
||||
sequence on interactive terminals. It's intended to clean up command output
|
||||
that was originally meant to be rendered on an interactive terminal and
|
||||
that has been captured using e.g. the :man:`script` program [#]_ or the
|
||||
:mod:`pty` module [#]_.
|
||||
|
||||
.. [#] My coloredlogs_ package supports the ``coloredlogs --to-html``
|
||||
command which uses :man:`script` to fool a subprocess into thinking
|
||||
that it's connected to an interactive terminal (in order to get it
|
||||
to emit ANSI escape sequences).
|
||||
|
||||
.. [#] My capturer_ package uses the :mod:`pty` module to fool the current
|
||||
process and subprocesses into thinking they are connected to an
|
||||
interactive terminal (in order to get them to emit ANSI escape
|
||||
sequences).
|
||||
|
||||
**Some caveats about the use of this function:**
|
||||
|
||||
- Strictly speaking the effect of carriage returns cannot be emulated
|
||||
outside of an actual terminal due to the interaction between overlapping
|
||||
output, terminal widths and line wrapping. The goal of this function is
|
||||
to sanitize noise in terminal output while preserving useful output.
|
||||
Think of it as a useful and pragmatic but possibly lossy conversion.
|
||||
|
||||
- The algorithm isn't smart enough to properly handle a pair of ANSI escape
|
||||
sequences that open before a carriage return and close after the last
|
||||
carriage return in a linefeed delimited string; the resulting string will
|
||||
contain only the closing end of the ANSI escape sequence pair. Tracking
|
||||
this kind of complexity requires a state machine and proper parsing.
|
||||
|
||||
.. _capturer: https://pypi.org/project/capturer
|
||||
.. _coloredlogs: https://pypi.org/project/coloredlogs
|
||||
"""
|
||||
cleaned_lines = []
|
||||
current_line = ''
|
||||
current_position = 0
|
||||
for token in CLEAN_OUTPUT_PATTERN.split(text):
|
||||
if token == '\r':
|
||||
# Seek back to the start of the current line.
|
||||
current_position = 0
|
||||
elif token == '\b':
|
||||
# Seek back one character in the current line.
|
||||
current_position = max(0, current_position - 1)
|
||||
else:
|
||||
if token == '\n':
|
||||
# Capture the current line.
|
||||
cleaned_lines.append(current_line)
|
||||
if token in ('\n', ANSI_ERASE_LINE):
|
||||
# Clear the current line.
|
||||
current_line = ''
|
||||
current_position = 0
|
||||
elif token:
|
||||
# Merge regular output into the current line.
|
||||
new_position = current_position + len(token)
|
||||
prefix = current_line[:current_position]
|
||||
suffix = current_line[new_position:]
|
||||
current_line = prefix + token + suffix
|
||||
current_position = new_position
|
||||
# Capture the last line (if any).
|
||||
cleaned_lines.append(current_line)
|
||||
# Remove any empty trailing lines.
|
||||
while cleaned_lines and not cleaned_lines[-1]:
|
||||
cleaned_lines.pop(-1)
|
||||
return cleaned_lines
|
||||
|
||||
|
||||
def connected_to_terminal(stream=None):
|
||||
"""
|
||||
Check if a stream is connected to a terminal.
|
||||
|
||||
:param stream: The stream to check (a file-like object,
|
||||
defaults to :data:`sys.stdout`).
|
||||
:returns: :data:`True` if the stream is connected to a terminal,
|
||||
:data:`False` otherwise.
|
||||
|
||||
See also :func:`terminal_supports_colors()`.
|
||||
"""
|
||||
stream = sys.stdout if stream is None else stream
|
||||
try:
|
||||
return stream.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@cached
|
||||
def enable_ansi_support():
|
||||
"""
|
||||
Try to enable support for ANSI escape sequences (required on Windows).
|
||||
|
||||
:returns: :data:`True` if ANSI is supported, :data:`False` otherwise.
|
||||
|
||||
This functions checks for the following supported configurations, in the
|
||||
given order:
|
||||
|
||||
1. On Windows, if :func:`have_windows_native_ansi_support()` confirms
|
||||
native support for ANSI escape sequences :mod:`ctypes` will be used to
|
||||
enable this support.
|
||||
|
||||
2. On Windows, if the environment variable ``$ANSICON`` is set nothing is
|
||||
done because it is assumed that support for ANSI escape sequences has
|
||||
already been enabled via `ansicon <https://github.com/adoxa/ansicon>`_.
|
||||
|
||||
3. On Windows, an attempt is made to import and initialize the Python
|
||||
package :pypi:`colorama` instead (of course for this to work
|
||||
:pypi:`colorama` has to be installed).
|
||||
|
||||
4. On other platforms this function calls :func:`connected_to_terminal()`
|
||||
to determine whether ANSI escape sequences are supported (that is to
|
||||
say all platforms that are not Windows are assumed to support ANSI
|
||||
escape sequences natively, without weird contortions like above).
|
||||
|
||||
This makes it possible to call :func:`enable_ansi_support()`
|
||||
unconditionally without checking the current platform.
|
||||
|
||||
The :func:`~humanfriendly.decorators.cached` decorator is used to ensure
|
||||
that this function is only executed once, but its return value remains
|
||||
available on later calls.
|
||||
"""
|
||||
if have_windows_native_ansi_support():
|
||||
import ctypes
|
||||
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7)
|
||||
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-12), 7)
|
||||
return True
|
||||
elif on_windows():
|
||||
if 'ANSICON' in os.environ:
|
||||
return True
|
||||
try:
|
||||
import colorama
|
||||
colorama.init()
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
else:
|
||||
return connected_to_terminal()
|
||||
|
||||
|
||||
def find_terminal_size():
|
||||
"""
|
||||
Determine the number of lines and columns visible in the terminal.
|
||||
|
||||
:returns: A tuple of two integers with the line and column count.
|
||||
|
||||
The result of this function is based on the first of the following three
|
||||
methods that works:
|
||||
|
||||
1. First :func:`find_terminal_size_using_ioctl()` is tried,
|
||||
2. then :func:`find_terminal_size_using_stty()` is tried,
|
||||
3. finally :data:`DEFAULT_LINES` and :data:`DEFAULT_COLUMNS` are returned.
|
||||
|
||||
.. note:: The :func:`find_terminal_size()` function performs the steps
|
||||
above every time it is called, the result is not cached. This is
|
||||
because the size of a virtual terminal can change at any time and
|
||||
the result of :func:`find_terminal_size()` should be correct.
|
||||
|
||||
`Pre-emptive snarky comment`_: It's possible to cache the result
|
||||
of this function and use :mod:`signal.SIGWINCH <signal>` to
|
||||
refresh the cached values!
|
||||
|
||||
Response: As a library I don't consider it the role of the
|
||||
:mod:`humanfriendly.terminal` module to install a process wide
|
||||
signal handler ...
|
||||
|
||||
.. _Pre-emptive snarky comment: http://blogs.msdn.com/b/oldnewthing/archive/2008/01/30/7315957.aspx
|
||||
"""
|
||||
# The first method. Any of the standard streams may have been redirected
|
||||
# somewhere and there's no telling which, so we'll just try them all.
|
||||
for stream in sys.stdin, sys.stdout, sys.stderr:
|
||||
try:
|
||||
result = find_terminal_size_using_ioctl(stream)
|
||||
if min(result) >= 1:
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
# The second method.
|
||||
try:
|
||||
result = find_terminal_size_using_stty()
|
||||
if min(result) >= 1:
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to conservative defaults.
|
||||
return DEFAULT_LINES, DEFAULT_COLUMNS
|
||||
|
||||
|
||||
def find_terminal_size_using_ioctl(stream):
|
||||
"""
|
||||
Find the terminal size using :func:`fcntl.ioctl()`.
|
||||
|
||||
:param stream: A stream connected to the terminal (a file object with a
|
||||
``fileno`` attribute).
|
||||
:returns: A tuple of two integers with the line and column count.
|
||||
:raises: This function can raise exceptions but I'm not going to document
|
||||
them here, you should be using :func:`find_terminal_size()`.
|
||||
|
||||
Based on an `implementation found on StackOverflow <http://stackoverflow.com/a/3010495/788200>`_.
|
||||
"""
|
||||
if not HAVE_IOCTL:
|
||||
raise NotImplementedError("It looks like the `fcntl' module is not available!")
|
||||
h, w, hp, wp = struct.unpack('HHHH', fcntl.ioctl(stream, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0)))
|
||||
return h, w
|
||||
|
||||
|
||||
def find_terminal_size_using_stty():
|
||||
"""
|
||||
Find the terminal size using the external command ``stty size``.
|
||||
|
||||
:param stream: A stream connected to the terminal (a file object).
|
||||
:returns: A tuple of two integers with the line and column count.
|
||||
:raises: This function can raise exceptions but I'm not going to document
|
||||
them here, you should be using :func:`find_terminal_size()`.
|
||||
"""
|
||||
stty = subprocess.Popen(['stty', 'size'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
stdout, stderr = stty.communicate()
|
||||
tokens = stdout.split()
|
||||
if len(tokens) != 2:
|
||||
raise Exception("Invalid output from `stty size'!")
|
||||
return tuple(map(int, tokens))
|
||||
|
||||
|
||||
def get_pager_command(text=None):
|
||||
"""
|
||||
Get the command to show a text on the terminal using a pager.
|
||||
|
||||
:param text: The text to print to the terminal (a string).
|
||||
:returns: A list of strings with the pager command and arguments.
|
||||
|
||||
The use of a pager helps to avoid the wall of text effect where the user
|
||||
has to scroll up to see where the output began (not very user friendly).
|
||||
|
||||
If the given text contains ANSI escape sequences the command ``less
|
||||
--RAW-CONTROL-CHARS`` is used, otherwise the environment variable
|
||||
``$PAGER`` is used (if ``$PAGER`` isn't set :man:`less` is used).
|
||||
|
||||
When the selected pager is :man:`less`, the following options are used to
|
||||
make the experience more user friendly:
|
||||
|
||||
- ``--quit-if-one-screen`` causes :man:`less` to automatically exit if the
|
||||
entire text can be displayed on the first screen. This makes the use of a
|
||||
pager transparent for smaller texts (because the operator doesn't have to
|
||||
quit the pager).
|
||||
|
||||
- ``--no-init`` prevents :man:`less` from clearing the screen when it
|
||||
exits. This ensures that the operator gets a chance to review the text
|
||||
(for example a usage message) after quitting the pager, while composing
|
||||
the next command.
|
||||
"""
|
||||
# Compose the pager command.
|
||||
if text and ANSI_CSI in text:
|
||||
command_line = ['less', '--RAW-CONTROL-CHARS']
|
||||
else:
|
||||
command_line = [os.environ.get('PAGER', 'less')]
|
||||
# Pass some additional options to `less' (to make it more
|
||||
# user friendly) without breaking support for other pagers.
|
||||
if os.path.basename(command_line[0]) == 'less':
|
||||
command_line.append('--no-init')
|
||||
command_line.append('--quit-if-one-screen')
|
||||
return command_line
|
||||
|
||||
|
||||
@cached
|
||||
def have_windows_native_ansi_support():
|
||||
"""
|
||||
Check if we're running on a Windows 10 release with native support for ANSI escape sequences.
|
||||
|
||||
:returns: :data:`True` if so, :data:`False` otherwise.
|
||||
|
||||
The :func:`~humanfriendly.decorators.cached` decorator is used as a minor
|
||||
performance optimization. Semantically this should have zero impact because
|
||||
the answer doesn't change in the lifetime of a computer process.
|
||||
"""
|
||||
if on_windows():
|
||||
try:
|
||||
# I can't be 100% sure this will never break and I'm not in a
|
||||
# position to test it thoroughly either, so I decided that paying
|
||||
# the price of one additional try / except statement is worth the
|
||||
# additional peace of mind :-).
|
||||
components = tuple(int(c) for c in platform.version().split('.'))
|
||||
return components >= (10, 0, 14393)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def message(text, *args, **kw):
|
||||
"""
|
||||
Print a formatted message to the standard error stream.
|
||||
|
||||
For details about argument handling please refer to
|
||||
:func:`~humanfriendly.text.format()`.
|
||||
|
||||
Renders the message using :func:`~humanfriendly.text.format()` and writes
|
||||
the resulting string (followed by a newline) to :data:`sys.stderr` using
|
||||
:func:`auto_encode()`.
|
||||
"""
|
||||
auto_encode(sys.stderr, coerce_string(text) + '\n', *args, **kw)
|
||||
|
||||
|
||||
def output(text, *args, **kw):
|
||||
"""
|
||||
Print a formatted message to the standard output stream.
|
||||
|
||||
For details about argument handling please refer to
|
||||
:func:`~humanfriendly.text.format()`.
|
||||
|
||||
Renders the message using :func:`~humanfriendly.text.format()` and writes
|
||||
the resulting string (followed by a newline) to :data:`sys.stdout` using
|
||||
:func:`auto_encode()`.
|
||||
"""
|
||||
auto_encode(sys.stdout, coerce_string(text) + '\n', *args, **kw)
|
||||
|
||||
|
||||
def readline_strip(expr):
|
||||
"""
|
||||
Remove `readline hints`_ from a string.
|
||||
|
||||
:param text: The text to strip (a string).
|
||||
:returns: The stripped text.
|
||||
"""
|
||||
return expr.replace('\001', '').replace('\002', '')
|
||||
|
||||
|
||||
def readline_wrap(expr):
|
||||
"""
|
||||
Wrap an ANSI escape sequence in `readline hints`_.
|
||||
|
||||
:param text: The text with the escape sequence to wrap (a string).
|
||||
:returns: The wrapped text.
|
||||
|
||||
.. _readline hints: http://superuser.com/a/301355
|
||||
"""
|
||||
return '\001' + expr + '\002'
|
||||
|
||||
|
||||
def show_pager(formatted_text, encoding=DEFAULT_ENCODING):
|
||||
"""
|
||||
Print a large text to the terminal using a pager.
|
||||
|
||||
:param formatted_text: The text to print to the terminal (a string).
|
||||
:param encoding: The name of the text encoding used to encode the formatted
|
||||
text if the formatted text is a Unicode string (a string,
|
||||
defaults to :data:`DEFAULT_ENCODING`).
|
||||
|
||||
When :func:`connected_to_terminal()` returns :data:`True` a pager is used
|
||||
to show the text on the terminal, otherwise the text is printed directly
|
||||
without invoking a pager.
|
||||
|
||||
The use of a pager helps to avoid the wall of text effect where the user
|
||||
has to scroll up to see where the output began (not very user friendly).
|
||||
|
||||
Refer to :func:`get_pager_command()` for details about the command line
|
||||
that's used to invoke the pager.
|
||||
"""
|
||||
if connected_to_terminal():
|
||||
# Make sure the selected pager command is available.
|
||||
command_line = get_pager_command(formatted_text)
|
||||
if which(command_line[0]):
|
||||
pager = subprocess.Popen(command_line, stdin=subprocess.PIPE)
|
||||
if is_unicode(formatted_text):
|
||||
formatted_text = formatted_text.encode(encoding)
|
||||
pager.communicate(input=formatted_text)
|
||||
return
|
||||
output(formatted_text)
|
||||
|
||||
|
||||
def terminal_supports_colors(stream=None):
|
||||
"""
|
||||
Check if a stream is connected to a terminal that supports ANSI escape sequences.
|
||||
|
||||
:param stream: The stream to check (a file-like object,
|
||||
defaults to :data:`sys.stdout`).
|
||||
:returns: :data:`True` if the terminal supports ANSI escape sequences,
|
||||
:data:`False` otherwise.
|
||||
|
||||
This function was originally inspired by the implementation of
|
||||
`django.core.management.color.supports_color()
|
||||
<https://github.com/django/django/blob/master/django/core/management/color.py>`_
|
||||
but has since evolved significantly.
|
||||
"""
|
||||
if on_windows():
|
||||
# On Windows support for ANSI escape sequences is not a given.
|
||||
have_ansicon = 'ANSICON' in os.environ
|
||||
have_colorama = 'colorama' in sys.modules
|
||||
have_native_support = have_windows_native_ansi_support()
|
||||
if not (have_ansicon or have_colorama or have_native_support):
|
||||
return False
|
||||
return connected_to_terminal(stream)
|
||||
|
||||
|
||||
def usage(usage_text):
|
||||
"""
|
||||
Print a human friendly usage message to the terminal.
|
||||
|
||||
:param text: The usage message to print (a string).
|
||||
|
||||
This function does two things:
|
||||
|
||||
1. If :data:`sys.stdout` is connected to a terminal (see
|
||||
:func:`connected_to_terminal()`) then the usage message is formatted
|
||||
using :func:`.format_usage()`.
|
||||
2. The usage message is shown using a pager (see :func:`show_pager()`).
|
||||
"""
|
||||
if terminal_supports_colors(sys.stdout):
|
||||
usage_text = format_usage(usage_text)
|
||||
show_pager(usage_text)
|
||||
|
||||
|
||||
def warning(text, *args, **kw):
|
||||
"""
|
||||
Show a warning message on the terminal.
|
||||
|
||||
For details about argument handling please refer to
|
||||
:func:`~humanfriendly.text.format()`.
|
||||
|
||||
Renders the message using :func:`~humanfriendly.text.format()` and writes
|
||||
the resulting string (followed by a newline) to :data:`sys.stderr` using
|
||||
:func:`auto_encode()`.
|
||||
|
||||
If :data:`sys.stderr` is connected to a terminal that supports colors,
|
||||
:func:`ansi_wrap()` is used to color the message in a red font (to make
|
||||
the warning stand out from surrounding text).
|
||||
"""
|
||||
text = coerce_string(text)
|
||||
if terminal_supports_colors(sys.stderr):
|
||||
text = ansi_wrap(text, color='red')
|
||||
auto_encode(sys.stderr, text + '\n', *args, **kw)
|
||||
|
||||
|
||||
# Define aliases for backwards compatibility.
|
||||
define_aliases(
|
||||
module_name=__name__,
|
||||
# In humanfriendly 1.31 the find_meta_variables() and format_usage()
|
||||
# functions were extracted to the new module humanfriendly.usage.
|
||||
find_meta_variables='humanfriendly.usage.find_meta_variables',
|
||||
format_usage='humanfriendly.usage.format_usage',
|
||||
# In humanfriendly 8.0 the html_to_ansi() function and HTMLConverter
|
||||
# class were extracted to the new module humanfriendly.terminal.html.
|
||||
html_to_ansi='humanfriendly.terminal.html.html_to_ansi',
|
||||
HTMLConverter='humanfriendly.terminal.html.HTMLConverter',
|
||||
)
|
||||
@@ -0,0 +1,423 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: February 29, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""Convert HTML with simple text formatting to text with ANSI escape sequences."""
|
||||
|
||||
# Standard library modules.
|
||||
import re
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import HTMLParser, StringIO, name2codepoint, unichr
|
||||
from humanfriendly.text import compact_empty_lines
|
||||
from humanfriendly.terminal import ANSI_COLOR_CODES, ANSI_RESET, ansi_style
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = ('HTMLConverter', 'html_to_ansi')
|
||||
|
||||
|
||||
def html_to_ansi(data, callback=None):
|
||||
"""
|
||||
Convert HTML with simple text formatting to text with ANSI escape sequences.
|
||||
|
||||
:param data: The HTML to convert (a string).
|
||||
:param callback: Optional callback to pass to :class:`HTMLConverter`.
|
||||
:returns: Text with ANSI escape sequences (a string).
|
||||
|
||||
Please refer to the documentation of the :class:`HTMLConverter` class for
|
||||
details about the conversion process (like which tags are supported) and an
|
||||
example with a screenshot.
|
||||
"""
|
||||
converter = HTMLConverter(callback=callback)
|
||||
return converter(data)
|
||||
|
||||
|
||||
class HTMLConverter(HTMLParser):
|
||||
|
||||
"""
|
||||
Convert HTML with simple text formatting to text with ANSI escape sequences.
|
||||
|
||||
The following text styles are supported:
|
||||
|
||||
- Bold: ``<b>``, ``<strong>`` and ``<span style="font-weight: bold;">``
|
||||
- Italic: ``<i>``, ``<em>`` and ``<span style="font-style: italic;">``
|
||||
- Strike-through: ``<del>``, ``<s>`` and ``<span style="text-decoration: line-through;">``
|
||||
- Underline: ``<ins>``, ``<u>`` and ``<span style="text-decoration: underline">``
|
||||
|
||||
Colors can be specified as follows:
|
||||
|
||||
- Foreground color: ``<span style="color: #RRGGBB;">``
|
||||
- Background color: ``<span style="background-color: #RRGGBB;">``
|
||||
|
||||
Here's a small demonstration:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from humanfriendly.text import dedent
|
||||
from humanfriendly.terminal import html_to_ansi
|
||||
|
||||
print(html_to_ansi(dedent('''
|
||||
<b>Hello world!</b>
|
||||
<i>Is this thing on?</i>
|
||||
I guess I can <u>underline</u> or <s>strike-through</s> text?
|
||||
And what about <span style="color: red">color</span>?
|
||||
''')))
|
||||
|
||||
rainbow_colors = [
|
||||
'#FF0000', '#E2571E', '#FF7F00', '#FFFF00', '#00FF00',
|
||||
'#96BF33', '#0000FF', '#4B0082', '#8B00FF', '#FFFFFF',
|
||||
]
|
||||
html_rainbow = "".join('<span style="color: %s">o</span>' % c for c in rainbow_colors)
|
||||
print(html_to_ansi("Let's try a rainbow: %s" % html_rainbow))
|
||||
|
||||
Here's what the results look like:
|
||||
|
||||
.. image:: images/html-to-ansi.png
|
||||
|
||||
Some more details:
|
||||
|
||||
- Nested tags are supported, within reasonable limits.
|
||||
|
||||
- Text in ``<code>`` and ``<pre>`` tags will be highlighted in a
|
||||
different color from the main text (currently this is yellow).
|
||||
|
||||
- ``<a href="URL">TEXT</a>`` is converted to the format "TEXT (URL)" where
|
||||
the uppercase symbols are highlighted in light blue with an underline.
|
||||
|
||||
- ``<div>``, ``<p>`` and ``<pre>`` tags are considered block level tags
|
||||
and are wrapped in vertical whitespace to prevent their content from
|
||||
"running into" surrounding text. This may cause runs of multiple empty
|
||||
lines to be emitted. As a *workaround* the :func:`__call__()` method
|
||||
will automatically call :func:`.compact_empty_lines()` on the generated
|
||||
output before returning it to the caller. Of course this won't work
|
||||
when `output` is set to something like :data:`sys.stdout`.
|
||||
|
||||
- ``<br>`` is converted to a single plain text line break.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- A list of dictionaries with style information is used as a stack where
|
||||
new styling can be pushed and a pop will restore the previous styling.
|
||||
When new styling is pushed, it is merged with (but overrides) the current
|
||||
styling.
|
||||
|
||||
- If you're going to be converting a lot of HTML it might be useful from
|
||||
a performance standpoint to re-use an existing :class:`HTMLConverter`
|
||||
object for unrelated HTML fragments, in this case take a look at the
|
||||
:func:`__call__()` method (it makes this use case very easy).
|
||||
|
||||
.. versionadded:: 4.15
|
||||
:class:`humanfriendly.terminal.HTMLConverter` was added to the
|
||||
`humanfriendly` package during the initial development of my new
|
||||
`chat-archive <https://chat-archive.readthedocs.io/>`_ project, whose
|
||||
command line interface makes for a great demonstration of the
|
||||
flexibility that this feature provides (hint: check out how the search
|
||||
keyword highlighting combines with the regular highlighting).
|
||||
"""
|
||||
|
||||
BLOCK_TAGS = ('div', 'p', 'pre')
|
||||
"""The names of tags that are padded with vertical whitespace."""
|
||||
|
||||
def __init__(self, *args, **kw):
|
||||
"""
|
||||
Initialize an :class:`HTMLConverter` object.
|
||||
|
||||
:param callback: Optional keyword argument to specify a function that
|
||||
will be called to process text fragments before they
|
||||
are emitted on the output stream. Note that link text
|
||||
and preformatted text fragments are not processed by
|
||||
this callback.
|
||||
:param output: Optional keyword argument to redirect the output to the
|
||||
given file-like object. If this is not given a new
|
||||
:class:`~python3:io.StringIO` object is created.
|
||||
"""
|
||||
# Hide our optional keyword arguments from the superclass.
|
||||
self.callback = kw.pop("callback", None)
|
||||
self.output = kw.pop("output", None)
|
||||
# Initialize the superclass.
|
||||
HTMLParser.__init__(self, *args, **kw)
|
||||
|
||||
def __call__(self, data):
|
||||
"""
|
||||
Reset the parser, convert some HTML and get the text with ANSI escape sequences.
|
||||
|
||||
:param data: The HTML to convert to text (a string).
|
||||
:returns: The converted text (only in case `output` is
|
||||
a :class:`~python3:io.StringIO` object).
|
||||
"""
|
||||
self.reset()
|
||||
self.feed(data)
|
||||
self.close()
|
||||
if isinstance(self.output, StringIO):
|
||||
return compact_empty_lines(self.output.getvalue())
|
||||
|
||||
@property
|
||||
def current_style(self):
|
||||
"""Get the current style from the top of the stack (a dictionary)."""
|
||||
return self.stack[-1] if self.stack else {}
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close previously opened ANSI escape sequences.
|
||||
|
||||
This method overrides the same method in the superclass to ensure that
|
||||
an :data:`.ANSI_RESET` code is emitted when parsing reaches the end of
|
||||
the input but a style is still active. This is intended to prevent
|
||||
malformed HTML from messing up terminal output.
|
||||
"""
|
||||
if any(self.stack):
|
||||
self.output.write(ANSI_RESET)
|
||||
self.stack = []
|
||||
HTMLParser.close(self)
|
||||
|
||||
def emit_style(self, style=None):
|
||||
"""
|
||||
Emit an ANSI escape sequence for the given or current style to the output stream.
|
||||
|
||||
:param style: A dictionary with arguments for :func:`.ansi_style()` or
|
||||
:data:`None`, in which case the style at the top of the
|
||||
stack is emitted.
|
||||
"""
|
||||
# Clear the current text styles.
|
||||
self.output.write(ANSI_RESET)
|
||||
# Apply a new text style?
|
||||
style = self.current_style if style is None else style
|
||||
if style:
|
||||
self.output.write(ansi_style(**style))
|
||||
|
||||
def handle_charref(self, value):
|
||||
"""
|
||||
Process a decimal or hexadecimal numeric character reference.
|
||||
|
||||
:param value: The decimal or hexadecimal value (a string).
|
||||
"""
|
||||
self.output.write(unichr(int(value[1:], 16) if value.startswith('x') else int(value)))
|
||||
|
||||
def handle_data(self, data):
|
||||
"""
|
||||
Process textual data.
|
||||
|
||||
:param data: The decoded text (a string).
|
||||
"""
|
||||
if self.link_url:
|
||||
# Link text is captured literally so that we can reliably check
|
||||
# whether the text and the URL of the link are the same string.
|
||||
self.link_text = data
|
||||
elif self.callback and self.preformatted_text_level == 0:
|
||||
# Text that is not part of a link and not preformatted text is
|
||||
# passed to the user defined callback to allow for arbitrary
|
||||
# pre-processing.
|
||||
data = self.callback(data)
|
||||
# All text is emitted unmodified on the output stream.
|
||||
self.output.write(data)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
"""
|
||||
Process the end of an HTML tag.
|
||||
|
||||
:param tag: The name of the tag (a string).
|
||||
"""
|
||||
if tag in ('a', 'b', 'code', 'del', 'em', 'i', 'ins', 'pre', 's', 'strong', 'span', 'u'):
|
||||
old_style = self.current_style
|
||||
# The following conditional isn't necessary for well formed
|
||||
# HTML but prevents raising exceptions on malformed HTML.
|
||||
if self.stack:
|
||||
self.stack.pop(-1)
|
||||
new_style = self.current_style
|
||||
if tag == 'a':
|
||||
if self.urls_match(self.link_text, self.link_url):
|
||||
# Don't render the URL when it's part of the link text.
|
||||
self.emit_style(new_style)
|
||||
else:
|
||||
self.emit_style(new_style)
|
||||
self.output.write(' (')
|
||||
self.emit_style(old_style)
|
||||
self.output.write(self.render_url(self.link_url))
|
||||
self.emit_style(new_style)
|
||||
self.output.write(')')
|
||||
else:
|
||||
self.emit_style(new_style)
|
||||
if tag in ('code', 'pre'):
|
||||
self.preformatted_text_level -= 1
|
||||
if tag in self.BLOCK_TAGS:
|
||||
# Emit an empty line after block level tags.
|
||||
self.output.write('\n\n')
|
||||
|
||||
def handle_entityref(self, name):
|
||||
"""
|
||||
Process a named character reference.
|
||||
|
||||
:param name: The name of the character reference (a string).
|
||||
"""
|
||||
self.output.write(unichr(name2codepoint[name]))
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
"""
|
||||
Process the start of an HTML tag.
|
||||
|
||||
:param tag: The name of the tag (a string).
|
||||
:param attrs: A list of tuples with two strings each.
|
||||
"""
|
||||
if tag in self.BLOCK_TAGS:
|
||||
# Emit an empty line before block level tags.
|
||||
self.output.write('\n\n')
|
||||
if tag == 'a':
|
||||
self.push_styles(color='blue', bright=True, underline=True)
|
||||
# Store the URL that the link points to for later use, so that we
|
||||
# can render the link text before the URL (with the reasoning that
|
||||
# this is the most intuitive way to present a link in a plain text
|
||||
# interface).
|
||||
self.link_url = next((v for n, v in attrs if n == 'href'), '')
|
||||
elif tag == 'b' or tag == 'strong':
|
||||
self.push_styles(bold=True)
|
||||
elif tag == 'br':
|
||||
self.output.write('\n')
|
||||
elif tag == 'code' or tag == 'pre':
|
||||
self.push_styles(color='yellow')
|
||||
self.preformatted_text_level += 1
|
||||
elif tag == 'del' or tag == 's':
|
||||
self.push_styles(strike_through=True)
|
||||
elif tag == 'em' or tag == 'i':
|
||||
self.push_styles(italic=True)
|
||||
elif tag == 'ins' or tag == 'u':
|
||||
self.push_styles(underline=True)
|
||||
elif tag == 'span':
|
||||
styles = {}
|
||||
css = next((v for n, v in attrs if n == 'style'), "")
|
||||
for rule in css.split(';'):
|
||||
name, _, value = rule.partition(':')
|
||||
name = name.strip()
|
||||
value = value.strip()
|
||||
if name == 'background-color':
|
||||
styles['background'] = self.parse_color(value)
|
||||
elif name == 'color':
|
||||
styles['color'] = self.parse_color(value)
|
||||
elif name == 'font-style' and value == 'italic':
|
||||
styles['italic'] = True
|
||||
elif name == 'font-weight' and value == 'bold':
|
||||
styles['bold'] = True
|
||||
elif name == 'text-decoration' and value == 'line-through':
|
||||
styles['strike_through'] = True
|
||||
elif name == 'text-decoration' and value == 'underline':
|
||||
styles['underline'] = True
|
||||
self.push_styles(**styles)
|
||||
|
||||
def normalize_url(self, url):
|
||||
"""
|
||||
Normalize a URL to enable string equality comparison.
|
||||
|
||||
:param url: The URL to normalize (a string).
|
||||
:returns: The normalized URL (a string).
|
||||
"""
|
||||
return re.sub('^mailto:', '', url)
|
||||
|
||||
def parse_color(self, value):
|
||||
"""
|
||||
Convert a CSS color to something that :func:`.ansi_style()` understands.
|
||||
|
||||
:param value: A string like ``rgb(1,2,3)``, ``#AABBCC`` or ``yellow``.
|
||||
:returns: A color value supported by :func:`.ansi_style()` or :data:`None`.
|
||||
"""
|
||||
# Parse an 'rgb(N,N,N)' expression.
|
||||
if value.startswith('rgb'):
|
||||
tokens = re.findall(r'\d+', value)
|
||||
if len(tokens) == 3:
|
||||
return tuple(map(int, tokens))
|
||||
# Parse an '#XXXXXX' expression.
|
||||
elif value.startswith('#'):
|
||||
value = value[1:]
|
||||
length = len(value)
|
||||
if length == 6:
|
||||
# Six hex digits (proper notation).
|
||||
return (
|
||||
int(value[:2], 16),
|
||||
int(value[2:4], 16),
|
||||
int(value[4:6], 16),
|
||||
)
|
||||
elif length == 3:
|
||||
# Three hex digits (shorthand).
|
||||
return (
|
||||
int(value[0], 16),
|
||||
int(value[1], 16),
|
||||
int(value[2], 16),
|
||||
)
|
||||
# Try to recognize a named color.
|
||||
value = value.lower()
|
||||
if value in ANSI_COLOR_CODES:
|
||||
return value
|
||||
|
||||
def push_styles(self, **changes):
|
||||
"""
|
||||
Push new style information onto the stack.
|
||||
|
||||
:param changes: Any keyword arguments are passed on to :func:`.ansi_style()`.
|
||||
|
||||
This method is a helper for :func:`handle_starttag()`
|
||||
that does the following:
|
||||
|
||||
1. Make a copy of the current styles (from the top of the stack),
|
||||
2. Apply the given `changes` to the copy of the current styles,
|
||||
3. Add the new styles to the stack,
|
||||
4. Emit the appropriate ANSI escape sequence to the output stream.
|
||||
"""
|
||||
prototype = self.current_style
|
||||
if prototype:
|
||||
new_style = dict(prototype)
|
||||
new_style.update(changes)
|
||||
else:
|
||||
new_style = changes
|
||||
self.stack.append(new_style)
|
||||
self.emit_style(new_style)
|
||||
|
||||
def render_url(self, url):
|
||||
"""
|
||||
Prepare a URL for rendering on the terminal.
|
||||
|
||||
:param url: The URL to simplify (a string).
|
||||
:returns: The simplified URL (a string).
|
||||
|
||||
This method pre-processes a URL before rendering on the terminal. The
|
||||
following modifications are made:
|
||||
|
||||
- The ``mailto:`` prefix is stripped.
|
||||
- Spaces are converted to ``%20``.
|
||||
- A trailing parenthesis is converted to ``%29``.
|
||||
"""
|
||||
url = re.sub('^mailto:', '', url)
|
||||
url = re.sub(' ', '%20', url)
|
||||
url = re.sub(r'\)$', '%29', url)
|
||||
return url
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the state of the HTML parser and ANSI converter.
|
||||
|
||||
When `output` is a :class:`~python3:io.StringIO` object a new
|
||||
instance will be created (and the old one garbage collected).
|
||||
"""
|
||||
# Reset the state of the superclass.
|
||||
HTMLParser.reset(self)
|
||||
# Reset our instance variables.
|
||||
self.link_text = None
|
||||
self.link_url = None
|
||||
self.preformatted_text_level = 0
|
||||
if self.output is None or isinstance(self.output, StringIO):
|
||||
# If the caller specified something like output=sys.stdout then it
|
||||
# doesn't make much sense to negate that choice here in reset().
|
||||
self.output = StringIO()
|
||||
self.stack = []
|
||||
|
||||
def urls_match(self, a, b):
|
||||
"""
|
||||
Compare two URLs for equality using :func:`normalize_url()`.
|
||||
|
||||
:param a: A string containing a URL.
|
||||
:param b: A string containing a URL.
|
||||
:returns: :data:`True` if the URLs are the same, :data:`False` otherwise.
|
||||
|
||||
This method is used by :func:`handle_endtag()` to omit the URL of a
|
||||
hyperlink (``<a href="...">``) when the link text is that same URL.
|
||||
"""
|
||||
return self.normalize_url(a) == self.normalize_url(b)
|
||||
@@ -0,0 +1,310 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 1, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Support for spinners that represent progress on interactive terminals.
|
||||
|
||||
The :class:`Spinner` class shows a "spinner" on the terminal to let the user
|
||||
know that something is happening during long running operations that would
|
||||
otherwise be silent (leaving the user to wonder what they're waiting for).
|
||||
Below are some visual examples that should illustrate the point.
|
||||
|
||||
**Simple spinners:**
|
||||
|
||||
Here's a screen capture that shows the simplest form of spinner:
|
||||
|
||||
.. image:: images/spinner-basic.gif
|
||||
:alt: Animated screen capture of a simple spinner.
|
||||
|
||||
The following code was used to create the spinner above:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from humanfriendly import Spinner
|
||||
|
||||
with Spinner(label="Downloading") as spinner:
|
||||
for i in itertools.count():
|
||||
# Do something useful here.
|
||||
time.sleep(0.1)
|
||||
# Advance the spinner.
|
||||
spinner.step()
|
||||
|
||||
**Spinners that show elapsed time:**
|
||||
|
||||
Here's a spinner that shows the elapsed time since it started:
|
||||
|
||||
.. image:: images/spinner-with-timer.gif
|
||||
:alt: Animated screen capture of a spinner showing elapsed time.
|
||||
|
||||
The following code was used to create the spinner above:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from humanfriendly import Spinner, Timer
|
||||
|
||||
with Spinner(label="Downloading", timer=Timer()) as spinner:
|
||||
for i in itertools.count():
|
||||
# Do something useful here.
|
||||
time.sleep(0.1)
|
||||
# Advance the spinner.
|
||||
spinner.step()
|
||||
|
||||
**Spinners that show progress:**
|
||||
|
||||
Here's a spinner that shows a progress percentage:
|
||||
|
||||
.. image:: images/spinner-with-progress.gif
|
||||
:alt: Animated screen capture of spinner showing progress.
|
||||
|
||||
The following code was used to create the spinner above:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import itertools
|
||||
import random
|
||||
import time
|
||||
from humanfriendly import Spinner, Timer
|
||||
|
||||
with Spinner(label="Downloading", total=100) as spinner:
|
||||
progress = 0
|
||||
while progress < 100:
|
||||
# Do something useful here.
|
||||
time.sleep(0.1)
|
||||
# Advance the spinner.
|
||||
spinner.step(progress)
|
||||
# Determine the new progress value.
|
||||
progress += random.random() * 5
|
||||
|
||||
If you want to provide user feedback during a long running operation but it's
|
||||
not practical to periodically call the :func:`~Spinner.step()` method consider
|
||||
using :class:`AutomaticSpinner` instead.
|
||||
|
||||
As you may already have noticed in the examples above, :class:`Spinner` objects
|
||||
can be used as context managers to automatically call :func:`Spinner.clear()`
|
||||
when the spinner ends.
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import multiprocessing
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly import Timer
|
||||
from humanfriendly.deprecation import deprecated_args
|
||||
from humanfriendly.terminal import ANSI_ERASE_LINE
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = ("AutomaticSpinner", "GLYPHS", "MINIMUM_INTERVAL", "Spinner")
|
||||
|
||||
GLYPHS = ["-", "\\", "|", "/"]
|
||||
"""A list of strings with characters that together form a crude animation :-)."""
|
||||
|
||||
MINIMUM_INTERVAL = 0.2
|
||||
"""Spinners are redrawn with a frequency no higher than this number (a floating point number of seconds)."""
|
||||
|
||||
|
||||
class Spinner(object):
|
||||
|
||||
"""Show a spinner on the terminal as a simple means of feedback to the user."""
|
||||
|
||||
@deprecated_args('label', 'total', 'stream', 'interactive', 'timer')
|
||||
def __init__(self, **options):
|
||||
"""
|
||||
Initialize a :class:`Spinner` object.
|
||||
|
||||
:param label:
|
||||
|
||||
The label for the spinner (a string or :data:`None`, defaults to
|
||||
:data:`None`).
|
||||
|
||||
:param total:
|
||||
|
||||
The expected number of steps (an integer or :data:`None`). If this is
|
||||
provided the spinner will show a progress percentage.
|
||||
|
||||
:param stream:
|
||||
|
||||
The output stream to show the spinner on (a file-like object,
|
||||
defaults to :data:`sys.stderr`).
|
||||
|
||||
:param interactive:
|
||||
|
||||
:data:`True` to enable rendering of the spinner, :data:`False` to
|
||||
disable (defaults to the result of ``stream.isatty()``).
|
||||
|
||||
:param timer:
|
||||
|
||||
A :class:`.Timer` object (optional). If this is given the spinner
|
||||
will show the elapsed time according to the timer.
|
||||
|
||||
:param interval:
|
||||
|
||||
The spinner will be updated at most once every this many seconds
|
||||
(a floating point number, defaults to :data:`MINIMUM_INTERVAL`).
|
||||
|
||||
:param glyphs:
|
||||
|
||||
A list of strings with single characters that are drawn in the same
|
||||
place in succession to implement a simple animated effect (defaults
|
||||
to :data:`GLYPHS`).
|
||||
"""
|
||||
# Store initializer arguments.
|
||||
self.interactive = options.get('interactive')
|
||||
self.interval = options.get('interval', MINIMUM_INTERVAL)
|
||||
self.label = options.get('label')
|
||||
self.states = options.get('glyphs', GLYPHS)
|
||||
self.stream = options.get('stream', sys.stderr)
|
||||
self.timer = options.get('timer')
|
||||
self.total = options.get('total')
|
||||
# Define instance variables.
|
||||
self.counter = 0
|
||||
self.last_update = 0
|
||||
# Try to automatically discover whether the stream is connected to
|
||||
# a terminal, but don't fail if no isatty() method is available.
|
||||
if self.interactive is None:
|
||||
try:
|
||||
self.interactive = self.stream.isatty()
|
||||
except Exception:
|
||||
self.interactive = False
|
||||
|
||||
def step(self, progress=0, label=None):
|
||||
"""
|
||||
Advance the spinner by one step and redraw it.
|
||||
|
||||
:param progress: The number of the current step, relative to the total
|
||||
given to the :class:`Spinner` constructor (an integer,
|
||||
optional). If not provided the spinner will not show
|
||||
progress.
|
||||
:param label: The label to use while redrawing (a string, optional). If
|
||||
not provided the label given to the :class:`Spinner`
|
||||
constructor is used instead.
|
||||
|
||||
This method advances the spinner by one step without starting a new
|
||||
line, causing an animated effect which is very simple but much nicer
|
||||
than waiting for a prompt which is completely silent for a long time.
|
||||
|
||||
.. note:: This method uses time based rate limiting to avoid redrawing
|
||||
the spinner too frequently. If you know you're dealing with
|
||||
code that will call :func:`step()` at a high frequency,
|
||||
consider using :func:`sleep()` to avoid creating the
|
||||
equivalent of a busy loop that's rate limiting the spinner
|
||||
99% of the time.
|
||||
"""
|
||||
if self.interactive:
|
||||
time_now = time.time()
|
||||
if time_now - self.last_update >= self.interval:
|
||||
self.last_update = time_now
|
||||
state = self.states[self.counter % len(self.states)]
|
||||
label = label or self.label
|
||||
if not label:
|
||||
raise Exception("No label set for spinner!")
|
||||
elif self.total and progress:
|
||||
label = "%s: %.2f%%" % (label, progress / (self.total / 100.0))
|
||||
elif self.timer and self.timer.elapsed_time > 2:
|
||||
label = "%s (%s)" % (label, self.timer.rounded)
|
||||
self.stream.write("%s %s %s ..\r" % (ANSI_ERASE_LINE, state, label))
|
||||
self.counter += 1
|
||||
|
||||
def sleep(self):
|
||||
"""
|
||||
Sleep for a short period before redrawing the spinner.
|
||||
|
||||
This method is useful when you know you're dealing with code that will
|
||||
call :func:`step()` at a high frequency. It will sleep for the interval
|
||||
with which the spinner is redrawn (less than a second). This avoids
|
||||
creating the equivalent of a busy loop that's rate limiting the
|
||||
spinner 99% of the time.
|
||||
|
||||
This method doesn't redraw the spinner, you still have to call
|
||||
:func:`step()` in order to do that.
|
||||
"""
|
||||
time.sleep(MINIMUM_INTERVAL)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Clear the spinner.
|
||||
|
||||
The next line which is shown on the standard output or error stream
|
||||
after calling this method will overwrite the line that used to show the
|
||||
spinner.
|
||||
"""
|
||||
if self.interactive:
|
||||
self.stream.write(ANSI_ERASE_LINE)
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Enable the use of spinners as context managers.
|
||||
|
||||
:returns: The :class:`Spinner` object.
|
||||
"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Clear the spinner when leaving the context."""
|
||||
self.clear()
|
||||
|
||||
|
||||
class AutomaticSpinner(object):
|
||||
|
||||
"""
|
||||
Show a spinner on the terminal that automatically starts animating.
|
||||
|
||||
This class shows a spinner on the terminal (just like :class:`Spinner`
|
||||
does) that automatically starts animating. This class should be used as a
|
||||
context manager using the :keyword:`with` statement. The animation
|
||||
continues for as long as the context is active.
|
||||
|
||||
:class:`AutomaticSpinner` provides an alternative to :class:`Spinner`
|
||||
for situations where it is not practical for the caller to periodically
|
||||
call :func:`~Spinner.step()` to advance the animation, e.g. because
|
||||
you're performing a blocking call and don't fancy implementing threading or
|
||||
subprocess handling just to provide some user feedback.
|
||||
|
||||
This works using the :mod:`multiprocessing` module by spawning a
|
||||
subprocess to render the spinner while the main process is busy doing
|
||||
something more useful. By using the :keyword:`with` statement you're
|
||||
guaranteed that the subprocess is properly terminated at the appropriate
|
||||
time.
|
||||
"""
|
||||
|
||||
def __init__(self, label, show_time=True):
|
||||
"""
|
||||
Initialize an automatic spinner.
|
||||
|
||||
:param label: The label for the spinner (a string).
|
||||
:param show_time: If this is :data:`True` (the default) then the spinner
|
||||
shows elapsed time.
|
||||
"""
|
||||
self.label = label
|
||||
self.show_time = show_time
|
||||
self.shutdown_event = multiprocessing.Event()
|
||||
self.subprocess = multiprocessing.Process(target=self._target)
|
||||
|
||||
def __enter__(self):
|
||||
"""Enable the use of automatic spinners as context managers."""
|
||||
self.subprocess.start()
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Enable the use of automatic spinners as context managers."""
|
||||
self.shutdown_event.set()
|
||||
self.subprocess.join()
|
||||
|
||||
def _target(self):
|
||||
try:
|
||||
timer = Timer() if self.show_time else None
|
||||
with Spinner(label=self.label, timer=timer) as spinner:
|
||||
while not self.shutdown_event.is_set():
|
||||
spinner.step()
|
||||
spinner.sleep()
|
||||
except KeyboardInterrupt:
|
||||
# Swallow Control-C signals without producing a nasty traceback that
|
||||
# won't make any sense to the average user.
|
||||
pass
|
||||
@@ -0,0 +1,669 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: March 6, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Utility classes and functions that make it easy to write :mod:`unittest` compatible test suites.
|
||||
|
||||
Over the years I've developed the habit of writing test suites for Python
|
||||
projects using the :mod:`unittest` module. During those years I've come to know
|
||||
:pypi:`pytest` and in fact I use :pypi:`pytest` to run my test suites (due to
|
||||
its much better error reporting) but I've yet to publish a test suite that
|
||||
*requires* :pypi:`pytest`. I have several reasons for doing so:
|
||||
|
||||
- It's nice to keep my test suites as simple and accessible as possible and
|
||||
not requiring a specific test runner is part of that attitude.
|
||||
|
||||
- Whereas :mod:`unittest` is quite explicit, :pypi:`pytest` contains a lot of
|
||||
magic, which kind of contradicts the Python mantra "explicit is better than
|
||||
implicit" (IMHO).
|
||||
"""
|
||||
|
||||
# Standard library module
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import pipes
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import StringIO
|
||||
from humanfriendly.text import random_string
|
||||
|
||||
# Initialize a logger for this module.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A unique object reference used to detect missing attributes.
|
||||
NOTHING = object()
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'CallableTimedOut',
|
||||
'CaptureBuffer',
|
||||
'CaptureOutput',
|
||||
'ContextManager',
|
||||
'CustomSearchPath',
|
||||
'MockedProgram',
|
||||
'PatchedAttribute',
|
||||
'PatchedItem',
|
||||
'TemporaryDirectory',
|
||||
'TestCase',
|
||||
'configure_logging',
|
||||
'make_dirs',
|
||||
'retry',
|
||||
'run_cli',
|
||||
'skip_on_raise',
|
||||
'touch',
|
||||
)
|
||||
|
||||
|
||||
def configure_logging(log_level=logging.DEBUG):
|
||||
"""configure_logging(log_level=logging.DEBUG)
|
||||
Automatically configure logging to the terminal.
|
||||
|
||||
:param log_level: The log verbosity (a number, defaults
|
||||
to :mod:`logging.DEBUG <logging>`).
|
||||
|
||||
When :mod:`coloredlogs` is installed :func:`coloredlogs.install()` will be
|
||||
used to configure logging to the terminal. When this fails with an
|
||||
:exc:`~exceptions.ImportError` then :func:`logging.basicConfig()` is used
|
||||
as a fall back.
|
||||
"""
|
||||
try:
|
||||
import coloredlogs
|
||||
coloredlogs.install(level=log_level)
|
||||
except ImportError:
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s %(name)s[%(process)d] %(levelname)s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def make_dirs(pathname):
|
||||
"""
|
||||
Create missing directories.
|
||||
|
||||
:param pathname: The pathname of a directory (a string).
|
||||
"""
|
||||
if not os.path.isdir(pathname):
|
||||
os.makedirs(pathname)
|
||||
|
||||
|
||||
def retry(func, timeout=60, exc_type=AssertionError):
|
||||
"""retry(func, timeout=60, exc_type=AssertionError)
|
||||
Retry a function until assertions no longer fail.
|
||||
|
||||
:param func: A callable. When the callable returns
|
||||
:data:`False` it will also be retried.
|
||||
:param timeout: The number of seconds after which to abort (a number,
|
||||
defaults to 60).
|
||||
:param exc_type: The type of exceptions to retry (defaults
|
||||
to :exc:`~exceptions.AssertionError`).
|
||||
:returns: The value returned by `func`.
|
||||
:raises: Once the timeout has expired :func:`retry()` will raise the
|
||||
previously retried assertion error. When `func` keeps returning
|
||||
:data:`False` until `timeout` expires :exc:`CallableTimedOut`
|
||||
will be raised.
|
||||
|
||||
This function sleeps between retries to avoid claiming CPU cycles we don't
|
||||
need. It starts by sleeping for 0.1 second but adjusts this to one second
|
||||
as the number of retries grows.
|
||||
"""
|
||||
pause = 0.1
|
||||
timeout += time.time()
|
||||
while True:
|
||||
try:
|
||||
result = func()
|
||||
if result is not False:
|
||||
return result
|
||||
except exc_type:
|
||||
if time.time() > timeout:
|
||||
raise
|
||||
else:
|
||||
if time.time() > timeout:
|
||||
raise CallableTimedOut()
|
||||
time.sleep(pause)
|
||||
if pause < 1:
|
||||
pause *= 2
|
||||
|
||||
|
||||
def run_cli(entry_point, *arguments, **options):
|
||||
"""
|
||||
Test a command line entry point.
|
||||
|
||||
:param entry_point: The function that implements the command line interface
|
||||
(a callable).
|
||||
:param arguments: Any positional arguments (strings) become the command
|
||||
line arguments (:data:`sys.argv` items 1-N).
|
||||
:param options: The following keyword arguments are supported:
|
||||
|
||||
**capture**
|
||||
Whether to use :class:`CaptureOutput`. Defaults
|
||||
to :data:`True` but can be disabled by passing
|
||||
:data:`False` instead.
|
||||
**input**
|
||||
Refer to :class:`CaptureOutput`.
|
||||
**merged**
|
||||
Refer to :class:`CaptureOutput`.
|
||||
**program_name**
|
||||
Used to set :data:`sys.argv` item 0.
|
||||
:returns: A tuple with two values:
|
||||
|
||||
1. The return code (an integer).
|
||||
2. The captured output (a string).
|
||||
"""
|
||||
# Add the `program_name' option to the arguments.
|
||||
arguments = list(arguments)
|
||||
arguments.insert(0, options.pop('program_name', sys.executable))
|
||||
# Log the command line arguments (and the fact that we're about to call the
|
||||
# command line entry point function).
|
||||
logger.debug("Calling command line entry point with arguments: %s", arguments)
|
||||
# Prepare to capture the return code and output even if the command line
|
||||
# interface raises an exception (whether the exception type is SystemExit
|
||||
# or something else).
|
||||
returncode = 0
|
||||
stdout = None
|
||||
stderr = None
|
||||
try:
|
||||
# Temporarily override sys.argv.
|
||||
with PatchedAttribute(sys, 'argv', arguments):
|
||||
# Manipulate the standard input/output/error streams?
|
||||
options['enabled'] = options.pop('capture', True)
|
||||
with CaptureOutput(**options) as capturer:
|
||||
try:
|
||||
# Call the command line interface.
|
||||
entry_point()
|
||||
finally:
|
||||
# Get the output even if an exception is raised.
|
||||
stdout = capturer.stdout.getvalue()
|
||||
stderr = capturer.stderr.getvalue()
|
||||
# Reconfigure logging to the terminal because it is very
|
||||
# likely that the entry point function has changed the
|
||||
# configured log level.
|
||||
configure_logging()
|
||||
except BaseException as e:
|
||||
if isinstance(e, SystemExit):
|
||||
logger.debug("Intercepting return code %s from SystemExit exception.", e.code)
|
||||
returncode = e.code
|
||||
else:
|
||||
logger.warning("Defaulting return code to 1 due to raised exception.", exc_info=True)
|
||||
returncode = 1
|
||||
else:
|
||||
logger.debug("Command line entry point returned successfully!")
|
||||
# Always log the output captured on stdout/stderr, to make it easier to
|
||||
# diagnose test failures (but avoid duplicate logging when merged=True).
|
||||
is_merged = options.get('merged', False)
|
||||
merged_streams = [('merged streams', stdout)]
|
||||
separate_streams = [('stdout', stdout), ('stderr', stderr)]
|
||||
streams = merged_streams if is_merged else separate_streams
|
||||
for name, value in streams:
|
||||
if value:
|
||||
logger.debug("Output on %s:\n%s", name, value)
|
||||
else:
|
||||
logger.debug("No output on %s.", name)
|
||||
return returncode, stdout
|
||||
|
||||
|
||||
def skip_on_raise(*exc_types):
|
||||
"""
|
||||
Decorate a test function to translation specific exception types to :exc:`unittest.SkipTest`.
|
||||
|
||||
:param exc_types: One or more positional arguments give the exception
|
||||
types to be translated to :exc:`unittest.SkipTest`.
|
||||
:returns: A decorator function specialized to `exc_types`.
|
||||
"""
|
||||
def decorator(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(*args, **kw):
|
||||
try:
|
||||
return function(*args, **kw)
|
||||
except exc_types as e:
|
||||
logger.debug("Translating exception to unittest.SkipTest ..", exc_info=True)
|
||||
raise unittest.SkipTest("skipping test because %s was raised" % type(e))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def touch(filename):
|
||||
"""
|
||||
The equivalent of the UNIX :man:`touch` program in Python.
|
||||
|
||||
:param filename: The pathname of the file to touch (a string).
|
||||
|
||||
Note that missing directories are automatically created using
|
||||
:func:`make_dirs()`.
|
||||
"""
|
||||
make_dirs(os.path.dirname(filename))
|
||||
with open(filename, 'a'):
|
||||
os.utime(filename, None)
|
||||
|
||||
|
||||
class CallableTimedOut(Exception):
|
||||
|
||||
"""Raised by :func:`retry()` when the timeout expires."""
|
||||
|
||||
|
||||
class ContextManager(object):
|
||||
|
||||
"""Base class to enable composition of context managers."""
|
||||
|
||||
def __enter__(self):
|
||||
"""Enable use as context managers."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Enable use as context managers."""
|
||||
|
||||
|
||||
class PatchedAttribute(ContextManager):
|
||||
|
||||
"""Context manager that temporary replaces an object attribute using :func:`setattr()`."""
|
||||
|
||||
def __init__(self, obj, name, value):
|
||||
"""
|
||||
Initialize a :class:`PatchedAttribute` object.
|
||||
|
||||
:param obj: The object to patch.
|
||||
:param name: An attribute name.
|
||||
:param value: The value to set.
|
||||
"""
|
||||
self.object_to_patch = obj
|
||||
self.attribute_to_patch = name
|
||||
self.patched_value = value
|
||||
self.original_value = NOTHING
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Replace (patch) the attribute.
|
||||
|
||||
:returns: The object whose attribute was patched.
|
||||
"""
|
||||
# Enable composition of context managers.
|
||||
super(PatchedAttribute, self).__enter__()
|
||||
# Patch the object's attribute.
|
||||
self.original_value = getattr(self.object_to_patch, self.attribute_to_patch, NOTHING)
|
||||
setattr(self.object_to_patch, self.attribute_to_patch, self.patched_value)
|
||||
return self.object_to_patch
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Restore the attribute to its original value."""
|
||||
# Enable composition of context managers.
|
||||
super(PatchedAttribute, self).__exit__(exc_type, exc_value, traceback)
|
||||
# Restore the object's attribute.
|
||||
if self.original_value is NOTHING:
|
||||
delattr(self.object_to_patch, self.attribute_to_patch)
|
||||
else:
|
||||
setattr(self.object_to_patch, self.attribute_to_patch, self.original_value)
|
||||
|
||||
|
||||
class PatchedItem(ContextManager):
|
||||
|
||||
"""Context manager that temporary replaces an object item using :meth:`~object.__setitem__()`."""
|
||||
|
||||
def __init__(self, obj, item, value):
|
||||
"""
|
||||
Initialize a :class:`PatchedItem` object.
|
||||
|
||||
:param obj: The object to patch.
|
||||
:param item: The item to patch.
|
||||
:param value: The value to set.
|
||||
"""
|
||||
self.object_to_patch = obj
|
||||
self.item_to_patch = item
|
||||
self.patched_value = value
|
||||
self.original_value = NOTHING
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Replace (patch) the item.
|
||||
|
||||
:returns: The object whose item was patched.
|
||||
"""
|
||||
# Enable composition of context managers.
|
||||
super(PatchedItem, self).__enter__()
|
||||
# Patch the object's item.
|
||||
try:
|
||||
self.original_value = self.object_to_patch[self.item_to_patch]
|
||||
except KeyError:
|
||||
self.original_value = NOTHING
|
||||
self.object_to_patch[self.item_to_patch] = self.patched_value
|
||||
return self.object_to_patch
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Restore the item to its original value."""
|
||||
# Enable composition of context managers.
|
||||
super(PatchedItem, self).__exit__(exc_type, exc_value, traceback)
|
||||
# Restore the object's item.
|
||||
if self.original_value is NOTHING:
|
||||
del self.object_to_patch[self.item_to_patch]
|
||||
else:
|
||||
self.object_to_patch[self.item_to_patch] = self.original_value
|
||||
|
||||
|
||||
class TemporaryDirectory(ContextManager):
|
||||
|
||||
"""
|
||||
Easy temporary directory creation & cleanup using the :keyword:`with` statement.
|
||||
|
||||
Here's an example of how to use this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
# Do something useful here.
|
||||
assert os.path.isdir(directory)
|
||||
"""
|
||||
|
||||
def __init__(self, **options):
|
||||
"""
|
||||
Initialize a :class:`TemporaryDirectory` object.
|
||||
|
||||
:param options: Any keyword arguments are passed on to
|
||||
:func:`tempfile.mkdtemp()`.
|
||||
"""
|
||||
self.mkdtemp_options = options
|
||||
self.temporary_directory = None
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Create the temporary directory using :func:`tempfile.mkdtemp()`.
|
||||
|
||||
:returns: The pathname of the directory (a string).
|
||||
"""
|
||||
# Enable composition of context managers.
|
||||
super(TemporaryDirectory, self).__enter__()
|
||||
# Create the temporary directory.
|
||||
self.temporary_directory = tempfile.mkdtemp(**self.mkdtemp_options)
|
||||
return self.temporary_directory
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Cleanup the temporary directory using :func:`shutil.rmtree()`."""
|
||||
# Enable composition of context managers.
|
||||
super(TemporaryDirectory, self).__exit__(exc_type, exc_value, traceback)
|
||||
# Cleanup the temporary directory.
|
||||
if self.temporary_directory is not None:
|
||||
shutil.rmtree(self.temporary_directory)
|
||||
self.temporary_directory = None
|
||||
|
||||
|
||||
class MockedHomeDirectory(PatchedItem, TemporaryDirectory):
|
||||
|
||||
"""
|
||||
Context manager to temporarily change ``$HOME`` (the current user's profile directory).
|
||||
|
||||
This class is a composition of the :class:`PatchedItem` and
|
||||
:class:`TemporaryDirectory` context managers.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a :class:`MockedHomeDirectory` object."""
|
||||
PatchedItem.__init__(self, os.environ, 'HOME', os.environ.get('HOME'))
|
||||
TemporaryDirectory.__init__(self)
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Activate the custom ``$PATH``.
|
||||
|
||||
:returns: The pathname of the directory that has
|
||||
been added to ``$PATH`` (a string).
|
||||
"""
|
||||
# Get the temporary directory.
|
||||
directory = TemporaryDirectory.__enter__(self)
|
||||
# Override the value to patch now that we have
|
||||
# the pathname of the temporary directory.
|
||||
self.patched_value = directory
|
||||
# Temporary patch $HOME.
|
||||
PatchedItem.__enter__(self)
|
||||
# Pass the pathname of the temporary directory to the caller.
|
||||
return directory
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Deactivate the custom ``$HOME``."""
|
||||
super(MockedHomeDirectory, self).__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
|
||||
class CustomSearchPath(PatchedItem, TemporaryDirectory):
|
||||
|
||||
"""
|
||||
Context manager to temporarily customize ``$PATH`` (the executable search path).
|
||||
|
||||
This class is a composition of the :class:`PatchedItem` and
|
||||
:class:`TemporaryDirectory` context managers.
|
||||
"""
|
||||
|
||||
def __init__(self, isolated=False):
|
||||
"""
|
||||
Initialize a :class:`CustomSearchPath` object.
|
||||
|
||||
:param isolated: :data:`True` to clear the original search path,
|
||||
:data:`False` to add the temporary directory to the
|
||||
start of the search path.
|
||||
"""
|
||||
# Initialize our own instance variables.
|
||||
self.isolated_search_path = isolated
|
||||
# Selectively initialize our superclasses.
|
||||
PatchedItem.__init__(self, os.environ, 'PATH', self.current_search_path)
|
||||
TemporaryDirectory.__init__(self)
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Activate the custom ``$PATH``.
|
||||
|
||||
:returns: The pathname of the directory that has
|
||||
been added to ``$PATH`` (a string).
|
||||
"""
|
||||
# Get the temporary directory.
|
||||
directory = TemporaryDirectory.__enter__(self)
|
||||
# Override the value to patch now that we have
|
||||
# the pathname of the temporary directory.
|
||||
self.patched_value = (
|
||||
directory if self.isolated_search_path
|
||||
else os.pathsep.join([directory] + self.current_search_path.split(os.pathsep))
|
||||
)
|
||||
# Temporary patch the $PATH.
|
||||
PatchedItem.__enter__(self)
|
||||
# Pass the pathname of the temporary directory to the caller
|
||||
# because they may want to `install' custom executables.
|
||||
return directory
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Deactivate the custom ``$PATH``."""
|
||||
super(CustomSearchPath, self).__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
@property
|
||||
def current_search_path(self):
|
||||
"""The value of ``$PATH`` or :data:`os.defpath` (a string)."""
|
||||
return os.environ.get('PATH', os.defpath)
|
||||
|
||||
|
||||
class MockedProgram(CustomSearchPath):
|
||||
|
||||
"""
|
||||
Context manager to mock the existence of a program (executable).
|
||||
|
||||
This class extends the functionality of :class:`CustomSearchPath`.
|
||||
"""
|
||||
|
||||
def __init__(self, name, returncode=0, script=None):
|
||||
"""
|
||||
Initialize a :class:`MockedProgram` object.
|
||||
|
||||
:param name: The name of the program (a string).
|
||||
:param returncode: The return code that the program should emit (a
|
||||
number, defaults to zero).
|
||||
:param script: Shell script code to include in the mocked program (a
|
||||
string or :data:`None`). This can be used to mock a
|
||||
program that is expected to generate specific output.
|
||||
"""
|
||||
# Initialize our own instance variables.
|
||||
self.program_name = name
|
||||
self.program_returncode = returncode
|
||||
self.program_script = script
|
||||
self.program_signal_file = None
|
||||
# Initialize our superclasses.
|
||||
super(MockedProgram, self).__init__()
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Create the mock program.
|
||||
|
||||
:returns: The pathname of the directory that has
|
||||
been added to ``$PATH`` (a string).
|
||||
"""
|
||||
directory = super(MockedProgram, self).__enter__()
|
||||
self.program_signal_file = os.path.join(directory, 'program-was-run-%s' % random_string(10))
|
||||
pathname = os.path.join(directory, self.program_name)
|
||||
with open(pathname, 'w') as handle:
|
||||
handle.write('#!/bin/sh\n')
|
||||
handle.write('echo > %s\n' % pipes.quote(self.program_signal_file))
|
||||
if self.program_script:
|
||||
handle.write('%s\n' % self.program_script.strip())
|
||||
handle.write('exit %i\n' % self.program_returncode)
|
||||
os.chmod(pathname, 0o755)
|
||||
return directory
|
||||
|
||||
def __exit__(self, *args, **kw):
|
||||
"""
|
||||
Ensure that the mock program was run.
|
||||
|
||||
:raises: :exc:`~exceptions.AssertionError` when
|
||||
the mock program hasn't been run.
|
||||
"""
|
||||
try:
|
||||
assert self.program_signal_file and os.path.isfile(self.program_signal_file), \
|
||||
("It looks like %r was never run!" % self.program_name)
|
||||
finally:
|
||||
return super(MockedProgram, self).__exit__(*args, **kw)
|
||||
|
||||
|
||||
class CaptureOutput(ContextManager):
|
||||
|
||||
"""
|
||||
Context manager that captures what's written to :data:`sys.stdout` and :data:`sys.stderr`.
|
||||
|
||||
.. attribute:: stdin
|
||||
|
||||
The :class:`~humanfriendly.compat.StringIO` object used to feed the standard input stream.
|
||||
|
||||
.. attribute:: stdout
|
||||
|
||||
The :class:`CaptureBuffer` object used to capture the standard output stream.
|
||||
|
||||
.. attribute:: stderr
|
||||
|
||||
The :class:`CaptureBuffer` object used to capture the standard error stream.
|
||||
"""
|
||||
|
||||
def __init__(self, merged=False, input='', enabled=True):
|
||||
"""
|
||||
Initialize a :class:`CaptureOutput` object.
|
||||
|
||||
:param merged: :data:`True` to merge the streams,
|
||||
:data:`False` to capture them separately.
|
||||
:param input: The data that reads from :data:`sys.stdin`
|
||||
should return (a string).
|
||||
:param enabled: :data:`True` to enable capturing (the default),
|
||||
:data:`False` otherwise. This makes it easy to
|
||||
unconditionally use :class:`CaptureOutput` in
|
||||
a :keyword:`with` block while preserving the
|
||||
choice to opt out of capturing output.
|
||||
"""
|
||||
self.stdin = StringIO(input)
|
||||
self.stdout = CaptureBuffer()
|
||||
self.stderr = self.stdout if merged else CaptureBuffer()
|
||||
self.patched_attributes = []
|
||||
if enabled:
|
||||
self.patched_attributes.extend(
|
||||
PatchedAttribute(sys, name, getattr(self, name))
|
||||
for name in ('stdin', 'stdout', 'stderr')
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
"""Start capturing what's written to :data:`sys.stdout` and :data:`sys.stderr`."""
|
||||
super(CaptureOutput, self).__enter__()
|
||||
for context in self.patched_attributes:
|
||||
context.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
"""Stop capturing what's written to :data:`sys.stdout` and :data:`sys.stderr`."""
|
||||
super(CaptureOutput, self).__exit__(exc_type, exc_value, traceback)
|
||||
for context in self.patched_attributes:
|
||||
context.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def get_lines(self):
|
||||
"""Get the contents of :attr:`stdout` split into separate lines."""
|
||||
return self.get_text().splitlines()
|
||||
|
||||
def get_text(self):
|
||||
"""Get the contents of :attr:`stdout` as a Unicode string."""
|
||||
return self.stdout.get_text()
|
||||
|
||||
def getvalue(self):
|
||||
"""Get the text written to :data:`sys.stdout`."""
|
||||
return self.stdout.getvalue()
|
||||
|
||||
|
||||
class CaptureBuffer(StringIO):
|
||||
|
||||
"""
|
||||
Helper for :class:`CaptureOutput` to provide an easy to use API.
|
||||
|
||||
The two methods defined by this subclass were specifically chosen to match
|
||||
the names of the methods provided by my :pypi:`capturer` package which
|
||||
serves a similar role as :class:`CaptureOutput` but knows how to simulate
|
||||
an interactive terminal (tty).
|
||||
"""
|
||||
|
||||
def get_lines(self):
|
||||
"""Get the contents of the buffer split into separate lines."""
|
||||
return self.get_text().splitlines()
|
||||
|
||||
def get_text(self):
|
||||
"""Get the contents of the buffer as a Unicode string."""
|
||||
return self.getvalue()
|
||||
|
||||
|
||||
class TestCase(unittest.TestCase):
|
||||
|
||||
"""Subclass of :class:`unittest.TestCase` with automatic logging and other miscellaneous features."""
|
||||
|
||||
def __init__(self, *args, **kw):
|
||||
"""
|
||||
Initialize a :class:`TestCase` object.
|
||||
|
||||
Any positional and/or keyword arguments are passed on to the
|
||||
initializer of the superclass.
|
||||
"""
|
||||
super(TestCase, self).__init__(*args, **kw)
|
||||
|
||||
def setUp(self, log_level=logging.DEBUG):
|
||||
"""setUp(log_level=logging.DEBUG)
|
||||
Automatically configure logging to the terminal.
|
||||
|
||||
:param log_level: Refer to :func:`configure_logging()`.
|
||||
|
||||
The :func:`setUp()` method is automatically called by
|
||||
:class:`unittest.TestCase` before each test method starts.
|
||||
It does two things:
|
||||
|
||||
- Logging to the terminal is configured using
|
||||
:func:`configure_logging()`.
|
||||
|
||||
- Before the test method starts a newline is emitted, to separate the
|
||||
name of the test method (which will be printed to the terminal by
|
||||
:mod:`unittest` or :pypi:`pytest`) from the first line of logging
|
||||
output that the test method is likely going to generate.
|
||||
"""
|
||||
# Configure logging to the terminal.
|
||||
configure_logging(log_level)
|
||||
# Separate the name of the test method (printed by the superclass
|
||||
# and/or py.test without a newline at the end) from the first line of
|
||||
# logging output that the test method is likely going to generate.
|
||||
sys.stderr.write("\n")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,449 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: December 1, 2020
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Simple text manipulation functions.
|
||||
|
||||
The :mod:`~humanfriendly.text` module contains simple functions to manipulate text:
|
||||
|
||||
- The :func:`concatenate()` and :func:`pluralize()` functions make it easy to
|
||||
generate human friendly output.
|
||||
|
||||
- The :func:`format()`, :func:`compact()` and :func:`dedent()` functions
|
||||
provide a clean and simple to use syntax for composing large text fragments
|
||||
with interpolated variables.
|
||||
|
||||
- The :func:`tokenize()` function parses simple user input.
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import numbers
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import textwrap
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'compact',
|
||||
'compact_empty_lines',
|
||||
'concatenate',
|
||||
'dedent',
|
||||
'format',
|
||||
'generate_slug',
|
||||
'is_empty_line',
|
||||
'join_lines',
|
||||
'pluralize',
|
||||
'pluralize_raw',
|
||||
'random_string',
|
||||
'split',
|
||||
'split_paragraphs',
|
||||
'tokenize',
|
||||
'trim_empty_lines',
|
||||
)
|
||||
|
||||
|
||||
def compact(text, *args, **kw):
|
||||
'''
|
||||
Compact whitespace in a string.
|
||||
|
||||
Trims leading and trailing whitespace, replaces runs of whitespace
|
||||
characters with a single space and interpolates any arguments using
|
||||
:func:`format()`.
|
||||
|
||||
:param text: The text to compact (a string).
|
||||
:param args: Any positional arguments are interpolated using :func:`format()`.
|
||||
:param kw: Any keyword arguments are interpolated using :func:`format()`.
|
||||
:returns: The compacted text (a string).
|
||||
|
||||
Here's an example of how I like to use the :func:`compact()` function, this
|
||||
is an example from a random unrelated project I'm working on at the moment::
|
||||
|
||||
raise PortDiscoveryError(compact("""
|
||||
Failed to discover port(s) that Apache is listening on!
|
||||
Maybe I'm parsing the wrong configuration file? ({filename})
|
||||
""", filename=self.ports_config))
|
||||
|
||||
The combination of :func:`compact()` and Python's multi line strings allows
|
||||
me to write long text fragments with interpolated variables that are easy
|
||||
to write, easy to read and work well with Python's whitespace
|
||||
sensitivity.
|
||||
'''
|
||||
non_whitespace_tokens = text.split()
|
||||
compacted_text = ' '.join(non_whitespace_tokens)
|
||||
return format(compacted_text, *args, **kw)
|
||||
|
||||
|
||||
def compact_empty_lines(text):
|
||||
"""
|
||||
Replace repeating empty lines with a single empty line (similar to ``cat -s``).
|
||||
|
||||
:param text: The text in which to compact empty lines (a string).
|
||||
:returns: The text with empty lines compacted (a string).
|
||||
"""
|
||||
i = 0
|
||||
lines = text.splitlines(True)
|
||||
while i < len(lines):
|
||||
if i > 0 and is_empty_line(lines[i - 1]) and is_empty_line(lines[i]):
|
||||
lines.pop(i)
|
||||
else:
|
||||
i += 1
|
||||
return ''.join(lines)
|
||||
|
||||
|
||||
def concatenate(items, conjunction='and', serial_comma=False):
|
||||
"""
|
||||
Concatenate a list of items in a human friendly way.
|
||||
|
||||
:param items:
|
||||
|
||||
A sequence of strings.
|
||||
|
||||
:param conjunction:
|
||||
|
||||
The word to use before the last item (a string, defaults to "and").
|
||||
|
||||
:param serial_comma:
|
||||
|
||||
:data:`True` to use a `serial comma`_, :data:`False` otherwise
|
||||
(defaults to :data:`False`).
|
||||
|
||||
:returns:
|
||||
|
||||
A single string.
|
||||
|
||||
>>> from humanfriendly.text import concatenate
|
||||
>>> concatenate(["eggs", "milk", "bread"])
|
||||
'eggs, milk and bread'
|
||||
|
||||
.. _serial comma: https://en.wikipedia.org/wiki/Serial_comma
|
||||
"""
|
||||
items = list(items)
|
||||
if len(items) > 1:
|
||||
final_item = items.pop()
|
||||
formatted = ', '.join(items)
|
||||
if serial_comma:
|
||||
formatted += ','
|
||||
return ' '.join([formatted, conjunction, final_item])
|
||||
elif items:
|
||||
return items[0]
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def dedent(text, *args, **kw):
|
||||
"""
|
||||
Dedent a string (remove common leading whitespace from all lines).
|
||||
|
||||
Removes common leading whitespace from all lines in the string using
|
||||
:func:`textwrap.dedent()`, removes leading and trailing empty lines using
|
||||
:func:`trim_empty_lines()` and interpolates any arguments using
|
||||
:func:`format()`.
|
||||
|
||||
:param text: The text to dedent (a string).
|
||||
:param args: Any positional arguments are interpolated using :func:`format()`.
|
||||
:param kw: Any keyword arguments are interpolated using :func:`format()`.
|
||||
:returns: The dedented text (a string).
|
||||
|
||||
The :func:`compact()` function's documentation contains an example of how I
|
||||
like to use the :func:`compact()` and :func:`dedent()` functions. The main
|
||||
difference is that I use :func:`compact()` for text that will be presented
|
||||
to the user (where whitespace is not so significant) and :func:`dedent()`
|
||||
for data file and code generation tasks (where newlines and indentation are
|
||||
very significant).
|
||||
"""
|
||||
dedented_text = textwrap.dedent(text)
|
||||
trimmed_text = trim_empty_lines(dedented_text)
|
||||
return format(trimmed_text, *args, **kw)
|
||||
|
||||
|
||||
def format(text, *args, **kw):
|
||||
"""
|
||||
Format a string using the string formatting operator and/or :meth:`str.format()`.
|
||||
|
||||
:param text: The text to format (a string).
|
||||
:param args: Any positional arguments are interpolated into the text using
|
||||
the string formatting operator (``%``). If no positional
|
||||
arguments are given no interpolation is done.
|
||||
:param kw: Any keyword arguments are interpolated into the text using the
|
||||
:meth:`str.format()` function. If no keyword arguments are given
|
||||
no interpolation is done.
|
||||
:returns: The text with any positional and/or keyword arguments
|
||||
interpolated (a string).
|
||||
|
||||
The implementation of this function is so trivial that it seems silly to
|
||||
even bother writing and documenting it. Justifying this requires some
|
||||
context :-).
|
||||
|
||||
**Why format() instead of the string formatting operator?**
|
||||
|
||||
For really simple string interpolation Python's string formatting operator
|
||||
is ideal, but it does have some strange quirks:
|
||||
|
||||
- When you switch from interpolating a single value to interpolating
|
||||
multiple values you have to wrap them in tuple syntax. Because
|
||||
:func:`format()` takes a `variable number of arguments`_ it always
|
||||
receives a tuple (which saves me a context switch :-). Here's an
|
||||
example:
|
||||
|
||||
>>> from humanfriendly.text import format
|
||||
>>> # The string formatting operator.
|
||||
>>> print('the magic number is %s' % 42)
|
||||
the magic number is 42
|
||||
>>> print('the magic numbers are %s and %s' % (12, 42))
|
||||
the magic numbers are 12 and 42
|
||||
>>> # The format() function.
|
||||
>>> print(format('the magic number is %s', 42))
|
||||
the magic number is 42
|
||||
>>> print(format('the magic numbers are %s and %s', 12, 42))
|
||||
the magic numbers are 12 and 42
|
||||
|
||||
- When you interpolate a single value and someone accidentally passes in a
|
||||
tuple your code raises a :exc:`~exceptions.TypeError`. Because
|
||||
:func:`format()` takes a `variable number of arguments`_ it always
|
||||
receives a tuple so this can never happen. Here's an example:
|
||||
|
||||
>>> # How expecting to interpolate a single value can fail.
|
||||
>>> value = (12, 42)
|
||||
>>> print('the magic value is %s' % value)
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: not all arguments converted during string formatting
|
||||
>>> # The following line works as intended, no surprises here!
|
||||
>>> print(format('the magic value is %s', value))
|
||||
the magic value is (12, 42)
|
||||
|
||||
**Why format() instead of the str.format() method?**
|
||||
|
||||
When you're doing complex string interpolation the :meth:`str.format()`
|
||||
function results in more readable code, however I frequently find myself
|
||||
adding parentheses to force evaluation order. The :func:`format()` function
|
||||
avoids this because of the relative priority between the comma and dot
|
||||
operators. Here's an example:
|
||||
|
||||
>>> "{adjective} example" + " " + "(can't think of anything less {adjective})".format(adjective='silly')
|
||||
"{adjective} example (can't think of anything less silly)"
|
||||
>>> ("{adjective} example" + " " + "(can't think of anything less {adjective})").format(adjective='silly')
|
||||
"silly example (can't think of anything less silly)"
|
||||
>>> format("{adjective} example" + " " + "(can't think of anything less {adjective})", adjective='silly')
|
||||
"silly example (can't think of anything less silly)"
|
||||
|
||||
The :func:`compact()` and :func:`dedent()` functions are wrappers that
|
||||
combine :func:`format()` with whitespace manipulation to make it easy to
|
||||
write nice to read Python code.
|
||||
|
||||
.. _variable number of arguments: https://docs.python.org/2/tutorial/controlflow.html#arbitrary-argument-lists
|
||||
"""
|
||||
if args:
|
||||
text %= args
|
||||
if kw:
|
||||
text = text.format(**kw)
|
||||
return text
|
||||
|
||||
|
||||
def generate_slug(text, delimiter="-"):
|
||||
"""
|
||||
Convert text to a normalized "slug" without whitespace.
|
||||
|
||||
:param text: The original text, for example ``Some Random Text!``.
|
||||
:param delimiter: The delimiter used to separate words
|
||||
(defaults to the ``-`` character).
|
||||
:returns: The slug text, for example ``some-random-text``.
|
||||
:raises: :exc:`~exceptions.ValueError` when the provided
|
||||
text is nonempty but results in an empty slug.
|
||||
"""
|
||||
slug = text.lower()
|
||||
escaped = delimiter.replace("\\", "\\\\")
|
||||
slug = re.sub("[^a-z0-9]+", escaped, slug)
|
||||
slug = slug.strip(delimiter)
|
||||
if text and not slug:
|
||||
msg = "The provided text %r results in an empty slug!"
|
||||
raise ValueError(format(msg, text))
|
||||
return slug
|
||||
|
||||
|
||||
def is_empty_line(text):
|
||||
"""
|
||||
Check if a text is empty or contains only whitespace.
|
||||
|
||||
:param text: The text to check for "emptiness" (a string).
|
||||
:returns: :data:`True` if the text is empty or contains only whitespace,
|
||||
:data:`False` otherwise.
|
||||
"""
|
||||
return len(text) == 0 or text.isspace()
|
||||
|
||||
|
||||
def join_lines(text):
|
||||
"""
|
||||
Remove "hard wrapping" from the paragraphs in a string.
|
||||
|
||||
:param text: The text to reformat (a string).
|
||||
:returns: The text without hard wrapping (a string).
|
||||
|
||||
This function works by removing line breaks when the last character before
|
||||
a line break and the first character after the line break are both
|
||||
non-whitespace characters. This means that common leading indentation will
|
||||
break :func:`join_lines()` (in that case you can use :func:`dedent()`
|
||||
before calling :func:`join_lines()`).
|
||||
"""
|
||||
return re.sub(r'(\S)\n(\S)', r'\1 \2', text)
|
||||
|
||||
|
||||
def pluralize(count, singular, plural=None):
|
||||
"""
|
||||
Combine a count with the singular or plural form of a word.
|
||||
|
||||
:param count: The count (a number).
|
||||
:param singular: The singular form of the word (a string).
|
||||
:param plural: The plural form of the word (a string or :data:`None`).
|
||||
:returns: The count and singular or plural word concatenated (a string).
|
||||
|
||||
See :func:`pluralize_raw()` for the logic underneath :func:`pluralize()`.
|
||||
"""
|
||||
return '%s %s' % (count, pluralize_raw(count, singular, plural))
|
||||
|
||||
|
||||
def pluralize_raw(count, singular, plural=None):
|
||||
"""
|
||||
Select the singular or plural form of a word based on a count.
|
||||
|
||||
:param count: The count (a number).
|
||||
:param singular: The singular form of the word (a string).
|
||||
:param plural: The plural form of the word (a string or :data:`None`).
|
||||
:returns: The singular or plural form of the word (a string).
|
||||
|
||||
When the given count is exactly 1.0 the singular form of the word is
|
||||
selected, in all other cases the plural form of the word is selected.
|
||||
|
||||
If the plural form of the word is not provided it is obtained by
|
||||
concatenating the singular form of the word with the letter "s". Of course
|
||||
this will not always be correct, which is why you have the option to
|
||||
specify both forms.
|
||||
"""
|
||||
if not plural:
|
||||
plural = singular + 's'
|
||||
return singular if float(count) == 1.0 else plural
|
||||
|
||||
|
||||
def random_string(length=(25, 100), characters=string.ascii_letters):
|
||||
"""random_string(length=(25, 100), characters=string.ascii_letters)
|
||||
Generate a random string.
|
||||
|
||||
:param length: The length of the string to be generated (a number or a
|
||||
tuple with two numbers). If this is a tuple then a random
|
||||
number between the two numbers given in the tuple is used.
|
||||
:param characters: The characters to be used (a string, defaults
|
||||
to :data:`string.ascii_letters`).
|
||||
:returns: A random string.
|
||||
|
||||
The :func:`random_string()` function is very useful in test suites; by the
|
||||
time I included it in :mod:`humanfriendly.text` I had already included
|
||||
variants of this function in seven different test suites :-).
|
||||
"""
|
||||
if not isinstance(length, numbers.Number):
|
||||
length = random.randint(length[0], length[1])
|
||||
return ''.join(random.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
def split(text, delimiter=','):
|
||||
"""
|
||||
Split a comma-separated list of strings.
|
||||
|
||||
:param text: The text to split (a string).
|
||||
:param delimiter: The delimiter to split on (a string).
|
||||
:returns: A list of zero or more nonempty strings.
|
||||
|
||||
Here's the default behavior of Python's built in :meth:`str.split()`
|
||||
function:
|
||||
|
||||
>>> 'foo,bar, baz,'.split(',')
|
||||
['foo', 'bar', ' baz', '']
|
||||
|
||||
In contrast here's the default behavior of the :func:`split()` function:
|
||||
|
||||
>>> from humanfriendly.text import split
|
||||
>>> split('foo,bar, baz,')
|
||||
['foo', 'bar', 'baz']
|
||||
|
||||
Here is an example that parses a nested data structure (a mapping of
|
||||
logging level names to one or more styles per level) that's encoded in a
|
||||
string so it can be set as an environment variable:
|
||||
|
||||
>>> from pprint import pprint
|
||||
>>> encoded_data = 'debug=green;warning=yellow;error=red;critical=red,bold'
|
||||
>>> parsed_data = dict((k, split(v, ',')) for k, v in (split(kv, '=') for kv in split(encoded_data, ';')))
|
||||
>>> pprint(parsed_data)
|
||||
{'debug': ['green'],
|
||||
'warning': ['yellow'],
|
||||
'error': ['red'],
|
||||
'critical': ['red', 'bold']}
|
||||
"""
|
||||
return [token.strip() for token in text.split(delimiter) if token and not token.isspace()]
|
||||
|
||||
|
||||
def split_paragraphs(text):
|
||||
"""
|
||||
Split a string into paragraphs (one or more lines delimited by an empty line).
|
||||
|
||||
:param text: The text to split into paragraphs (a string).
|
||||
:returns: A list of strings.
|
||||
"""
|
||||
paragraphs = []
|
||||
for chunk in text.split('\n\n'):
|
||||
chunk = trim_empty_lines(chunk)
|
||||
if chunk and not chunk.isspace():
|
||||
paragraphs.append(chunk)
|
||||
return paragraphs
|
||||
|
||||
|
||||
def tokenize(text):
|
||||
"""
|
||||
Tokenize a text into numbers and strings.
|
||||
|
||||
:param text: The text to tokenize (a string).
|
||||
:returns: A list of strings and/or numbers.
|
||||
|
||||
This function is used to implement robust tokenization of user input in
|
||||
functions like :func:`.parse_size()` and :func:`.parse_timespan()`. It
|
||||
automatically coerces integer and floating point numbers, ignores
|
||||
whitespace and knows how to separate numbers from strings even without
|
||||
whitespace. Some examples to make this more concrete:
|
||||
|
||||
>>> from humanfriendly.text import tokenize
|
||||
>>> tokenize('42')
|
||||
[42]
|
||||
>>> tokenize('42MB')
|
||||
[42, 'MB']
|
||||
>>> tokenize('42.5MB')
|
||||
[42.5, 'MB']
|
||||
>>> tokenize('42.5 MB')
|
||||
[42.5, 'MB']
|
||||
"""
|
||||
tokenized_input = []
|
||||
for token in re.split(r'(\d+(?:\.\d+)?)', text):
|
||||
token = token.strip()
|
||||
if re.match(r'\d+\.\d+', token):
|
||||
tokenized_input.append(float(token))
|
||||
elif token.isdigit():
|
||||
tokenized_input.append(int(token))
|
||||
elif token:
|
||||
tokenized_input.append(token)
|
||||
return tokenized_input
|
||||
|
||||
|
||||
def trim_empty_lines(text):
|
||||
"""
|
||||
Trim leading and trailing empty lines from the given text.
|
||||
|
||||
:param text: The text to trim (a string).
|
||||
:returns: The trimmed text (a string).
|
||||
"""
|
||||
lines = text.splitlines(True)
|
||||
while lines and is_empty_line(lines[0]):
|
||||
lines.pop(0)
|
||||
while lines and is_empty_line(lines[-1]):
|
||||
lines.pop(-1)
|
||||
return ''.join(lines)
|
||||
@@ -0,0 +1,351 @@
|
||||
# Human friendly input/output in Python.
|
||||
#
|
||||
# Author: Peter Odding <peter@peterodding.com>
|
||||
# Last Change: June 11, 2021
|
||||
# URL: https://humanfriendly.readthedocs.io
|
||||
|
||||
"""
|
||||
Parsing and reformatting of usage messages.
|
||||
|
||||
The :mod:`~humanfriendly.usage` module parses and reformats usage messages:
|
||||
|
||||
- The :func:`format_usage()` function takes a usage message and inserts ANSI
|
||||
escape sequences that highlight items of special significance like command
|
||||
line options, meta variables, etc. The resulting usage message is (intended
|
||||
to be) easier to read on a terminal.
|
||||
|
||||
- The :func:`render_usage()` function takes a usage message and rewrites it to
|
||||
reStructuredText_ suitable for inclusion in the documentation of a Python
|
||||
package. This provides a DRY solution to keeping a single authoritative
|
||||
definition of the usage message while making it easily available in
|
||||
documentation. As a cherry on the cake it's not just a pre-formatted dump of
|
||||
the usage message but a nicely formatted reStructuredText_ fragment.
|
||||
|
||||
- The remaining functions in this module support the two functions above.
|
||||
|
||||
Usage messages in general are free format of course, however the functions in
|
||||
this module assume a certain structure from usage messages in order to
|
||||
successfully parse and reformat them, refer to :func:`parse_usage()` for
|
||||
details.
|
||||
|
||||
.. _DRY: https://en.wikipedia.org/wiki/Don%27t_repeat_yourself
|
||||
.. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText
|
||||
"""
|
||||
|
||||
# Standard library modules.
|
||||
import csv
|
||||
import functools
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Standard library module or external dependency (see setup.py).
|
||||
from importlib import import_module
|
||||
|
||||
# Modules included in our package.
|
||||
from humanfriendly.compat import StringIO
|
||||
from humanfriendly.text import dedent, split_paragraphs, trim_empty_lines
|
||||
|
||||
# Public identifiers that require documentation.
|
||||
__all__ = (
|
||||
'find_meta_variables',
|
||||
'format_usage',
|
||||
'import_module', # previously exported (backwards compatibility)
|
||||
'inject_usage',
|
||||
'parse_usage',
|
||||
'render_usage',
|
||||
'USAGE_MARKER',
|
||||
)
|
||||
|
||||
USAGE_MARKER = "Usage:"
|
||||
"""The string that starts the first line of a usage message."""
|
||||
|
||||
START_OF_OPTIONS_MARKER = "Supported options:"
|
||||
"""The string that marks the start of the documented command line options."""
|
||||
|
||||
# Compiled regular expression used to tokenize usage messages.
|
||||
USAGE_PATTERN = re.compile(r'''
|
||||
# Make sure whatever we're matching isn't preceded by a non-whitespace
|
||||
# character.
|
||||
(?<!\S)
|
||||
(
|
||||
# A short command line option or a long command line option
|
||||
# (possibly including a meta variable for a value).
|
||||
(-\w|--\w+(-\w+)*(=\S+)?)
|
||||
# Or ...
|
||||
|
|
||||
# An environment variable.
|
||||
\$[A-Za-z_][A-Za-z0-9_]*
|
||||
# Or ...
|
||||
|
|
||||
# Might be a meta variable (usage() will figure it out).
|
||||
[A-Z][A-Z0-9_]+
|
||||
)
|
||||
''', re.VERBOSE)
|
||||
|
||||
# Compiled regular expression used to recognize options.
|
||||
OPTION_PATTERN = re.compile(r'^(-\w|--\w+(-\w+)*(=\S+)?)$')
|
||||
|
||||
# Initialize a logger for this module.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_usage(usage_text):
|
||||
"""
|
||||
Highlight special items in a usage message.
|
||||
|
||||
:param usage_text: The usage message to process (a string).
|
||||
:returns: The usage message with special items highlighted.
|
||||
|
||||
This function highlights the following special items:
|
||||
|
||||
- The initial line of the form "Usage: ..."
|
||||
- Short and long command line options
|
||||
- Environment variables
|
||||
- Meta variables (see :func:`find_meta_variables()`)
|
||||
|
||||
All items are highlighted in the color defined by
|
||||
:data:`.HIGHLIGHT_COLOR`.
|
||||
"""
|
||||
# Ugly workaround to avoid circular import errors due to interdependencies
|
||||
# between the humanfriendly.terminal and humanfriendly.usage modules.
|
||||
from humanfriendly.terminal import ansi_wrap, HIGHLIGHT_COLOR
|
||||
formatted_lines = []
|
||||
meta_variables = find_meta_variables(usage_text)
|
||||
for line in usage_text.strip().splitlines(True):
|
||||
if line.startswith(USAGE_MARKER):
|
||||
# Highlight the "Usage: ..." line in bold font and color.
|
||||
formatted_lines.append(ansi_wrap(line, color=HIGHLIGHT_COLOR))
|
||||
else:
|
||||
# Highlight options, meta variables and environment variables.
|
||||
formatted_lines.append(replace_special_tokens(
|
||||
line, meta_variables,
|
||||
lambda token: ansi_wrap(token, color=HIGHLIGHT_COLOR),
|
||||
))
|
||||
return ''.join(formatted_lines)
|
||||
|
||||
|
||||
def find_meta_variables(usage_text):
|
||||
"""
|
||||
Find the meta variables in the given usage message.
|
||||
|
||||
:param usage_text: The usage message to parse (a string).
|
||||
:returns: A list of strings with any meta variables found in the usage
|
||||
message.
|
||||
|
||||
When a command line option requires an argument, the convention is to
|
||||
format such options as ``--option=ARG``. The text ``ARG`` in this example
|
||||
is the meta variable.
|
||||
"""
|
||||
meta_variables = set()
|
||||
for match in USAGE_PATTERN.finditer(usage_text):
|
||||
token = match.group(0)
|
||||
if token.startswith('-'):
|
||||
option, _, value = token.partition('=')
|
||||
if value:
|
||||
meta_variables.add(value)
|
||||
return list(meta_variables)
|
||||
|
||||
|
||||
def parse_usage(text):
|
||||
"""
|
||||
Parse a usage message by inferring its structure (and making some assumptions :-).
|
||||
|
||||
:param text: The usage message to parse (a string).
|
||||
:returns: A tuple of two lists:
|
||||
|
||||
1. A list of strings with the paragraphs of the usage message's
|
||||
"introduction" (the paragraphs before the documentation of the
|
||||
supported command line options).
|
||||
|
||||
2. A list of strings with pairs of command line options and their
|
||||
descriptions: Item zero is a line listing a supported command
|
||||
line option, item one is the description of that command line
|
||||
option, item two is a line listing another supported command
|
||||
line option, etc.
|
||||
|
||||
Usage messages in general are free format of course, however
|
||||
:func:`parse_usage()` assume a certain structure from usage messages in
|
||||
order to successfully parse them:
|
||||
|
||||
- The usage message starts with a line ``Usage: ...`` that shows a symbolic
|
||||
representation of the way the program is to be invoked.
|
||||
|
||||
- After some free form text a line ``Supported options:`` (surrounded by
|
||||
empty lines) precedes the documentation of the supported command line
|
||||
options.
|
||||
|
||||
- The command line options are documented as follows::
|
||||
|
||||
-v, --verbose
|
||||
|
||||
Make more noise.
|
||||
|
||||
So all of the variants of the command line option are shown together on a
|
||||
separate line, followed by one or more paragraphs describing the option.
|
||||
|
||||
- There are several other minor assumptions, but to be honest I'm not sure if
|
||||
anyone other than me is ever going to use this functionality, so for now I
|
||||
won't list every intricate detail :-).
|
||||
|
||||
If you're curious anyway, refer to the usage message of the `humanfriendly`
|
||||
package (defined in the :mod:`humanfriendly.cli` module) and compare it with
|
||||
the usage message you see when you run ``humanfriendly --help`` and the
|
||||
generated usage message embedded in the readme.
|
||||
|
||||
Feel free to request more detailed documentation if you're interested in
|
||||
using the :mod:`humanfriendly.usage` module outside of the little ecosystem
|
||||
of Python packages that I have been building over the past years.
|
||||
"""
|
||||
introduction = []
|
||||
documented_options = []
|
||||
# Split the raw usage message into paragraphs.
|
||||
paragraphs = split_paragraphs(text)
|
||||
# Get the paragraphs that are part of the introduction.
|
||||
while paragraphs:
|
||||
# Check whether we've found the end of the introduction.
|
||||
end_of_intro = (paragraphs[0] == START_OF_OPTIONS_MARKER)
|
||||
# Append the current paragraph to the introduction.
|
||||
introduction.append(paragraphs.pop(0))
|
||||
# Stop after we've processed the complete introduction.
|
||||
if end_of_intro:
|
||||
break
|
||||
logger.debug("Parsed introduction: %s", introduction)
|
||||
# Parse the paragraphs that document command line options.
|
||||
while paragraphs:
|
||||
documented_options.append(dedent(paragraphs.pop(0)))
|
||||
description = []
|
||||
while paragraphs:
|
||||
# Check if the next paragraph starts the documentation of another
|
||||
# command line option. We split on a comma followed by a space so
|
||||
# that our parsing doesn't trip up when the label used for an
|
||||
# option's value contains commas.
|
||||
tokens = [t.strip() for t in re.split(r',\s', paragraphs[0]) if t and not t.isspace()]
|
||||
if all(OPTION_PATTERN.match(t) for t in tokens):
|
||||
break
|
||||
else:
|
||||
description.append(paragraphs.pop(0))
|
||||
# Join the description's paragraphs back together so we can remove
|
||||
# common leading indentation.
|
||||
documented_options.append(dedent('\n\n'.join(description)))
|
||||
logger.debug("Parsed options: %s", documented_options)
|
||||
return introduction, documented_options
|
||||
|
||||
|
||||
def render_usage(text):
|
||||
"""
|
||||
Reformat a command line program's usage message to reStructuredText_.
|
||||
|
||||
:param text: The plain text usage message (a string).
|
||||
:returns: The usage message rendered to reStructuredText_ (a string).
|
||||
"""
|
||||
meta_variables = find_meta_variables(text)
|
||||
introduction, options = parse_usage(text)
|
||||
output = [render_paragraph(p, meta_variables) for p in introduction]
|
||||
if options:
|
||||
output.append('\n'.join([
|
||||
'.. csv-table::',
|
||||
' :header: Option, Description',
|
||||
' :widths: 30, 70',
|
||||
'',
|
||||
]))
|
||||
csv_buffer = StringIO()
|
||||
csv_writer = csv.writer(csv_buffer)
|
||||
while options:
|
||||
variants = options.pop(0)
|
||||
description = options.pop(0)
|
||||
csv_writer.writerow([
|
||||
render_paragraph(variants, meta_variables),
|
||||
('\n\n'.join(render_paragraph(p, meta_variables) for p in split_paragraphs(description))).rstrip(),
|
||||
])
|
||||
csv_lines = csv_buffer.getvalue().splitlines()
|
||||
output.append('\n'.join(' %s' % line for line in csv_lines))
|
||||
logger.debug("Rendered output: %s", output)
|
||||
return '\n\n'.join(trim_empty_lines(o) for o in output)
|
||||
|
||||
|
||||
def inject_usage(module_name):
|
||||
"""
|
||||
Use cog_ to inject a usage message into a reStructuredText_ file.
|
||||
|
||||
:param module_name: The name of the module whose ``__doc__`` attribute is
|
||||
the source of the usage message (a string).
|
||||
|
||||
This simple wrapper around :func:`render_usage()` makes it very easy to
|
||||
inject a reformatted usage message into your documentation using cog_. To
|
||||
use it you add a fragment like the following to your ``*.rst`` file::
|
||||
|
||||
.. [[[cog
|
||||
.. from humanfriendly.usage import inject_usage
|
||||
.. inject_usage('humanfriendly.cli')
|
||||
.. ]]]
|
||||
.. [[[end]]]
|
||||
|
||||
The lines in the fragment above are single line reStructuredText_ comments
|
||||
that are not copied to the output. Their purpose is to instruct cog_ where
|
||||
to inject the reformatted usage message. Once you've added these lines to
|
||||
your ``*.rst`` file, updating the rendered usage message becomes really
|
||||
simple thanks to cog_:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
$ cog.py -r README.rst
|
||||
|
||||
This will inject or replace the rendered usage message in your
|
||||
``README.rst`` file with an up to date copy.
|
||||
|
||||
.. _cog: http://nedbatchelder.com/code/cog/
|
||||
"""
|
||||
import cog
|
||||
usage_text = import_module(module_name).__doc__
|
||||
cog.out("\n" + render_usage(usage_text) + "\n\n")
|
||||
|
||||
|
||||
def render_paragraph(paragraph, meta_variables):
|
||||
# Reformat the "Usage:" line to highlight "Usage:" in bold and show the
|
||||
# remainder of the line as pre-formatted text.
|
||||
if paragraph.startswith(USAGE_MARKER):
|
||||
tokens = paragraph.split()
|
||||
return "**%s** `%s`" % (tokens[0], ' '.join(tokens[1:]))
|
||||
# Reformat the "Supported options:" line to highlight it in bold.
|
||||
if paragraph == 'Supported options:':
|
||||
return "**%s**" % paragraph
|
||||
# Reformat shell transcripts into code blocks.
|
||||
if re.match(r'^\s*\$\s+\S', paragraph):
|
||||
# Split the paragraph into lines.
|
||||
lines = paragraph.splitlines()
|
||||
# Check if the paragraph is already indented.
|
||||
if not paragraph[0].isspace():
|
||||
# If the paragraph isn't already indented we'll indent it now.
|
||||
lines = [' %s' % line for line in lines]
|
||||
lines.insert(0, '.. code-block:: sh')
|
||||
lines.insert(1, '')
|
||||
return "\n".join(lines)
|
||||
# The following reformatting applies only to paragraphs which are not
|
||||
# indented. Yes this is a hack - for now we assume that indented paragraphs
|
||||
# are code blocks, even though this assumption can be wrong.
|
||||
if not paragraph[0].isspace():
|
||||
# Change UNIX style `quoting' so it doesn't trip up DocUtils.
|
||||
paragraph = re.sub("`(.+?)'", r'"\1"', paragraph)
|
||||
# Escape asterisks.
|
||||
paragraph = paragraph.replace('*', r'\*')
|
||||
# Reformat inline tokens.
|
||||
paragraph = replace_special_tokens(
|
||||
paragraph, meta_variables,
|
||||
lambda token: '``%s``' % token,
|
||||
)
|
||||
return paragraph
|
||||
|
||||
|
||||
def replace_special_tokens(text, meta_variables, replace_fn):
|
||||
return USAGE_PATTERN.sub(functools.partial(
|
||||
replace_tokens_callback,
|
||||
meta_variables=meta_variables,
|
||||
replace_fn=replace_fn
|
||||
), text)
|
||||
|
||||
|
||||
def replace_tokens_callback(match, meta_variables, replace_fn):
|
||||
token = match.group(0)
|
||||
if not (re.match('^[A-Z][A-Z0-9_]+$', token) and token not in meta_variables):
|
||||
token = replace_fn(token)
|
||||
return token
|
||||
Reference in New Issue
Block a user