Spaces:
Building
Building
# Copyright 2024 The etils Authors. | |
# | |
# Licensed under the Apache License, Version 2.0 (the "License"); | |
# you may not use this file except in compliance with the License. | |
# You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, software | |
# distributed under the License is distributed on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
# See the License for the specific language governing permissions and | |
# limitations under the License. | |
"""Python utils.""" | |
from __future__ import annotations | |
from collections.abc import Callable, Sequence | |
import enum | |
import functools | |
import sys | |
import typing | |
from typing import Any, Optional, TypeVar, Union | |
StrOrStrList = Union[str, Sequence[str]] | |
_Cls = TypeVar('_Cls') | |
_StrEnum = ( | |
(enum.StrEnum,) if sys.version_info[:2] >= (3, 11) else (str, enum.Enum) | |
) | |
class StrEnum(*_StrEnum): | |
"""Like `Enum`, but `enum.auto()` assigns `str` rather than `int`. | |
```python | |
class MyEnum(epy.StrEnum): | |
SOME_ATTR = enum.auto() | |
OTHER_ATTR = enum.auto() | |
assert MyEnum('some_attr') is MyEnum.SOME_ATTR | |
assert MyEnum.SOME_ATTR == 'some_attr' | |
``` | |
`StrEnum` is case insensitive. | |
""" | |
# `issubclass(StrEnum, str)`, so can annotate `str` instead of `str | StrEnum` | |
def _generate_next_value_(name, start, count, last_values) -> str: # pylint: disable=no-self-argument | |
return name.lower() | |
def _missing_(cls, value: str) -> StrEnum: | |
if isinstance(value, str) and not value.islower(): | |
return cls(value.lower()) | |
# Could also add `did you meant yy ?` | |
all_values = [e.value for e in cls] | |
raise ValueError( | |
f'{value!r} is not a valid {cls.__qualname__}. ' | |
f'Expected one of {all_values}' | |
) | |
def __eq__(self, other: str) -> bool: | |
return super().__eq__(other.lower()) | |
def __hash__(self) -> int: # pylint: disable=useless-super-delegation | |
# Somehow `hash` is not defined automatically (maybe because of | |
# the `__eq__`, so define it explicitly. | |
return super().__hash__() | |
# Pytype is confused by EnumMeta.__iter__ vs str.__iter__ | |
if typing.TYPE_CHECKING: | |
def __iter__(cls): | |
return type(enum.Enum).__iter__(cls) | |
def is_namedtuple(x) -> bool: | |
"""Returns `True` if the value is instance of `NamedTuple`. | |
This is using some heuristic by checking for a `._field` attribute. | |
Args: | |
x: Object to check | |
Returns: | |
`True` if the object is a `namedtuple` | |
""" | |
return isinstance(x, tuple) and hasattr(type(x), '_fields') | |
def issubclass_( | |
cls: Any, | |
types: Union[type[Any], tuple[type[Any], ...]], | |
) -> bool: | |
"""Like `issubclass`, but do not raise error if value is not `type`.""" | |
return isinstance(cls, type) and issubclass(cls, types) | |
def _wrap_init(init_fn): | |
"""`__init__` wrapper.""" | |
def new_init(self, *args, **kwargs): | |
# Do NOT use `hasattr` to support children with custom `__getattr__` | |
if '_epy_is_init_done' in self.__dict__: | |
# `_epy_is_init_done` already created, so it means we're | |
# a `super().__init__` call. | |
return init_fn(self, *args, **kwargs) | |
object.__setattr__(self, '_epy_is_init_done', False) | |
init_fn(self, *args, **kwargs) | |
object.__setattr__(self, '_epy_is_init_done', True) | |
return new_init | |
def _wrap_setattr(setattr_fn): | |
"""`__setattr__` wrapper.""" | |
def new_setattr(self, name, value): | |
if not hasattr(self, '_epy_is_init_done'): | |
raise ValueError( | |
'Child of `@epy.frozen` class should be `@epy.frozen` too. (Error' | |
f' raised by {type(self)})' | |
) | |
if not self._epy_is_init_done: # pylint: disable=protected-access | |
return setattr_fn(self, name, value) | |
else: | |
raise AttributeError( | |
f'Cannot assign {name!r} in `@epy.frozen` class {type(self)}' | |
) | |
return new_setattr | |
def frozen(cls: _Cls) -> _Cls: | |
"""Class decorator which prevent mutating attributes after `__init__`. | |
Example: | |
```python | |
@epy.frozen | |
class A: | |
def __init__(self): | |
self.x = 123 | |
a = A() | |
a.x = 456 # AttributeError | |
``` | |
Supports inheritance, child classes should explicitly be marked as | |
`@epy.frozen` if they mutate additional attributes in `__init__`. | |
Args: | |
cls: The class to freeze. | |
Returns: | |
cls: The class object | |
""" | |
if not isinstance(cls, type): | |
raise TypeError(f'{cls.__name__} is not') | |
cls.__init__ = _wrap_init(cls.__init__) | |
cls.__setattr__ = _wrap_setattr(cls.__setattr__) | |
return cls | |
def normalize_str_to_list(x: Optional[StrOrStrList]) -> list[str]: | |
if x is None: | |
return [] | |
elif isinstance(x, str): | |
return [v.strip() for v in x.split(',')] | |
elif not isinstance(x, (list, tuple)): | |
raise TypeError(f'Expected list. Got: {x!r}') | |
else: # list/tuple | |
return list(x) | |
def wraps_cls(wrapped: type[Any]) -> Callable[[_Cls], _Cls]: | |
"""Equivalent of `functools.wraps` but for classes.""" | |
def decorator(cls): | |
cls.__name__ = wrapped.__name__ | |
cls.__qualname__ = wrapped.__qualname__ | |
cls.__doc__ = wrapped.__doc__ | |
cls.__module__ = wrapped.__module__ | |
return cls | |
return decorator | |