"""Miscellaneous utilities."""
import datetime
import functools
import io
import keyword
import operator
import platform
import textwrap
import uuid
from .typing import TYPE_CHECKING, MutableMapping, overload
if TYPE_CHECKING:
from types import TracebackType # isort: split
from .typing import (Dict, Generator, Iterable, Iterator, List, Mapping, NoReturn, Optional,
Set, T, TextIO, Tuple, Type, Union)
# backport contextlib.nullcontext for Python < 3.7
try:
from contextlib import nullcontext # pylint: disable=unused-import # novermin
except ImportError: # pragma: no cover
class nullcontext: # type: ignore[no-redef]
def __init__(self, enter_result: 'T' = None) -> None: # type: ignore[assignment]
self.enter_result = enter_result # type: T
def __enter__(self) -> 'T':
return self.enter_result
def __exit__(self, exc_type: 'Optional[Type[BaseException]]', exc_value: 'Optional[BaseException]',
traceback: 'Optional[TracebackType]') -> None:
pass
is_windows = platform.system() == 'Windows'
[docs]def current_time_with_tzinfo() -> 'datetime.datetime':
"""Get the current time with local time zone information.
Returns:
datetime object representing current time with local time zone information
"""
return datetime.datetime.now(datetime.timezone.utc).astimezone()
@overload
def first_truthy(args: 'Iterable[T]') -> 'Optional[T]':
... # pragma: no cover
@overload
def first_truthy(*args: 'T') -> 'Optional[T]': # noqa: F811
... # pragma: no cover
[docs]def first_truthy(*args): # type: ignore[no-untyped-def] # noqa: F811 # pylint: disable=missing-return-type-doc
"""Return the first *truthy* value from a list of values.
Args:
*args: variable length argument list
* If one positional argument is provided, it should be an iterable of the values.
* If two or more positional arguments are provided, then the value list is the positional argument list.
Returns:
the first *truthy* value, if no *truthy* values found or sequence is empty, return :data:`None`
Raises:
TypeError: if no arguments provided
"""
if not args:
raise TypeError('no arguments provided')
if len(args) == 1:
args = args[0]
return next(filter(bool, args), None)
@overload
def first_non_none(args: 'Iterable[T]') -> 'Optional[T]':
... # pragma: no cover
@overload
def first_non_none(*args: 'T') -> 'Optional[T]': # noqa: F811
... # pragma: no cover
[docs]def first_non_none(*args): # type: ignore[no-untyped-def] # noqa: F811 # pylint: disable=missing-return-type-doc
"""Return the first non-:data:`None` value from a list of values.
Args:
*args: variable length argument list
* If one positional argument is provided, it should be an iterable of the values.
* If two or more positional arguments are provided, then the value list is the positional argument list.
Returns:
the first non-:data:`None` value, if all values are :data:`None` or sequence is empty, return :data:`None`
Raises:
TypeError: if no arguments provided
"""
if not args:
raise TypeError('no arguments provided')
if len(args) == 1:
args = args[0]
return next(filter(lambda x: x is not None, args), None)
[docs]class UUID4Generator:
"""UUID 4 generator wrapper to prevent UUID collisions."""
def __init__(self, dash: bool = True) -> None:
"""Constructor of UUID 4 generator wrapper.
Args:
dash: whether the generated UUID string has dashes or not
"""
self.used_uuids = set() # type: Set[str]
self.dash = dash
[docs] def gen(self) -> str:
"""Generate a new UUID 4 string that is guaranteed not to collide with used UUIDs.
Returns:
a new UUID 4 string
"""
while True:
new_uuid = uuid.uuid4()
nuid = str(new_uuid) if self.dash else new_uuid.hex
if nuid not in self.used_uuids: # pragma: no cover
break
self.used_uuids.add(nuid)
return nuid
[docs]class MakeTextIO:
"""Context wrapper class to handle :obj:`str` and *file* objects together.
Attributes:
obj (Union[str, TextIO]): the object to manage in the context
sio (Optional[StringIO]): the I/O object to manage in the context
only if :attr:`self.obj <MakeTextIO.obj>` is :obj:`str`
pos (Optional[int]): the original offset of :attr:`self.obj <MakeTextIO.obj>`,
only if :attr:`self.obj <MakeTextIO.obj>` is a seekable *file* object
"""
def __init__(self, obj: 'Union[str, TextIO]') -> None:
"""Initialize context.
Args:
obj: the object to manage in the context
"""
self.obj = obj
[docs] def __enter__(self) -> 'TextIO':
"""Enter context.
* If :attr:`self.obj <MakeTextIO.obj>` is :obj:`str`, a
:class:`~io.StringIO` will be created and returned.
* If :attr:`self.obj <MakeTextIO.obj>` is a seekable *file* object,
it will be seeked to the beginning and returned.
* If :attr:`self.obj <MakeTextIO.obj>` is an unseekable *file* object,
it will be returned directly.
"""
if isinstance(self.obj, str):
#: StringIO: the I/O object to manage in the context
#: only if :attr:`self.obj <MakeTextIO.obj>` is :obj:`str`
self.sio = io.StringIO(self.obj, newline='') # turn off newline translation # pylint: disable=W0201
return self.sio
if self.obj.seekable():
#: int: the original offset of :attr:`self.obj <MakeTextIO.obj>`,
#: only if :attr:`self.obj <MakeTextIO.obj>` is a seekable
#: :class:`TextIO <io.TextIOWrapper>`
self.pos = self.obj.tell() # pylint: disable=W0201
#: Union[str, TextIO]: the object to manage in the context
self.obj.seek(0)
return self.obj
[docs] def __exit__(self, exc_type: 'Optional[Type[BaseException]]', exc_value: 'Optional[BaseException]',
traceback: 'Optional[TracebackType]') -> None:
"""Exit context.
* If :attr:`self.obj <MakeTextIO.obj>` is :obj:`str`, the
:class:`~io.StringIO` (:attr:`self.sio <MakeTextIO.sio>`) will be closed.
* If :attr:`self.obj <MakeTextIO.obj>` is a seekable *file* object,
its stream position (:attr:`self.pos <MakeTextIO.pos>`) will be recovered.
"""
if isinstance(self.obj, str):
self.sio.close()
elif self.obj.seekable():
self.obj.seek(self.pos)
[docs]class Config(MutableMapping[str, object]):
"""Configuration namespace.
This class is inspired from :class:`argparse.Namespace` for storing
internal attributes and/or configuration variables.
>>> config = Config(foo='var', bar=True)
>>> config.foo
'var'
>>> config['bar']
True
>>> config.bar = 'boo'
>>> del config['foo']
>>> config
Config(bar='boo')
"""
def __init__(self, **kwargs: object) -> None:
for name, value in kwargs.items():
setattr(self, name, value)
def __contains__(self, key: object) -> bool:
return key in self.__dict__
def __iter__(self) -> 'Iterator[str]':
return iter(self.__dict__)
def __len__(self) -> int:
return len(self.__dict__)
def __getitem__(self, key: str) -> object:
return self.__dict__[key]
def __setitem__(self, key: str, value: object) -> None:
self.__dict__[key] = value
def __delitem__(self, key: str) -> None:
del self.__dict__[key]
def __eq__(self, other: object) -> bool:
return isinstance(other, Config) and self.__dict__ == other.__dict__
def __repr__(self) -> str:
type_name = type(self).__name__
arg_strings = [] # type: List[str]
star_args = {} # type: Dict[str, object]
for name, value in sorted(self.__dict__.items()):
if name.isidentifier() and not keyword.iskeyword(name) and name != '__debug__':
arg_strings.append('%s=%r' % (name, value))
else: # wrap invalid names into a dict to make __repr__ round-trip
star_args[name] = value
if star_args:
arg_strings.append('**%s' % repr(star_args))
return '%s(%s)' % (type_name, ', '.join(arg_strings))
[docs]class Placeholder:
"""Placeholder for string interpolation.
:class:`Placeholder` objects can be concatenated with :obj:`str`, other :class:`Placeholder` objects
and :class:`StringInterpolation` objects via the '+' operator.
:class:`Placeholder` objects should be regarded as immutable. Please do not modify the ``_name``
internal attribute. Build new objects instead.
"""
def __init__(self, name: str) -> None:
"""Initialize Placeholder.
Args:
name: name of the placeholder
Raises:
TypeError: if ``name`` is not :obj:`str`
"""
if not isinstance(name, str):
raise TypeError('placeholder name must be str')
self._name = name
@property
def name(self) -> str:
"""Returns the name of this placeholder."""
return self._name
def __eq__(self, other: object) -> bool:
return type(self) is type(other) and self.name == other.name # type: ignore[attr-defined]
def __hash__(self) -> int:
return hash((self.name,))
def __repr__(self) -> str:
return '{}({!r})'.format(type(self).__name__, self.name)
def __str__(self) -> 'NoReturn':
raise TypeError('Placeholder objects cannot be converted to str, consider using '
'repr() if you want a string representation')
def __add__(self, other: object) -> 'StringInterpolation':
if isinstance(other, str):
return StringInterpolation.from_components(('', other), (self,))
if isinstance(other, Placeholder):
return StringInterpolation.from_components(('', '', ''), (self, other))
if isinstance(other, StringInterpolation):
return StringInterpolation.from_components(('',) + other.literals, (self,) + other.placeholders)
return NotImplemented
def __radd__(self, other: object) -> 'StringInterpolation':
if isinstance(other, str):
return StringInterpolation.from_components((other, ''), (self,))
return NotImplemented
[docs]class StringInterpolation:
"""A string with placeholders to be filled in.
This looks like an object-oriented format string, but making sure that string literals are
always interpreted literally (so no need to manually do escaping). The boundaries between string
literals and placeholders are very clear. Filling in a placeholder will never inject a new
placeholder, protecting string integrity for multiple-round interpolation.
>>> s1 = '%(injected)s'
>>> s2 = 'hello'
>>> s = StringInterpolation('prefix ', Placeholder('q1'), ' infix ', Placeholder('q2'), ' suffix')
>>> str(s % {'q1': s1} % {'q2': s2})
'prefix %(injected)s infix hello suffix'
(This can be regarded as an improved version of :meth:`string.Template.safe_substitute`.)
Multiple-round interpolation is tricky to do with a traditional format string. In order to do things
correctly and avoid format string injection vulnerabilities, you need to perform escapes very carefully.
>>> fs = 'prefix %(q1)s infix %(q2)s suffix'
>>> fs % {'q1': s1} % {'q2': s2}
Traceback (most recent call last):
...
KeyError: 'q2'
>>> fs = 'prefix %(q1)s infix %%(q2)s suffix'
>>> fs % {'q1': s1} % {'q2': s2}
Traceback (most recent call last):
...
KeyError: 'injected'
>>> fs % {'q1': s1.replace('%', '%%')} % {'q2': s2}
'prefix %(injected)s infix hello suffix'
:class:`StringInterpolation` objects can be concatenated with :obj:`str`, :class:`Placeholder` objects
and other :class:`StringInterpolation` objects via the '+' operator.
:class:`StringInterpolation` objects should be regarded as immutable. Please do not modify the
``_literals`` and ``_placeholders`` internal attributes. Build new objects instead.
"""
def __init__(self, *args: 'Union[str, Placeholder, StringInterpolation]') -> None:
"""Initialize StringInterpolation. ``args`` will be concatenated to construct a
:class:`StringInterpolation` object.
>>> StringInterpolation('prefix', Placeholder('data'), 'suffix')
StringInterpolation('prefix', Placeholder('data'), 'suffix')
Args:
args: the components to construct a :class:`StringInterpolation` object
"""
if not args:
self._literals = ('',) # type: Tuple[str, ...]
self._placeholders = () # type: Tuple[Placeholder, ...]
return
obj = functools.reduce(operator.add, args, type(self)())
self._literals = obj.literals
self._placeholders = obj.placeholders
@property
def literals(self) -> 'Tuple[str, ...]':
"""Returns the literal components in this :class:`StringInterpolation` object."""
return self._literals
@property
def placeholders(self) -> 'Tuple[Placeholder, ...]':
"""Returns the :class:`Placeholder` components in this :class:`StringInterpolation` object."""
return self._placeholders
[docs] @classmethod
def from_components(cls, literals: 'Iterable[str]', placeholders: 'Iterable[Placeholder]') -> 'StringInterpolation':
"""Construct a :class:`StringInterpolation` object from ``literals`` and ``placeholders`` components.
This method is more efficient than the :func:`StringInterpolation` constructor, but it is mainly
intended for internal use.
>>> StringInterpolation.from_components(
... ('prefix', 'infix', 'suffix'),
... (Placeholder('data1'), Placeholder('data2'))
... )
StringInterpolation('prefix', Placeholder('data1'), 'infix', Placeholder('data2'), 'suffix')
Args:
literals: the literal components in order
placeholders: the :class:`Placeholder` components in order
Returns:
the constructed :class:`StringInterpolation` object
Raises:
TypeError: if ``literals`` is :obj:`str`; if ``literals`` contains non-:obj:`str` values;
if ``placeholders`` contains non-:class:`Placeholder` values
ValueError: if the length of ``literals`` is not exactly one more than the length of ``placeholders``
"""
obj = cls()
if isinstance(literals, str):
raise TypeError('literals must be a non-string iterable')
obj._literals = tuple(literals) # pylint: disable=protected-access
obj._placeholders = tuple(placeholders) # pylint: disable=protected-access
if len(obj.literals) - len(obj.placeholders) != 1:
raise ValueError('the number of literals must be exactly one more than the number of placeholders')
for literal in obj.literals:
if not isinstance(literal, str):
raise TypeError('literals contain non-string value: {!r}'.format(literal))
for placeholder in obj.placeholders:
if not isinstance(placeholder, Placeholder):
raise TypeError('placeholders contain non-Placeholder value: {!r}'.format(placeholder))
return obj
[docs] def iter_components(self) -> 'Generator[Union[str, Placeholder], None, None]':
"""Generator to iterate all components of this :class:`StringInterpolation` object in order.
>>> list(StringInterpolation('prefix', Placeholder('data'), 'suffix').iter_components())
['prefix', Placeholder('data'), 'suffix']
Yields:
the components of this :class:`StringInterpolation` object in order
"""
for literal, placeholder in zip(self.literals, self.placeholders):
yield literal
yield placeholder
yield self.literals[-1]
def __repr__(self) -> str:
return '{}({})'.format(type(self).__name__, ', '.join(repr(c) for c in self.iter_components() if c))
[docs] def __str__(self) -> str:
"""Returns the fully-substituted string interpolation result.
>>> str(StringInterpolation('prefix hello suffix'))
'prefix hello suffix'
Returns:
the fully-substituted string interpolation result
Raises:
ValueError: if there are still unsubstituted placeholders in this :class:`StringInterpolation` object
"""
if self.placeholders:
raise ValueError(
'cannot convert this StringInterpolation object to str (retrieve interpolation result) '
'because it contains the following unsubstituted placeholders: '
+ ', '.join(map(repr, sorted(set(placeholder.name for placeholder in self.placeholders))))
+ '; consider using repr() if you want a string representation of this object'
)
return self.literals[0]
def __eq__(self, other: object) -> bool:
if (type(self) is type(other) and self.literals == other.literals # type: ignore[attr-defined]
and self.placeholders == other.placeholders): # type: ignore[attr-defined]
return True
if isinstance(other, str) and self.literals == (other,):
return True
if isinstance(other, Placeholder) and self.placeholders == (other,) and self.literals == ('', ''):
return True
return False
def __hash__(self) -> int:
if len(self.literals) == 1:
return hash(self.literals[0])
if self.literals == ('', ''):
return hash(self.placeholders[0])
return hash((self.literals, self.placeholders))
def __bool__(self) -> bool:
return len(self.literals) > 1 or bool(self.literals[0])
def __add__(self, other: object) -> 'StringInterpolation':
if isinstance(other, str):
return StringInterpolation.from_components(self.literals[:-1] + (self.literals[-1] + other,),
self.placeholders)
if isinstance(other, Placeholder):
return StringInterpolation.from_components(self.literals + ('',), self.placeholders + (other,))
if isinstance(other, StringInterpolation):
return StringInterpolation.from_components(
self.literals[:-1] + (self.literals[-1] + other.literals[0],) + other.literals[1:],
self.placeholders + other.placeholders
)
return NotImplemented
def __radd__(self, other: object) -> 'StringInterpolation':
if isinstance(other, str):
return StringInterpolation.from_components((other + self.literals[0],) + self.literals[1:],
self.placeholders)
return NotImplemented
[docs] def __mod__(self, substitutions: 'Mapping[str, object]') -> 'StringInterpolation':
"""Substitute the placeholders in this :class:`StringInterpolation` object with string values (if possible)
according to the ``substitutions`` mapping.
>>> StringInterpolation('prefix ', Placeholder('data'), ' suffix') % {'data': 'hello'}
StringInterpolation('prefix hello suffix')
Args:
substitutions: a mapping from placeholder names to the values to be filled in; all values
are converted into :obj:`str`
Returns:
a new :class:`StringInterpolation` object with as many placeholders substituted as possible
"""
result = type(self)()
for component in self.iter_components():
if isinstance(component, Placeholder) and component.name in substitutions:
result += str(substitutions[component.name])
else:
result += component
return result
@property
def result(self) -> str:
"""Alias of :meth:`StringInterpolation.__str__` to get the fully-substituted string interpolation result.
>>> StringInterpolation('prefix hello suffix').result
'prefix hello suffix'
"""
return str(self)
[docs]class BPCInternalError(RuntimeError):
"""Internal bug happened in BPC tools."""
def __init__(self, message: object, context: str):
"""Initialize BPCInternalError.
Args:
message: the error message
context: describe the context/location/component where the bug happened
Raises:
TypeError: if ``context`` is not :obj:`str`
ValueError: if ``message`` (when converted to :obj:`str`) or ``context`` is empty or
only contains whitespace characters
"""
msg_string = str(message)
if not msg_string.strip():
raise ValueError('message should not be empty')
if not isinstance(context, str):
raise TypeError('context should be str')
if not context.strip():
raise ValueError('context should not be empty')
super().__init__(textwrap.dedent('''\
An internal bug happened in {}:
{}
Please report this error to project maintainers.''').format(context, msg_string))
__all__ = ['first_truthy', 'first_non_none', 'UUID4Generator', 'Config', 'Placeholder', 'StringInterpolation',
'BPCInternalError']