2025-12-01
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Python Utils
|
||||
|
||||
This package contains dependency-free Python utility functions used throughout the
|
||||
codebase.
|
||||
|
||||
Each utility should belong in its own file and be the default export.
|
||||
|
||||
These functions are not part of the module interface and are subject to change.
|
||||
"""
|
||||
|
||||
from .convert_case import camel_to_snake, snake_to_camel
|
||||
from .cached_property import cached_property
|
||||
from .description import (
|
||||
Description,
|
||||
is_description,
|
||||
register_description,
|
||||
unregister_description,
|
||||
)
|
||||
from .did_you_mean import did_you_mean
|
||||
from .group_by import group_by
|
||||
from .identity_func import identity_func
|
||||
from .inspect import inspect
|
||||
from .is_awaitable import is_awaitable
|
||||
from .is_iterable import is_collection, is_iterable
|
||||
from .natural_compare import natural_comparison_key
|
||||
from .awaitable_or_value import AwaitableOrValue
|
||||
from .suggestion_list import suggestion_list
|
||||
from .frozen_error import FrozenError
|
||||
from .frozen_list import FrozenList
|
||||
from .frozen_dict import FrozenDict
|
||||
from .merge_kwargs import merge_kwargs
|
||||
from .path import Path
|
||||
from .print_path_list import print_path_list
|
||||
from .simple_pub_sub import SimplePubSub, SimplePubSubIterator
|
||||
from .undefined import Undefined, UndefinedType
|
||||
|
||||
__all__ = [
|
||||
"camel_to_snake",
|
||||
"snake_to_camel",
|
||||
"cached_property",
|
||||
"did_you_mean",
|
||||
"Description",
|
||||
"group_by",
|
||||
"is_description",
|
||||
"register_description",
|
||||
"unregister_description",
|
||||
"identity_func",
|
||||
"inspect",
|
||||
"is_awaitable",
|
||||
"is_collection",
|
||||
"is_iterable",
|
||||
"merge_kwargs",
|
||||
"natural_comparison_key",
|
||||
"AwaitableOrValue",
|
||||
"suggestion_list",
|
||||
"FrozenError",
|
||||
"FrozenList",
|
||||
"FrozenDict",
|
||||
"Path",
|
||||
"print_path_list",
|
||||
"SimplePubSub",
|
||||
"SimplePubSubIterator",
|
||||
"Undefined",
|
||||
"UndefinedType",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import Awaitable, TypeVar, Union
|
||||
|
||||
__all__ = ["AwaitableOrValue"]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
AwaitableOrValue = Union[Awaitable[T], T]
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
standard_cached_property = None
|
||||
else:
|
||||
try:
|
||||
from functools import cached_property as standard_cached_property
|
||||
except ImportError: # Python < 3.8
|
||||
standard_cached_property = None
|
||||
|
||||
if standard_cached_property:
|
||||
cached_property = standard_cached_property
|
||||
else:
|
||||
# Code taken from https://github.com/bottlepy/bottle
|
||||
|
||||
class CachedProperty:
|
||||
"""A cached property.
|
||||
|
||||
A property that is only computed once per instance and then replaces itself with
|
||||
an ordinary attribute. Deleting the attribute resets the property.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable) -> None:
|
||||
self.__doc__ = getattr(func, "__doc__")
|
||||
self.func = func
|
||||
|
||||
def __get__(self, obj: object, cls: type) -> Any:
|
||||
if obj is None:
|
||||
return self
|
||||
value = obj.__dict__[self.func.__name__] = self.func(obj)
|
||||
return value
|
||||
|
||||
cached_property = CachedProperty
|
||||
|
||||
__all__ = ["cached_property"]
|
||||
@@ -0,0 +1,25 @@
|
||||
# uses code from https://github.com/daveoncode/python-string-utils
|
||||
|
||||
import re
|
||||
|
||||
__all__ = ["camel_to_snake", "snake_to_camel"]
|
||||
|
||||
_re_camel_to_snake = re.compile(r"([a-z]|[A-Z0-9]+)(?=[A-Z])")
|
||||
_re_snake_to_camel = re.compile(r"(_)([a-z\d])")
|
||||
|
||||
|
||||
def camel_to_snake(s: str) -> str:
|
||||
"""Convert from CamelCase to snake_case"""
|
||||
return _re_camel_to_snake.sub(r"\1_", s).lower()
|
||||
|
||||
|
||||
def snake_to_camel(s: str, upper: bool = True) -> str:
|
||||
"""Convert from snake_case to CamelCase
|
||||
|
||||
If upper is set, then convert to upper CamelCase, otherwise the first character
|
||||
keeps its case.
|
||||
"""
|
||||
s = _re_snake_to_camel.sub(lambda m: m.group(2).upper(), s)
|
||||
if upper:
|
||||
s = s[:1].upper() + s[1:]
|
||||
return s
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any, Tuple, Union
|
||||
|
||||
__all__ = [
|
||||
"Description",
|
||||
"is_description",
|
||||
"register_description",
|
||||
"unregister_description",
|
||||
]
|
||||
|
||||
|
||||
class Description:
|
||||
"""Type checker for human readable descriptions.
|
||||
|
||||
By default, only ordinary strings are accepted as descriptions,
|
||||
but you can register() other classes that will also be allowed,
|
||||
e.g. to support lazy string objects that are evaluated only at runtime.
|
||||
If you register(object), any object will be allowed as description.
|
||||
"""
|
||||
|
||||
bases: Union[type, Tuple[type, ...]] = str
|
||||
|
||||
@classmethod
|
||||
def isinstance(cls, obj: Any) -> bool:
|
||||
return isinstance(obj, cls.bases)
|
||||
|
||||
@classmethod
|
||||
def register(cls, base: type) -> None:
|
||||
"""Register a class that shall be accepted as a description."""
|
||||
if not isinstance(base, type):
|
||||
raise TypeError("Only types can be registered.")
|
||||
if base is object:
|
||||
cls.bases = object
|
||||
elif cls.bases is object:
|
||||
cls.bases = base
|
||||
elif not isinstance(cls.bases, tuple):
|
||||
if base is not cls.bases:
|
||||
cls.bases = (cls.bases, base)
|
||||
elif base not in cls.bases:
|
||||
cls.bases += (base,)
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, base: type) -> None:
|
||||
"""Unregister a class that shall no more be accepted as a description."""
|
||||
if not isinstance(base, type):
|
||||
raise TypeError("Only types can be unregistered.")
|
||||
if isinstance(cls.bases, tuple):
|
||||
if base in cls.bases: # pragma: no branch
|
||||
cls.bases = tuple(b for b in cls.bases if b is not base)
|
||||
if not cls.bases:
|
||||
cls.bases = object
|
||||
elif len(cls.bases) == 1:
|
||||
cls.bases = cls.bases[0]
|
||||
elif cls.bases is base:
|
||||
cls.bases = object
|
||||
|
||||
|
||||
is_description = Description.isinstance
|
||||
register_description = Description.register
|
||||
unregister_description = Description.unregister
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing import Optional, Sequence
|
||||
|
||||
__all__ = ["did_you_mean"]
|
||||
|
||||
MAX_LENGTH = 5
|
||||
|
||||
|
||||
def did_you_mean(suggestions: Sequence[str], sub_message: Optional[str] = None) -> str:
|
||||
"""Given [ A, B, C ] return ' Did you mean A, B, or C?'"""
|
||||
if not suggestions or not MAX_LENGTH:
|
||||
return ""
|
||||
parts = [" Did you mean "]
|
||||
if sub_message:
|
||||
parts.extend([sub_message, " "])
|
||||
suggestions = suggestions[:MAX_LENGTH]
|
||||
n = len(suggestions)
|
||||
if n == 1:
|
||||
parts.append(f"'{suggestions[0]}'?")
|
||||
elif n == 2:
|
||||
parts.append(f"'{suggestions[0]}' or '{suggestions[1]}'?")
|
||||
else:
|
||||
parts.extend(
|
||||
[
|
||||
", ".join(f"'{s}'" for s in suggestions[:-1]),
|
||||
f", or '{suggestions[-1]}'?",
|
||||
]
|
||||
)
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,52 @@
|
||||
from copy import deepcopy
|
||||
from typing import Dict, TypeVar
|
||||
|
||||
from .frozen_error import FrozenError
|
||||
|
||||
__all__ = ["FrozenDict"]
|
||||
|
||||
KT = TypeVar("KT")
|
||||
VT = TypeVar("VT")
|
||||
|
||||
|
||||
class FrozenDict(Dict[KT, VT]):
|
||||
"""Dictionary that can only be read, but not changed.
|
||||
|
||||
.. deprecated:: 3.2
|
||||
Use dicts and the Mapping type instead. Will be removed in v3.3.
|
||||
"""
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise FrozenError
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
raise FrozenError
|
||||
|
||||
def __iadd__(self, value):
|
||||
raise FrozenError
|
||||
|
||||
def __hash__(self) -> int: # type: ignore
|
||||
return hash(tuple(self.items()))
|
||||
|
||||
def __copy__(self) -> "FrozenDict":
|
||||
return FrozenDict(self)
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def __deepcopy__(self, memo: Dict) -> "FrozenDict":
|
||||
return FrozenDict({k: deepcopy(v, memo) for k, v in self.items()})
|
||||
|
||||
def clear(self):
|
||||
raise FrozenError
|
||||
|
||||
def pop(self, key, default=None):
|
||||
raise FrozenError
|
||||
|
||||
def popitem(self):
|
||||
raise FrozenError
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
raise FrozenError
|
||||
|
||||
def update(self, other=None): # type: ignore
|
||||
raise FrozenError
|
||||
@@ -0,0 +1,5 @@
|
||||
__all__ = ["FrozenError"]
|
||||
|
||||
|
||||
class FrozenError(TypeError):
|
||||
"""Error when trying to change a frozen (read only) collection."""
|
||||
@@ -0,0 +1,70 @@
|
||||
from copy import deepcopy
|
||||
from typing import Dict, List, TypeVar
|
||||
|
||||
from .frozen_error import FrozenError
|
||||
|
||||
__all__ = ["FrozenList"]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class FrozenList(List[T]):
|
||||
"""List that can only be read, but not changed.
|
||||
|
||||
.. deprecated:: 3.2
|
||||
Use tuples or lists and the Collection type instead. Will be removed in v3.3.
|
||||
"""
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise FrozenError
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
raise FrozenError
|
||||
|
||||
def __add__(self, value):
|
||||
if isinstance(value, tuple):
|
||||
value = list(value)
|
||||
return list.__add__(self, value)
|
||||
|
||||
def __iadd__(self, value):
|
||||
raise FrozenError
|
||||
|
||||
def __mul__(self, value):
|
||||
return list.__mul__(self, value)
|
||||
|
||||
def __imul__(self, value):
|
||||
raise FrozenError
|
||||
|
||||
def __hash__(self) -> int: # type: ignore
|
||||
return hash(tuple(self))
|
||||
|
||||
def __copy__(self) -> "FrozenList":
|
||||
return FrozenList(self)
|
||||
|
||||
def __deepcopy__(self, memo: Dict) -> "FrozenList":
|
||||
return FrozenList(deepcopy(value, memo) for value in self)
|
||||
|
||||
def append(self, x):
|
||||
raise FrozenError
|
||||
|
||||
def extend(self, iterable):
|
||||
raise FrozenError
|
||||
|
||||
def insert(self, i, x):
|
||||
raise FrozenError
|
||||
|
||||
def remove(self, x):
|
||||
raise FrozenError
|
||||
|
||||
def pop(self, i=None):
|
||||
raise FrozenError
|
||||
|
||||
def clear(self):
|
||||
raise FrozenError
|
||||
|
||||
def sort(self, *, key=None, reverse=False):
|
||||
raise FrozenError
|
||||
|
||||
def reverse(self):
|
||||
raise FrozenError
|
||||
@@ -0,0 +1,16 @@
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Collection, Dict, List, TypeVar
|
||||
|
||||
__all__ = ["group_by"]
|
||||
|
||||
K = TypeVar("K")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def group_by(items: Collection[T], key_fn: Callable[[T], K]) -> Dict[K, List[T]]:
|
||||
"""Group an unsorted collection of items by a key derived via a function."""
|
||||
result: Dict[K, List[T]] = defaultdict(list)
|
||||
for item in items:
|
||||
key = key_fn(item)
|
||||
result[key].append(item)
|
||||
return result
|
||||
@@ -0,0 +1,13 @@
|
||||
from typing import cast, Any, TypeVar
|
||||
|
||||
from .undefined import Undefined
|
||||
|
||||
__all__ = ["identity_func"]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def identity_func(x: T = cast(Any, Undefined), *_args: Any) -> T:
|
||||
"""Return the first received argument."""
|
||||
return x
|
||||
@@ -0,0 +1,182 @@
|
||||
from inspect import (
|
||||
isclass,
|
||||
ismethod,
|
||||
isfunction,
|
||||
isgeneratorfunction,
|
||||
isgenerator,
|
||||
iscoroutinefunction,
|
||||
iscoroutine,
|
||||
isasyncgenfunction,
|
||||
isasyncgen,
|
||||
)
|
||||
from typing import Any, List
|
||||
|
||||
from .undefined import Undefined
|
||||
|
||||
__all__ = ["inspect"]
|
||||
|
||||
max_recursive_depth = 2
|
||||
max_str_size = 240
|
||||
max_list_size = 10
|
||||
|
||||
|
||||
def inspect(value: Any) -> str:
|
||||
"""Inspect value and a return string representation for error messages.
|
||||
|
||||
Used to print values in error messages. We do not use repr() in order to not
|
||||
leak too much of the inner Python representation of unknown objects, and we
|
||||
do not use json.dumps() because not all objects can be serialized as JSON and
|
||||
we want to output strings with single quotes like Python repr() does it.
|
||||
|
||||
We also restrict the size of the representation by truncating strings and
|
||||
collections and allowing only a maximum recursion depth.
|
||||
"""
|
||||
return inspect_recursive(value, [])
|
||||
|
||||
|
||||
def inspect_recursive(value: Any, seen_values: List) -> str:
|
||||
if value is None or value is Undefined or isinstance(value, (bool, float, complex)):
|
||||
return repr(value)
|
||||
if isinstance(value, (int, str, bytes, bytearray)):
|
||||
return trunc_str(repr(value))
|
||||
if len(seen_values) < max_recursive_depth and value not in seen_values:
|
||||
# check if we have a custom inspect method
|
||||
inspect_method = getattr(value, "__inspect__", None)
|
||||
if inspect_method is not None and callable(inspect_method):
|
||||
s = inspect_method()
|
||||
if isinstance(s, str):
|
||||
return trunc_str(s)
|
||||
seen_values = [*seen_values, value]
|
||||
return inspect_recursive(s, seen_values)
|
||||
# recursively inspect collections
|
||||
if isinstance(value, (list, tuple, dict, set, frozenset)):
|
||||
if not value:
|
||||
return repr(value)
|
||||
seen_values = [*seen_values, value]
|
||||
if isinstance(value, list):
|
||||
items = value
|
||||
elif isinstance(value, dict):
|
||||
items = list(value.items())
|
||||
else:
|
||||
items = list(value)
|
||||
items = trunc_list(items)
|
||||
if isinstance(value, dict):
|
||||
s = ", ".join(
|
||||
(
|
||||
"..."
|
||||
if v is ELLIPSIS
|
||||
else inspect_recursive(v[0], seen_values)
|
||||
+ ": "
|
||||
+ inspect_recursive(v[1], seen_values)
|
||||
)
|
||||
for v in items
|
||||
)
|
||||
else:
|
||||
s = ", ".join(
|
||||
"..." if v is ELLIPSIS else inspect_recursive(v, seen_values)
|
||||
for v in items
|
||||
)
|
||||
if isinstance(value, tuple):
|
||||
if len(items) == 1:
|
||||
return f"({s},)"
|
||||
return f"({s})"
|
||||
if isinstance(value, (dict, set)):
|
||||
return "{" + s + "}"
|
||||
if isinstance(value, frozenset):
|
||||
return f"frozenset({{{s}}})"
|
||||
return f"[{s}]"
|
||||
else:
|
||||
# handle collections that are nested too deep
|
||||
if isinstance(value, (list, tuple, dict, set, frozenset)):
|
||||
if not value:
|
||||
return repr(value)
|
||||
if isinstance(value, list):
|
||||
return "[...]"
|
||||
if isinstance(value, tuple):
|
||||
return "(...)"
|
||||
if isinstance(value, dict):
|
||||
return "{...}"
|
||||
if isinstance(value, set):
|
||||
return "set(...)"
|
||||
return "frozenset(...)"
|
||||
if isinstance(value, Exception):
|
||||
type_ = "exception"
|
||||
value = type(value)
|
||||
elif isclass(value):
|
||||
type_ = "exception class" if issubclass(value, Exception) else "class"
|
||||
elif ismethod(value):
|
||||
type_ = "method"
|
||||
elif iscoroutinefunction(value):
|
||||
type_ = "coroutine function"
|
||||
elif isasyncgenfunction(value):
|
||||
type_ = "async generator function"
|
||||
elif isgeneratorfunction(value):
|
||||
type_ = "generator function"
|
||||
elif isfunction(value):
|
||||
type_ = "function"
|
||||
elif iscoroutine(value):
|
||||
type_ = "coroutine"
|
||||
elif isasyncgen(value):
|
||||
type_ = "async generator"
|
||||
elif isgenerator(value):
|
||||
type_ = "generator"
|
||||
else:
|
||||
# stringify (only) the well-known GraphQL types
|
||||
from ..type import (
|
||||
GraphQLDirective,
|
||||
GraphQLNamedType,
|
||||
GraphQLScalarType,
|
||||
GraphQLWrappingType,
|
||||
)
|
||||
|
||||
if isinstance(
|
||||
value,
|
||||
(
|
||||
GraphQLDirective,
|
||||
GraphQLNamedType,
|
||||
GraphQLScalarType,
|
||||
GraphQLWrappingType,
|
||||
),
|
||||
):
|
||||
return str(value)
|
||||
try:
|
||||
name = type(value).__name__
|
||||
if not name or "<" in name or ">" in name:
|
||||
raise AttributeError
|
||||
except AttributeError:
|
||||
return "<object>"
|
||||
else:
|
||||
return f"<{name} instance>"
|
||||
try:
|
||||
name = value.__name__
|
||||
if not name or "<" in name or ">" in name:
|
||||
raise AttributeError
|
||||
except AttributeError:
|
||||
return f"<{type_}>"
|
||||
else:
|
||||
return f"<{type_} {name}>"
|
||||
|
||||
|
||||
def trunc_str(s: str) -> str:
|
||||
"""Truncate strings to maximum length."""
|
||||
if len(s) > max_str_size:
|
||||
i = max(0, (max_str_size - 3) // 2)
|
||||
j = max(0, max_str_size - 3 - i)
|
||||
s = s[:i] + "..." + s[-j:]
|
||||
return s
|
||||
|
||||
|
||||
def trunc_list(s: List) -> List:
|
||||
"""Truncate lists to maximum length."""
|
||||
if len(s) > max_list_size:
|
||||
i = max_list_size // 2
|
||||
j = i - 1
|
||||
s = s[:i] + [ELLIPSIS] + s[-j:]
|
||||
return s
|
||||
|
||||
|
||||
class InspectEllipsisType:
|
||||
"""Singleton class for indicating ellipses in iterables."""
|
||||
|
||||
|
||||
ELLIPSIS = InspectEllipsisType()
|
||||
@@ -0,0 +1,24 @@
|
||||
import inspect
|
||||
from typing import Any
|
||||
from types import CoroutineType, GeneratorType
|
||||
|
||||
__all__ = ["is_awaitable"]
|
||||
|
||||
CO_ITERABLE_COROUTINE = inspect.CO_ITERABLE_COROUTINE
|
||||
|
||||
|
||||
def is_awaitable(value: Any) -> bool:
|
||||
"""Return true if object can be passed to an ``await`` expression.
|
||||
|
||||
Instead of testing if the object is an instance of abc.Awaitable, it checks
|
||||
the existence of an `__await__` attribute. This is much faster.
|
||||
"""
|
||||
return (
|
||||
# check for coroutine objects
|
||||
isinstance(value, CoroutineType)
|
||||
# check for old-style generator based coroutine objects
|
||||
or isinstance(value, GeneratorType)
|
||||
and bool(value.gi_code.co_flags & CO_ITERABLE_COROUTINE)
|
||||
# check for other awaitables (e.g. futures)
|
||||
or hasattr(value, "__await__")
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from collections.abc import Collection, Iterable, Mapping, ValuesView
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["is_collection", "is_iterable"]
|
||||
|
||||
collection_types: Any = Collection
|
||||
if not isinstance({}.values(), Collection): # Python < 3.7.2
|
||||
collection_types = (Collection, ValuesView)
|
||||
iterable_types: Any = Iterable
|
||||
not_iterable_types: Any = (bytes, bytearray, memoryview, str, Mapping)
|
||||
|
||||
|
||||
def is_collection(value: Any) -> bool:
|
||||
"""Check if value is a collection, but not a string or a mapping."""
|
||||
return isinstance(value, collection_types) and not isinstance(
|
||||
value, not_iterable_types
|
||||
)
|
||||
|
||||
|
||||
def is_iterable(value: Any) -> bool:
|
||||
"""Check if value is an iterable, but not a string or a mapping."""
|
||||
return isinstance(value, iterable_types) and not isinstance(
|
||||
value, not_iterable_types
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import cast, Any, Dict, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def merge_kwargs(base_dict: T, **kwargs: Any) -> T:
|
||||
"""Return arbitrary typed dictionary with some keyword args merged in."""
|
||||
return cast(T, {**cast(Dict, base_dict), **kwargs})
|
||||
@@ -0,0 +1,19 @@
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
from itertools import cycle
|
||||
|
||||
__all__ = ["natural_comparison_key"]
|
||||
|
||||
_re_digits = re.compile(r"(\d+)")
|
||||
|
||||
|
||||
def natural_comparison_key(key: str) -> Tuple:
|
||||
"""Comparison key function for sorting strings by natural sort order.
|
||||
|
||||
See: https://en.wikipedia.org/wiki/Natural_sort_order
|
||||
"""
|
||||
return tuple(
|
||||
(int(part), part) if is_digit else part
|
||||
for part, is_digit in zip(_re_digits.split(key), cycle((False, True)))
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing import Any, List, NamedTuple, Optional, Union
|
||||
|
||||
__all__ = ["Path"]
|
||||
|
||||
|
||||
class Path(NamedTuple):
|
||||
"""A generic path of string or integer indices"""
|
||||
|
||||
prev: Any # Optional['Path'] (python/mypy/issues/731)
|
||||
"""path with the previous indices"""
|
||||
key: Union[str, int]
|
||||
"""current index in the path (string or integer)"""
|
||||
typename: Optional[str]
|
||||
"""name of the parent type to avoid path ambiguity"""
|
||||
|
||||
def add_key(self, key: Union[str, int], typename: Optional[str] = None) -> "Path":
|
||||
"""Return a new Path containing the given key."""
|
||||
return Path(self, key, typename)
|
||||
|
||||
def as_list(self) -> List[Union[str, int]]:
|
||||
"""Return a list of the path keys."""
|
||||
flattened: List[Union[str, int]] = []
|
||||
append = flattened.append
|
||||
curr: Path = self
|
||||
while curr:
|
||||
append(curr.key)
|
||||
curr = curr.prev
|
||||
return flattened[::-1]
|
||||
@@ -0,0 +1,6 @@
|
||||
from typing import Collection, Union
|
||||
|
||||
|
||||
def print_path_list(path: Collection[Union[str, int]]) -> str:
|
||||
"""Build a string describing the path."""
|
||||
return "".join(f"[{key}]" if isinstance(key, int) else f".{key}" for key in path)
|
||||
@@ -0,0 +1,81 @@
|
||||
from asyncio import Future, Queue, ensure_future, sleep
|
||||
from inspect import isawaitable
|
||||
from typing import Any, AsyncIterator, Callable, Optional, Set
|
||||
|
||||
try:
|
||||
from asyncio import get_running_loop
|
||||
except ImportError:
|
||||
from asyncio import get_event_loop as get_running_loop # Python < 3.7
|
||||
|
||||
|
||||
__all__ = ["SimplePubSub", "SimplePubSubIterator"]
|
||||
|
||||
|
||||
class SimplePubSub:
|
||||
"""A very simple publish-subscript system.
|
||||
|
||||
Creates an AsyncIterator from an EventEmitter.
|
||||
|
||||
Useful for mocking a PubSub system for tests.
|
||||
"""
|
||||
|
||||
subscribers: Set[Callable]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.subscribers = set()
|
||||
|
||||
def emit(self, event: Any) -> bool:
|
||||
"""Emit an event."""
|
||||
for subscriber in self.subscribers:
|
||||
result = subscriber(event)
|
||||
if isawaitable(result):
|
||||
ensure_future(result)
|
||||
return bool(self.subscribers)
|
||||
|
||||
def get_subscriber(
|
||||
self, transform: Optional[Callable] = None
|
||||
) -> "SimplePubSubIterator":
|
||||
return SimplePubSubIterator(self, transform)
|
||||
|
||||
|
||||
class SimplePubSubIterator(AsyncIterator):
|
||||
def __init__(self, pubsub: SimplePubSub, transform: Optional[Callable]) -> None:
|
||||
self.pubsub = pubsub
|
||||
self.transform = transform
|
||||
self.pull_queue: Queue[Future] = Queue()
|
||||
self.push_queue: Queue[Any] = Queue()
|
||||
self.listening = True
|
||||
pubsub.subscribers.add(self.push_value)
|
||||
|
||||
def __aiter__(self) -> "SimplePubSubIterator":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
if not self.listening:
|
||||
raise StopAsyncIteration
|
||||
await sleep(0)
|
||||
if not self.push_queue.empty():
|
||||
return await self.push_queue.get()
|
||||
future = get_running_loop().create_future()
|
||||
await self.pull_queue.put(future)
|
||||
return future
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self.listening:
|
||||
await self.empty_queue()
|
||||
|
||||
async def empty_queue(self) -> None:
|
||||
self.listening = False
|
||||
self.pubsub.subscribers.remove(self.push_value)
|
||||
while not self.pull_queue.empty():
|
||||
future = await self.pull_queue.get()
|
||||
future.cancel()
|
||||
while not self.push_queue.empty():
|
||||
await self.push_queue.get()
|
||||
|
||||
async def push_value(self, event: Any) -> None:
|
||||
value = event if self.transform is None else self.transform(event)
|
||||
if self.pull_queue.empty():
|
||||
await self.push_queue.put(value)
|
||||
else:
|
||||
(await self.pull_queue.get()).set_result(value)
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Collection, Optional, List
|
||||
|
||||
from .natural_compare import natural_comparison_key
|
||||
|
||||
__all__ = ["suggestion_list"]
|
||||
|
||||
|
||||
def suggestion_list(input_: str, options: Collection[str]) -> List[str]:
|
||||
"""Get list with suggestions for a given input.
|
||||
|
||||
Given an invalid input string and list of valid options, returns a filtered list
|
||||
of valid options sorted based on their similarity with the input.
|
||||
"""
|
||||
options_by_distance = {}
|
||||
lexical_distance = LexicalDistance(input_)
|
||||
|
||||
threshold = int(len(input_) * 0.4) + 1
|
||||
for option in options:
|
||||
distance = lexical_distance.measure(option, threshold)
|
||||
if distance is not None:
|
||||
options_by_distance[option] = distance
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
return sorted(
|
||||
options_by_distance,
|
||||
key=lambda option: (
|
||||
options_by_distance.get(option, 0),
|
||||
natural_comparison_key(option),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class LexicalDistance:
|
||||
"""Computes the lexical distance between strings A and B.
|
||||
|
||||
The "distance" between two strings is given by counting the minimum number of edits
|
||||
needed to transform string A into string B. An edit can be an insertion, deletion,
|
||||
or substitution of a single character, or a swap of two adjacent characters.
|
||||
|
||||
This distance can be useful for detecting typos in input or sorting.
|
||||
"""
|
||||
|
||||
_input: str
|
||||
_input_lower_case: str
|
||||
_input_list: List[int]
|
||||
_rows: List[List[int]]
|
||||
|
||||
def __init__(self, input_: str):
|
||||
self._input = input_
|
||||
self._input_lower_case = input_.lower()
|
||||
row_size = len(input_) + 1
|
||||
self._input_list = list(map(ord, self._input_lower_case))
|
||||
|
||||
self._rows = [[0] * row_size, [0] * row_size, [0] * row_size]
|
||||
|
||||
def measure(self, option: str, threshold: int) -> Optional[int]:
|
||||
if self._input == option:
|
||||
return 0
|
||||
|
||||
option_lower_case = option.lower()
|
||||
|
||||
# Any case change counts as a single edit
|
||||
if self._input_lower_case == option_lower_case:
|
||||
return 1
|
||||
|
||||
a, b = list(map(ord, option_lower_case)), self._input_list
|
||||
a_len, b_len = len(a), len(b)
|
||||
if a_len < b_len:
|
||||
a, b = b, a
|
||||
a_len, b_len = b_len, a_len
|
||||
|
||||
if a_len - b_len > threshold:
|
||||
return None
|
||||
|
||||
rows = self._rows
|
||||
for j in range(b_len + 1):
|
||||
rows[0][j] = j
|
||||
|
||||
for i in range(1, a_len + 1):
|
||||
up_row = rows[(i - 1) % 3]
|
||||
current_row = rows[i % 3]
|
||||
|
||||
smallest_cell = current_row[0] = i
|
||||
for j in range(1, b_len + 1):
|
||||
cost = 0 if a[i - 1] == b[j - 1] else 1
|
||||
|
||||
current_cell = min(
|
||||
up_row[j] + 1, # delete
|
||||
current_row[j - 1] + 1, # insert
|
||||
up_row[j - 1] + cost, # substitute
|
||||
)
|
||||
|
||||
if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
|
||||
# transposition
|
||||
double_diagonal_cell = rows[(i - 2) % 3][j - 2]
|
||||
current_cell = min(current_cell, double_diagonal_cell + 1)
|
||||
|
||||
if current_cell < smallest_cell:
|
||||
smallest_cell = current_cell
|
||||
|
||||
current_row[j] = current_cell
|
||||
|
||||
# Early exit, since distance can't go smaller than smallest element
|
||||
# of the previous row.
|
||||
if smallest_cell > threshold:
|
||||
return None
|
||||
|
||||
distance = rows[a_len % 3][b_len]
|
||||
return distance if distance <= threshold else None
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["Undefined", "UndefinedType"]
|
||||
|
||||
|
||||
class UndefinedType(ValueError):
|
||||
"""Auxiliary class for creating the Undefined singleton."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Undefined"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(UndefinedType)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return other is Undefined
|
||||
|
||||
def __ne__(self, other: Any) -> bool:
|
||||
return not self == other
|
||||
|
||||
|
||||
# Used to indicate undefined or invalid values (like "undefined" in JavaScript):
|
||||
Undefined = UndefinedType()
|
||||
|
||||
Undefined.__doc__ = """Symbol for undefined values
|
||||
|
||||
This singleton object is used to describe undefined or invalid values.
|
||||
It can be used in places where you would use ``undefined`` in GraphQL.js.
|
||||
"""
|
||||
Reference in New Issue
Block a user