190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
"""Generic re-useable self-contained helper functions."""
|
|
|
|
import functools
|
|
import inspect
|
|
import itertools
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import typing
|
|
import warnings
|
|
|
|
__all__ = ['attach',
|
|
'mkdirs',
|
|
'mapping_items',
|
|
'promote_pathlike',
|
|
'promote_pathlike_directory',
|
|
'deprecate_positional_args']
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def attach(object: typing.Any, /, name: str) -> typing.Callable:
|
|
"""Return a decorator doing ``setattr(object, name)`` with its argument.
|
|
|
|
>>> spam = type('Spam', (object,), {})() # doctest: +NO_EXE
|
|
|
|
>>> @attach(spam, 'eggs')
|
|
... def func():
|
|
... pass
|
|
|
|
>>> spam.eggs # doctest: +ELLIPSIS
|
|
<function func at 0x...>
|
|
"""
|
|
def decorator(func):
|
|
setattr(object, name, func)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
|
|
def mkdirs(filename: typing.Union[os.PathLike, str], /, *, mode: int = 0o777) -> None:
|
|
"""Recursively create directories up to the path of ``filename``
|
|
as needed."""
|
|
dirname = os.path.dirname(filename)
|
|
if not dirname:
|
|
return
|
|
log.debug('os.makedirs(%r)', dirname)
|
|
os.makedirs(dirname, mode=mode, exist_ok=True)
|
|
|
|
|
|
def mapping_items(mapping, /):
|
|
"""Return an iterator over the ``mapping`` items,
|
|
sort if it's a plain dict.
|
|
|
|
>>> list(mapping_items({'spam': 0, 'ham': 1, 'eggs': 2})) # doctest: +NO_EXE
|
|
[('eggs', 2), ('ham', 1), ('spam', 0)]
|
|
|
|
>>> from collections import OrderedDict
|
|
>>> list(mapping_items(OrderedDict(enumerate(['spam', 'ham', 'eggs']))))
|
|
[(0, 'spam'), (1, 'ham'), (2, 'eggs')]
|
|
"""
|
|
result = iter(mapping.items())
|
|
if type(mapping) is dict:
|
|
result = iter(sorted(result))
|
|
return result
|
|
|
|
|
|
@typing.overload
|
|
def promote_pathlike(filepath: typing.Union[os.PathLike, str], /) -> pathlib.Path:
|
|
"""Return path object for path-like-object."""
|
|
|
|
|
|
@typing.overload
|
|
def promote_pathlike(filepath: None, /) -> None:
|
|
"""Return None for None."""
|
|
|
|
|
|
@typing.overload
|
|
def promote_pathlike(filepath: typing.Union[os.PathLike, str, None], /,
|
|
) -> typing.Optional[pathlib.Path]:
|
|
"""Return path object or ``None`` depending on ``filepath``."""
|
|
|
|
|
|
def promote_pathlike(filepath: typing.Union[os.PathLike, str, None]
|
|
) -> typing.Optional[pathlib.Path]:
|
|
"""Return path-like object ``filepath`` promoted into a path object.
|
|
|
|
See also:
|
|
https://docs.python.org/3/glossary.html#term-path-like-object
|
|
"""
|
|
return pathlib.Path(filepath) if filepath is not None else None
|
|
|
|
|
|
def promote_pathlike_directory(directory: typing.Union[os.PathLike, str, None], /, *,
|
|
default: typing.Union[os.PathLike, str, None] = None,
|
|
) -> pathlib.Path:
|
|
"""Return path-like object ``directory`` promoted into a path object (default to ``os.curdir``).
|
|
|
|
See also:
|
|
https://docs.python.org/3/glossary.html#term-path-like-object
|
|
"""
|
|
return pathlib.Path(directory if directory is not None
|
|
else default or os.curdir)
|
|
|
|
|
|
def deprecate_positional_args(*,
|
|
supported_number: int,
|
|
ignore_arg: typing.Optional[str] = None,
|
|
category: typing.Type[Warning] = PendingDeprecationWarning,
|
|
stacklevel: int = 1):
|
|
"""Mark supported_number of positional arguments as the maximum.
|
|
|
|
Args:
|
|
supported_number: Number of positional arguments
|
|
for which no warning is raised.
|
|
ignore_arg: Name of positional argument to ignore.
|
|
category: Type of Warning to raise
|
|
or None to return a nulldecorator
|
|
returning the undecorated function.
|
|
stacklevel: See :func:`warning.warn`.
|
|
|
|
Returns:
|
|
Return a decorator raising a category warning
|
|
on more than supported_number positional args.
|
|
|
|
See also:
|
|
https://docs.python.org/3/library/exceptions.html#FutureWarning
|
|
https://docs.python.org/3/library/exceptions.html#DeprecationWarning
|
|
https://docs.python.org/3/library/exceptions.html#PendingDeprecationWarning
|
|
"""
|
|
assert supported_number >= 0, f'supported_number => 0: {supported_number!r}'
|
|
|
|
if category is None:
|
|
def nulldecorator(func):
|
|
"""Return the undecorated function."""
|
|
return func
|
|
|
|
return nulldecorator
|
|
|
|
assert issubclass(category, Warning)
|
|
|
|
stacklevel += 1
|
|
|
|
def decorator(func):
|
|
signature = inspect.signature(func)
|
|
argnames = [name for name, param in signature.parameters.items()
|
|
if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD]
|
|
check_number = supported_number
|
|
if ignore_arg is not None:
|
|
ignored = [name for name in argnames if name == ignore_arg]
|
|
assert ignored, 'ignore_arg must be a positional arg'
|
|
check_number += len(ignored)
|
|
qualification = f' (ignoring {ignore_arg}))'
|
|
else:
|
|
qualification = ''
|
|
|
|
deprecated = argnames[supported_number:]
|
|
assert deprecated
|
|
log.debug('deprecate positional args: %s.%s(%r)',
|
|
func.__module__, func.__qualname__, deprecated)
|
|
|
|
# mangle function name in message for this package
|
|
func_name = func.__name__.lstrip('_')
|
|
func_name, sep, rest = func_name.partition('_legacy')
|
|
assert func_name and (not sep or not rest)
|
|
|
|
s_ = 's' if supported_number > 1 else ''
|
|
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
if len(args) > check_number:
|
|
call_args = zip(argnames, args)
|
|
supported = dict(itertools.islice(call_args, check_number))
|
|
deprecated = dict(call_args)
|
|
assert deprecated
|
|
wanted = ', '.join(f'{name}={value!r}'
|
|
for name, value in deprecated.items())
|
|
warnings.warn(f'The signature of {func_name} will be reduced'
|
|
f' to {supported_number} positional arg{s_}{qualification}'
|
|
f' {list(supported)}: pass {wanted} as keyword arg{s_}',
|
|
stacklevel=stacklevel,
|
|
category=category)
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|