# █ █ ▀ █▄▀ ▄▀█ █▀█ ▀ # █▀█ █ █ █ █▀█ █▀▄ █ # © Copyright 2022 # https://t.me/hikariatama # # 🔒 Licensed under the GNU AGPLv3 # 🌐 https://www.gnu.org/licenses/agpl-3.0.html import ast import logging from dataclasses import dataclass, field from typing import Any, Optional, Union from .inline.types import * # skipcq: PYL-W0614 from . import validators # skipcq: PY-W2000 from importlib.abc import SourceLoader from telethon.tl.types import Message logger = logging.getLogger(__name__) class StringLoader(SourceLoader): """Load a python module/file from a string""" def __init__(self, data: str, origin: str): self.data = data.encode("utf-8") if isinstance(data, str) else data self.origin = origin def get_code(self, fullname: str) -> str: return ( compile(source, self.origin, "exec", dont_inherit=True) if (source := self.get_source(fullname)) else None ) def get_filename(self, *args, **kwargs) -> str: return self.origin def get_data(self, *args, **kwargs) -> bytes: return self.data class Module: strings = {"name": "Unknown"} """There is no help for this module""" def config_complete(self): """Called when module.config is populated""" async def client_ready(self, client, db): """Called after client is ready (after config_loaded)""" async def on_unload(self): """Called after unloading / reloading module""" async def on_dlmod(self, client, db): """ Called after the module is first time loaded with .dlmod or .loadmod Possible use-cases: - Send reaction to author's channel message - Join author's channel - Create asset folder - ... ⚠️ Note, that any error there will not interrupt module load, and will just send a message to logs with verbosity INFO and exception traceback """ class Library: """All external libraries must have a class-inheritant from this class""" class LoadError(Exception): """Tells user, why your module can't be loaded, if raised in `client_ready`""" def __init__(self, error_message: str): # skipcq: PYL-W0231 self._error = error_message def __str__(self) -> str: return self._error class CoreOverwriteError(Exception): """Is being raised when core module or command is overwritten""" def __init__(self, module: Optional[str] = None, command: Optional[str] = None): self.type = "module" if module else "command" self.target = module or command super().__init__() def __str__(self) -> str: return ( f"Module {self.target} will not be overwritten, because it's core" if self.type == "module" else f"Command {self.target} will not be overwritten, because it's core" ) class CoreUnloadError(Exception): """Is being raised when user tries to unload core module""" def __init__(self, module: str): self.module = module super().__init__() def __str__(self) -> str: return f"Module {self.module} will not be unloaded, because it's core" class SelfUnload(Exception): """Silently unloads module, if raised in `client_ready`""" def __init__(self, error_message: Optional[str] = ""): super().__init__() self._error = error_message def __str__(self) -> str: return self._error class SelfSuspend(Exception): """ Silently suspends module, if raised in `client_ready` Commands and watcher will not be registered if raised Module won't be unloaded from db and will be unfreezed after restart, unless the exception is raised again """ def __init__(self, error_message: Optional[str] = ""): super().__init__() self._error = error_message def __str__(self) -> str: return self._error class StopLoop(Exception): """Stops the loop, in which is raised""" class ModuleConfig(dict): """Stores config for modules and apparently libraries""" def __init__(self, *entries): if all(isinstance(entry, ConfigValue) for entry in entries): # New config format processing self._config = {config.option: config for config in entries} else: # Legacy config processing keys = [] values = [] defaults = [] docstrings = [] for i, entry in enumerate(entries): if i % 3 == 0: keys += [entry] elif i % 3 == 1: values += [entry] defaults += [entry] else: docstrings += [entry] self._config = { key: ConfigValue(option=key, default=default, doc=doc) for key, default, doc in zip(keys, defaults, docstrings) } super().__init__( {option: config.value for option, config in self._config.items()} ) def getdoc(self, key: str, message: Message = None) -> str: """Get the documentation by key""" ret = self._config[key].doc if callable(ret): try: # Compatibility tweak # does nothing in Hikka ret = ret(message) except Exception: ret = ret() return ret def getdef(self, key: str) -> str: """Get the default value by key""" return self._config[key].default def __setitem__(self, key: str, value: Any): self._config[key].value = value self.update({key: value}) def set_no_raise(self, key: str, value: Any): self._config[key].set_no_raise(value) self.update({key: value}) def __getitem__(self, key: str) -> Any: try: return self._config[key].value except KeyError: return None LibraryConfig = ModuleConfig class _Placeholder: """Placeholder to determine if the default value is going to be set""" @dataclass(repr=True) class ConfigValue: option: str default: Any = None doc: Union[callable, str] = "No description" value: Any = field(default_factory=_Placeholder) validator: Optional[callable] = None def __post_init__(self): if isinstance(self.value, _Placeholder): self.value = self.default def set_no_raise(self, value: Any) -> bool: """ Sets the config value w/o ValidationError being raised Should not be used uninternally """ return self.__setattr__("value", value, ignore_validation=True) def __setattr__( self, key: str, value: Any, *, ignore_validation: Optional[bool] = False, ) -> bool: if key == "value": try: value = ast.literal_eval(value) except Exception: pass # Convert value to list if it's tuple just not to mess up # with json convertations if isinstance(value, (set, tuple)): value = list(value) if isinstance(value, list): value = [ item.strip() if isinstance(item, str) else item for item in value ] if self.validator is not None: if value is not None: try: value = self.validator.validate(value) except validators.ValidationError as e: if not ignore_validation: raise e logger.debug( f"Config value was broken ({value}), so it was reset to" f" {self.default}" ) value = self.default else: defaults = { "String": "", "Integer": 0, "Boolean": False, "Series": [], "Float": 0.0, } if self.validator.internal_id in defaults: logger.debug( "Config value was None, so it was reset to" f" {defaults[self.validator.internal_id]}" ) value = defaults[self.validator.internal_id] # This attribute will tell the `Loader` to save this value in db self._save_marker = True object.__setattr__(self, key, value)