diff options
| author | 2025-01-04 22:38:23 +0800 | |
|---|---|---|
| committer | 2025-01-04 22:38:23 +0800 | |
| commit | c990518cb533a793399e44edbb4bc036342c7175 (patch) | |
| tree | 8e2bd0f833b803a73dea7d88e7c294cf3d078d4d /src | |
| parent | bc57c1410c08323ba37114082d0fe609fafc2c5d (diff) | |
| download | HydroRollCore-c990518cb533a793399e44edbb4bc036342c7175.tar.gz HydroRollCore-c990518cb533a793399e44edbb4bc036342c7175.zip | |
Diffstat (limited to 'src')
34 files changed, 1446 insertions, 12 deletions
diff --git a/src/hrc/__init__.py b/src/hrc/__init__.py new file mode 100644 index 0000000..4876897 --- /dev/null +++ b/src/hrc/__init__.py @@ -0,0 +1 @@ +from ._core import * # noqa: F403
\ No newline at end of file diff --git a/src/hrc/_core.abi3.so b/src/hrc/_core.abi3.so Binary files differnew file mode 100755 index 0000000..2d85fa5 --- /dev/null +++ b/src/hrc/_core.abi3.so diff --git a/src/hrc/_core.pyi b/src/hrc/_core.pyi new file mode 100644 index 0000000..78ae466 --- /dev/null +++ b/src/hrc/_core.pyi @@ -0,0 +1,4 @@ +class LibCore(object): + """Core library for hydro roll""" + + def __init__(self, name: str = ""): ...
\ No newline at end of file diff --git a/src/hrc/cli.py b/src/hrc/cli.py new file mode 100644 index 0000000..55758bc --- /dev/null +++ b/src/hrc/cli.py @@ -0,0 +1,46 @@ +import argparse + + +class Cli(object): + parser = argparse.ArgumentParser(description="水系核心终端") + + def __init__(self): + self.parser.add_argument( + "-i", + "--install", + dest="command", + help="安装规则包", + action="store_const", + const="install_package", + ) + self.parser.add_argument( + "-T", + "--template", + dest="command", + help="选择模板快速创建规则包实例", + action="store_const", + const="build_template", + ) + self.parser.add_argument( + "-S", + "--search", + dest="command", + help="在指定镜像源查找规则包", + action="store_const", + const="search_package", + ) + self.parser.add_argument( + "-c", + "--config", + dest="command", + help="配置管理", + action="store_const", + const="config", + ) + self.args = self.parser.parse_args() + + def get_args(self): + return self.args + + def get_help(self): + return self.parser.format_help() diff --git a/src/hrc/config.py b/src/hrc/config.py new file mode 100644 index 0000000..d179258 --- /dev/null +++ b/src/hrc/config.py @@ -0,0 +1,34 @@ +from typing import Literal, Optional, Set, Union + +from pydantic import BaseModel, ConfigDict, DirectoryPath, Field + + +class ConfigModel(BaseModel): + model_config = ConfigDict(extra="allow") + + __config_name__: str = "" + + +class LogConfig(ConfigModel): + level: Union[str, int] = "DEBUG" + verbose_exception: bool = False + + +class ServiceConfig(ConfigModel): + """Service configuration.""" + + +class CoreConfig(ConfigModel): + rules: Set[str] = Field(default_factory=set) + rule_dirs: Set[DirectoryPath] = Field(default_factory=set) + log: LogConfig = LogConfig() + services: Set[str] = Field(default_factory=set) + +class RuleConfig(ConfigModel): + """Rule configuration.""" + + +class MainConfig(ConfigModel): + core: CoreConfig = CoreConfig() + rule: RuleConfig = RuleConfig() + service: ServiceConfig = ServiceConfig() diff --git a/src/hrc/const.py b/src/hrc/const.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/const.py diff --git a/src/hrc/core.py b/src/hrc/core.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/core.py diff --git a/src/hrc/dependencies.py b/src/hrc/dependencies.py new file mode 100644 index 0000000..e176e14 --- /dev/null +++ b/src/hrc/dependencies.py @@ -0,0 +1,115 @@ +import inspect +from contextlib import AsyncExitStack, asynccontextmanager, contextmanager +from typing import ( + Any, + AsyncContextManager, + AsyncGenerator, + Callable, + ContextManager, + Dict, + Generator, + Optional, + Type, + TypeVar, + Union, + cast, +) + +from hrc.utils import get_annotations, sync_ctx_manager_wrapper + +_T = TypeVar("_T") +Dependency = Union[ + # Class + Type[Union[_T, AsyncContextManager[_T], ContextManager[_T]]], + # GeneratorContextManager + Callable[[], AsyncGenerator[_T, None]], + Callable[[], Generator[_T, None, None]], +] + + +__all__ = ["Depends"] + + +class InnerDepends: + + dependency: Optional[Dependency[Any]] + use_cache: bool + + def __init__( + self, dependency: Optional[Dependency[Any]] = None, *, use_cache: bool = True + ) -> None: + self.dependency = dependency + self.use_cache = use_cache + + def __repr__(self) -> str: + attr = getattr(self.dependency, "__name__", type(self.dependency).__name__) + cache = "" if self.use_cache else ", use_cache=False" + return f"InnerDepends({attr}{cache})" + + +def Depends(dependency: Optional[Dependency[_T]] = None, *, use_cache: bool = True) -> _T: + return InnerDepends(dependency=dependency, use_cache=use_cache) # type: ignore + + +async def solve_dependencies( + dependent: Dependency[_T], + *, + use_cache: bool, + stack: AsyncExitStack, + dependency_cache: Dict[Dependency[Any], Any], +) -> _T: + if use_cache and dependent in dependency_cache: + return dependency_cache[dependent] + + if isinstance(dependent, type): + # type of dependent is Type[T] + values: Dict[str, Any] = {} + ann = get_annotations(dependent) + for name, sub_dependent in inspect.getmembers( + dependent, lambda x: isinstance(x, InnerDepends) + ): + assert isinstance(sub_dependent, InnerDepends) + if sub_dependent.dependency is None: + dependent_ann = ann.get(name, None) + if dependent_ann is None: + raise TypeError("can not solve dependent") + sub_dependent.dependency = dependent_ann + values[name] = await solve_dependencies( + sub_dependent.dependency, + use_cache=sub_dependent.use_cache, + stack=stack, + dependency_cache=dependency_cache, + ) + depend_obj = cast( + Union[_T, AsyncContextManager[_T], ContextManager[_T]], + dependent.__new__(dependent), # pyright: ignore[reportGeneralTypeIssues] + ) + for key, value in values.items(): + setattr(depend_obj, key, value) + depend_obj.__init__() # type: ignore[misc] # pylint: disable=unnecessary-dunder-call + + if isinstance(depend_obj, AsyncContextManager): + depend = await stack.enter_async_context( + depend_obj # pyright: ignore[reportUnknownArgumentType] + ) + elif isinstance(depend_obj, ContextManager): + depend = await stack.enter_async_context( + sync_ctx_manager_wrapper( + depend_obj # pyright: ignore[reportUnknownArgumentType] + ) + ) + else: + depend = depend_obj + elif inspect.isasyncgenfunction(dependent): + # type of dependent is Callable[[], AsyncGenerator[T, None]] + cm = asynccontextmanager(dependent)() + depend = cast(_T, await stack.enter_async_context(cm)) + elif inspect.isgeneratorfunction(dependent): + # type of dependent is Callable[[], Generator[T, None, None]] + cm = sync_ctx_manager_wrapper(contextmanager(dependent)()) + depend = cast(_T, await stack.enter_async_context(cm)) + else: + raise TypeError("dependent is not a class or generator function") + + dependency_cache[dependent] = depend + return depend
\ No newline at end of file diff --git a/src/hrc/dev/__init__.py b/src/hrc/dev/__init__.py new file mode 100644 index 0000000..ea8f56b --- /dev/null +++ b/src/hrc/dev/__init__.py @@ -0,0 +1 @@ +from hrc.dev.grps import v1
\ No newline at end of file diff --git a/src/hrc/dev/api/__init__.py b/src/hrc/dev/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/dev/api/__init__.py diff --git a/src/hrc/dev/character.py b/src/hrc/dev/character.py new file mode 100644 index 0000000..c883e45 --- /dev/null +++ b/src/hrc/dev/character.py @@ -0,0 +1,2 @@ +class Character: + class Attribute: ... diff --git a/src/hrc/dev/echo.py b/src/hrc/dev/echo.py new file mode 100644 index 0000000..5bdeab7 --- /dev/null +++ b/src/hrc/dev/echo.py @@ -0,0 +1,105 @@ +"""HydroRoll-Team/echo +水系跨平台事件标准(cross-platform event standard): Event Communication and Harmonization across Online platforms. +:ref: https://github/com/HydroRoll-Team/echo +:ref: https://echo.hydroroll.team +""" + +class Event(object): + """事件基类 + :ref: https://echo.hydroroll.team/Event/#0_event + """ + def __init__(self, event_type, data, metadata): + self.event_type = event_type + self.data = data + self.metadata = metadata + +class WorkFlow(Event): + """workflow + :ref: https://echo.hydroroll.team/Event/#1_workflow + """ + def __init__(self, data, metadata): + super().__init__('workflow', data, metadata) + +class CallBack(Event): + """callback + :ref: https://echo.hydroroll.team/Event/#4_callback + """ + def __init__(self, data, metadata): + super().__init__('callback', data, metadata) + +class Message(Event): + """message + :ref: https://echo.hydroroll.team/Event/#2_message + """ + def __init__(self, data, metadata): + super().__init__('message', data, metadata) + +class Reaction(Event): + """reaction + :ref: https://echo.hydroroll.team/Event/#3_reaction + """ + def __init__(self, data, metadata): + super().__init__('reaction', data, metadata) + +class Typing(Event): + """typing + :ref: https://echo.hydroroll.team/Event/#5_typing + """ + def __init__(self, data, metadata): + super().__init__('typing', data, metadata) + +class UserJoin(Event): + """user join + :ref: https://echo.hydroroll.team/Event/#6_user_join + """ + def __init__(self, data, metadata): + super().__init__('user_join', data, metadata) + +class UserLeave(Event): + """user leave + :ref: https://echo.hydroroll.team/Event/#7_user_leave + """ + def __init__(self, data, metadata): + super().__init__('user_leave', data, metadata) + +class FileShare(Event): + """file share + :ref: https://echo.hydroroll.team/Event/#8_file_share + """ + def __init__(self, data, metadata): + super().__init__('file_share', data, metadata) + +class Mention(Event): + """mention + :ref: https://echo.hydroroll.team/Event/#9_mention + """ + def __init__(self, data, metadata): + super().__init__('mention', data, metadata) + +class ChannelCreate(Event): + """channel create + :ref: https://echo.hydroroll.team/Event/#10_channel_create + """ + def __init__(self, data, metadata): + super().__init__('channel_create', data, metadata) + +class ChannelDelete(Event): + """channel delete + :ref: https://echo.hydroroll.team/Event/#11_channel_delete + """ + def __init__(self, data, metadata): + super().__init__('channel_delete', data, metadata) + +class ChannelUpdate(Event): + """channel update + :ref: https://echo.hydroroll.team/Event/#12_channel_update + """ + def __init__(self, data, metadata): + super().__init__('channel_update', data, metadata) + +class UserUpdate(Event): + """user update + :ref: https://echo.hydroroll.team/Event/#13_user_update + """ + def __init__(self, data, metadata): + super().__init__('user_update', data, metadata)
\ No newline at end of file diff --git a/src/hrc/dev/grps/__init__.py b/src/hrc/dev/grps/__init__.py new file mode 100644 index 0000000..bbf8c7e --- /dev/null +++ b/src/hrc/dev/grps/__init__.py @@ -0,0 +1 @@ +from . import v1
\ No newline at end of file diff --git a/src/hrc/dev/grps/v1.py b/src/hrc/dev/grps/v1.py new file mode 100644 index 0000000..fa987a7 --- /dev/null +++ b/src/hrc/dev/grps/v1.py @@ -0,0 +1,12 @@ +from datetime import datetime +from pydantic import BaseModel + + +__version__ = "1.0.0-alpha.1" + +class GRPS(BaseModel): + id: str + name: str + description: str + created_at: datetime + updated_at: datetime
\ No newline at end of file diff --git a/src/hrc/doc/__init__.py b/src/hrc/doc/__init__.py new file mode 100644 index 0000000..998b999 --- /dev/null +++ b/src/hrc/doc/__init__.py @@ -0,0 +1 @@ +import sphinx
\ No newline at end of file diff --git a/src/hrc/event.py b/src/hrc/event.py new file mode 100644 index 0000000..961a356 --- /dev/null +++ b/src/hrc/event.py @@ -0,0 +1,94 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Generic, Optional, Union +from typing_extensions import Self + +from pydantic import BaseModel, ConfigDict +from hrc.typing import RuleT + + +class Event(ABC, BaseModel, Generic[RuleT]): + model_config = ConfigDict(extra="allow") + + if TYPE_CHECKING: + rule: RuleT + else: + rule: Any + type: Optional[str] + __handled__: bool = False + + def __str__(self) -> str: + return f"Event<{self.type}>" + + def __repr__(self) -> str: + return self.__str__() + + +class MessageEvent(Event[RuleT], Generic[RuleT]): + """Base class for general message event classes.""" + + @abstractmethod + def get_plain_text(self) -> str: + """Get the plain text content of the message. + + Returns: + The plain text content of the message. + """ + + @abstractmethod + async def reply(self, message: str) -> Any: + """Reply message. + + Args: + message: The content of the reply message. + + Returns: + The response to the reply message action. + """ + + @abstractmethod + async def is_same_sender(self, other: Self) -> bool: + """Determine whether itself and another event are the same sender. + + Args: + other: another event. + + Returns: + Is it the same sender? + """ + + async def get( + self, + *, + max_try_times: Optional[int] = None, + timeout: Optional[Union[int, float]] = None, + ) -> Self: + + return await self.rule.get( + self.is_same_sender, + event_type=type(self), + max_try_times=max_try_times, + timeout=timeout, + ) + + async def ask( + self, + message: str, + max_try_times: Optional[int] = None, + timeout: Optional[Union[int, float]] = None, + ) -> Self: + """Ask for news. + + Indicates getting the user's reply after replying to a message. + Equivalent to executing ``get()`` after ``reply()``. + + Args: + message: The content of the reply message. + max_try_times: Maximum number of events. + timeout: timeout period. + + Returns: + Message event that the user replies to. + """ + + await self.reply(message) + return await self.get(max_try_times=max_try_times, timeout=timeout) diff --git a/src/hrc/exceptions.py b/src/hrc/exceptions.py new file mode 100644 index 0000000..c71118f --- /dev/null +++ b/src/hrc/exceptions.py @@ -0,0 +1,22 @@ +class EventException(BaseException): + ... + + +class SkipException(EventException): + ... + + +class StopException(EventException): + ... + + +class CoreException(Exception): + ... # noqa: N818 + + +class GetEventTimeout(CoreException): + ... + + +class LoadModuleError(CoreException): + ... diff --git a/src/hrc/feat/__init__.py b/src/hrc/feat/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/feat/__init__.py diff --git a/src/hrc/log.py b/src/hrc/log.py new file mode 100644 index 0000000..8e476a6 --- /dev/null +++ b/src/hrc/log.py @@ -0,0 +1,22 @@ +import os +import sys +from datetime import datetime +from typing import Optional + +from loguru import logger as _logger + +logger = _logger + +current_path = os.path.dirname(os.path.abspath("__file__")) +log_path = os.path.join( + current_path, "logs", datetime.now().strftime("%Y-%m-%d") + ".log" +) + +def error_or_exception(message: str, exception: Optional[Exception], verbose: bool = True): + logger.remove() + logger.add(sys.stderr) + logger.add(sink=log_path, level="INFO", rotation="10 MB") + if verbose: + logger.exception(message) + else: + logger.critical(f"{message} {exception!r}") diff --git a/src/hrc/perf/__init__.py b/src/hrc/perf/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/perf/__init__.py diff --git a/src/hrc/py.typed b/src/hrc/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/py.typed diff --git a/src/hrc/rule/BaseRule/CharacterCard.py b/src/hrc/rule/BaseRule/CharacterCard.py new file mode 100644 index 0000000..2baea48 --- /dev/null +++ b/src/hrc/rule/BaseRule/CharacterCard.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + + +@dataclass +class Custom(object): + """Docstring for Custom.""" + + property: type + + +class Attribute(Custom): ... + + +class Skill(Custom): ... + + +class Information(Custom): ... diff --git a/src/hrc/rule/BaseRule/CustomRule.py b/src/hrc/rule/BaseRule/CustomRule.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/rule/BaseRule/CustomRule.py diff --git a/src/hrc/rule/BaseRule/Wiki.py b/src/hrc/rule/BaseRule/Wiki.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/hrc/rule/BaseRule/Wiki.py diff --git a/src/hrc/rule/BaseRule/__init__.py b/src/hrc/rule/BaseRule/__init__.py new file mode 100644 index 0000000..fdde86c --- /dev/null +++ b/src/hrc/rule/BaseRule/__init__.py @@ -0,0 +1,3 @@ +from . import CharacterCard # noqa: F401 +from . import CustomRule # noqa: F401 +from . import Wiki # noqa: F401 diff --git a/src/hrc/rule/__init__.py b/src/hrc/rule/__init__.py new file mode 100644 index 0000000..282e3e2 --- /dev/null +++ b/src/hrc/rule/__init__.py @@ -0,0 +1,164 @@ +import functools # noqa: F401 +from typing import Generic, Any, Type + +from abc import ABC + +from hrc.rule import BaseRule # noqa: F401 +from hrc.typing import RuleT # noqa: F401 + +import inspect +from abc import abstractmethod # noqa: F401 +from enum import Enum +from typing import ( + TYPE_CHECKING, + ClassVar, + NoReturn, + Optional, + Tuple, + cast, + final, +) +from typing_extensions import Annotated, get_args, get_origin + +from hrc.config import ConfigModel + +from hrc.dependencies import Depends +from hrc.event import Event +from hrc.exceptions import SkipException, StopException +from hrc.typing import ConfigT, EventT, StateT +from hrc.utils import is_config_class + +if TYPE_CHECKING: + from hrc.core import Core + + +class RuleLoadType(Enum): + """Rules loaded types.""" + + DIR = "dir" + NAME = "name" + FILE = "file" + CLASS = "class" + + +class Rule(ABC, Generic[EventT, StateT, ConfigT]): + priority: ClassVar[int] = 0 + block: ClassVar[bool] = False + + # Cannot use ClassVar because PEP 526 does not allow it + Config: Type[ConfigT] + + __rule_load_type__: ClassVar[RuleLoadType] + __rule_file_path__: ClassVar[Optional[str]] + + if TYPE_CHECKING: + event: EventT + else: + event = Depends(Event) # noqa: F821 + + def __init_state__(self) -> Optional[StateT]: + """Initialize rule state.""" + + def __init_subclass__( + cls, + config: Optional[Type[ConfigT]] = None, + init_state: Optional[StateT] = None, + **_kwargs: Any, + ) -> None: + super().__init_subclass__() + + orig_bases: Tuple[type, ...] = getattr(cls, "__orig_bases__", ()) + for orig_base in orig_bases: + origin_class = get_origin(orig_base) + if inspect.isclass(origin_class) and issubclass(origin_class, Rule): + try: + _event_t, state_t, config_t = cast( + Tuple[EventT, StateT, ConfigT], get_args(orig_base) + ) + except ValueError: # pragma: no cover + continue + if ( + config is None + and inspect.isclass(config_t) + and issubclass(config_t, ConfigModel) + ): + config = config_t # pyright: ignore + if ( + init_state is None + and get_origin(state_t) is Annotated + and hasattr(state_t, "__metadata__") + ): + init_state = state_t.__metadata__[0] # pyright: ignore + + if not hasattr(cls, "Config") and config is not None: + cls.Config = config + if cls.__init_state__ is Rule.__init_state__ and init_state is not None: + cls.__init_state__ = lambda _: init_state # type: ignore + + @final + @property + def name(self) -> str: + """rule class name.""" + return self.__class__.__name__ + + @final + @property + def core(self) -> "Core": + """core object.""" + return self.event.core # pylint: disable=no-member + + @final + @property + def config(self) -> ConfigT: + """rule configuration.""" + default: Any = None + config_class = getattr(self, "Config", None) + if is_config_class(config_class): + return getattr( + self.core.config.rule, + config_class.__config_name__, + default, + ) + return default + + @final + def stop(self) -> NoReturn: + """Stop propagation of current events.""" + raise StopException + + @final + def skip(self) -> NoReturn: + """Skips itself and continues propagation of the current event.""" + raise SkipException + + @property + def state(self) -> StateT: + """rule status.""" + return self.core.rule_state[self.name] + + @state.setter + @final + def state(self, value: StateT) -> None: + self.core.rule_state[self.name] = value + + async def enable(self): ... + + async def disable(self): ... + + @staticmethod + def aliases(names, ignore_case=False): + def decorator(func): + func._aliases = names + func._ignore_case = ignore_case + return func + + return decorator + + @final + async def safe_run(self) -> None: + try: + await self.enable() + except Exception as e: + self.bot.error_or_exception( + f"Enable rule {self.__class__.__name__} failed:", e + ) diff --git a/src/hrc/service/__init__.py b/src/hrc/service/__init__.py new file mode 100644 index 0000000..f153d5b --- /dev/null +++ b/src/hrc/service/__init__.py @@ -0,0 +1,111 @@ +import os +from abc import ABC, abstractmethod +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generic, + Optional, + Type, + TypeVar, + Union, + final, + overload, +) + +from hrc.event import Event +from hrc.typing import ConfigT, EventT +from hrc.utils import is_config_class + +if TYPE_CHECKING: + from ..core import Core + +__all__ = ["Server"] + +if os.getenv("IAMAI_DEV") == "1": # pragma: no cover + # 当处于开发环境时,使用 pkg_resources 风格的命名空间包 + __import__("pkg_resources").declare_namespace(__name__) + + +_EventT = TypeVar("_EventT", bound="Event[Any]") + + +class Service(Generic[EventT, ConfigT], ABC): + name: str + core: "Core" + Config: Type[ConfigT] + + def __init__(self, core: "Core") -> None: + if not hasattr(self, "name"): + self.name = self.__class__.__name__ + self.core: Core = core + self.handle_event = self.core.handle_event + + @property + def config(self) -> ConfigT: + default: Any = None + config_class = getattr(self, "Config", None) + if is_config_class(config_class): + return getattr( + self.core.config.service, + config_class.__config_name__, + default, + ) + return default + + @final + async def safe_run(self) -> None: + try: + await self.run() + except Exception as e: + self.core.error_or_exception( + f"Run service {self.__class__.__name__} failed:", e + ) + + @abstractmethod + async def run(self) -> None: + raise NotImplementedError + + async def startup(self) -> None: + ... + + async def shutdown(self) -> None: + ... + + @overload + async def get( + self, + func: Optional[Callable[[EventT], Union[bool, Awaitable[bool]]]] = None, + *, + event_type: None = None, + max_try_times: Optional[int] = None, + timeout: Optional[Union[int, float]] = None, + ) -> EventT: ... + + @overload + async def get( + self, + func: Optional[Callable[[_EventT], Union[bool, Awaitable[bool]]]] = None, + *, + event_type: Type[_EventT], + max_try_times: Optional[int] = None, + timeout: Optional[Union[int, float]] = None, + ) -> _EventT: ... + + @final + async def get( + self, + func: Optional[Callable[[Any], Union[bool, Awaitable[bool]]]] = None, + *, + event_type: Any = None, + max_try_times: Optional[int] = None, + timeout: Optional[Union[int, float]] = None, + ) -> Event[Any]: + return await self.core.get( + func, + event_type=event_type, + server_type=type(self), + max_try_times=max_try_times, + timeout=timeout, + )
\ No newline at end of file diff --git a/src/hrc/service/console/__init__.py b/src/hrc/service/console/__init__.py new file mode 100644 index 0000000..e11768e --- /dev/null +++ b/src/hrc/service/console/__init__.py @@ -0,0 +1,41 @@ +import asyncio +import sys +from typing_extensions import override + +from hrc.event import MessageEvent +from hrc.service import Service + +class ConsoleServiceEvent(MessageEvent["ConsoleService"]): + message: str + + @override + def get_sender_id(self) -> None: + return None + + @override + def get_plain_text(self) -> str: + return self.message + + @override + async def reply(self, message: str) -> None: + return await self.service.send(message) + + async def is_same_sender(self) -> bool: + return True + +class ConsoleService(Service[ConsoleServiceEvent, None]): + name: str = "console" + + @override + async def run(self) -> None: + while not self.core.should_exit.is_set(): + print("Please input message: ") # noqa: T201 + message = await asyncio.get_event_loop().run_in_executor( + None, sys.stdin.readline + ) + await self.handle_event( + ConsoleServiceEvent(service=self, type="message", message=message, rule="") + ) + + async def send(self, message: str) -> None: + print(f"Send a message: {message}") # noqa: T201
\ No newline at end of file diff --git a/src/hrc/service/http/__init__.py b/src/hrc/service/http/__init__.py new file mode 100644 index 0000000..a8d938b --- /dev/null +++ b/src/hrc/service/http/__init__.py @@ -0,0 +1,33 @@ +from typing_extensions import override + +from aiohttp import web + +from hrc.service.utils import HttpServerService +from hrc.event import Event +from hrc.log import logger +from aiohttp import web + +class HttpServerTestEvent(Event["HttpServerTestService"]): + """HTTP 服务端示例适配器事件类。""" + + message: str + + +class HttpServerTestService(HttpServerService[HttpServerTestEvent, None]): + name: str = "http_server_service" + get_url: str = "/" + post_url: str = "/" + host: str = "127.0.0.1" + port: int = 8080 + + + @override + async def handle_response(self, request: web.Request) -> web.StreamResponse: + event = HttpServerTestEvent( + service=self, + type="message", + rule="", + message=await request.text(), + ) + await self.handle_event(event) + return web.Response()
\ No newline at end of file diff --git a/src/hrc/service/utils.py b/src/hrc/service/utils.py new file mode 100644 index 0000000..4f29e0c --- /dev/null +++ b/src/hrc/service/utils.py @@ -0,0 +1,256 @@ +import asyncio +from abc import ABCMeta, abstractmethod +from typing import Literal, Optional, Union + +import aiohttp +from aiohttp import web + +from hrc.service import Service +from hrc.log import logger +from hrc.typing import ConfigT, EventT + +__all__ = [ + "PollingService", + "HttpClientService", + "WebSocketClientService", + "HttpServerService", + "WebSocketServerService", + "WebSocketService", +] + + +class PollingService(Service[EventT, ConfigT], metaclass=ABCMeta): + """轮询式适配器示例。""" + + delay: float = 0.1 + create_task: bool = False + _on_tick_task: Optional["asyncio.Task[None]"] = None + + async def run(self) -> None: + while not self.core.should_exit.is_set(): + await asyncio.sleep(self.delay) + if self.create_task: + self._on_tick_task = asyncio.create_task(self.on_tick()) + else: + await self.on_tick() + + @abstractmethod + async def on_tick(self) -> None: + """当轮询发生。""" + + +class HttpClientService(PollingService[EventT, ConfigT], metaclass=ABCMeta): + session: aiohttp.ClientSession + + async def startup(self) -> None: + self.session = aiohttp.ClientSession() + + @abstractmethod + async def on_tick(self) -> None: + ... + async def shutdown(self) -> None: + """关闭并清理连接。""" + await self.session.close() + + +class WebSocketClientService(Service[EventT, ConfigT], metaclass=ABCMeta): + url: str + + async def run(self) -> None: + async with aiohttp.ClientSession() as session, session.ws_connect( + self.url + ) as ws: + msg: aiohttp.WSMessage + async for msg in ws: + if self.core.should_exit.is_set(): + break + if msg.type == aiohttp.WSMsgType.ERROR: + break + await self.handle_response(msg) + + @abstractmethod + async def handle_response(self, msg: aiohttp.WSMessage) -> None: + """处理响应。""" + + +class HttpServerService(Service[EventT, ConfigT], metaclass=ABCMeta): + app: web.Application + runner: web.AppRunner + site: web.TCPSite + host: str + port: int + get_url: str + post_url: str + + async def startup(self) -> None: + """初始化适配器。""" + self.app = web.Application() + self.app.add_routes( + [ + web.get(self.get_url, self.handle_response), + web.post(self.post_url, self.handle_response), + ] + ) + + async def run(self) -> None: + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + + async def shutdown(self) -> None: + """关闭并清理连接。""" + await self.runner.cleanup() + + @abstractmethod + async def handle_response(self, request: web.Request) -> web.StreamResponse: + """处理响应。""" + + +class WebSocketServerService(Service[EventT, ConfigT], metaclass=ABCMeta): + app: web.Application + runner: web.AppRunner + site: web.TCPSite + websocket: web.WebSocketResponse + host: str + port: int + url: str + + async def startup(self) -> None: + self.app = web.Application() + self.app.add_routes([web.get(self.url, self.handle_response)]) + + async def run(self) -> None: + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + + async def shutdown(self) -> None: + """关闭并清理连接。""" + await self.websocket.close() + await self.site.stop() + await self.runner.cleanup() + + async def handle_response(self, request: web.Request) -> web.WebSocketResponse: + """处理 WebSocket。""" + ws = web.WebSocketResponse() + await ws.prepare(request) + self.websocket = ws + + msg: aiohttp.WSMessage + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await self.handle_ws_response(msg) + elif msg.type == aiohttp.WSMsgType.ERROR: + break + + return ws + + @abstractmethod + async def handle_ws_response(self, msg: aiohttp.WSMessage) -> None: + """处理 WebSocket 响应。""" + + +class WebSocketService(Service[EventT, ConfigT], metaclass=ABCMeta): + """ + 同时支持 WebSocket 客户端和服务端。 + """ + + websocket: Union[web.WebSocketResponse, aiohttp.ClientWebSocketResponse, None] = ( + None + ) + + # ws + session: Optional[aiohttp.ClientSession] + + # reverse-ws + app: Optional[web.Application] + runner: Optional[web.AppRunner] + site: Optional[web.TCPSite] + + # config + service_type: Literal["ws", "reverse-ws"] + host: str + port: int + url: str + reconnect_interval: int = 3 + + async def startup(self) -> None: + if self.service_type == "ws": + self.session = aiohttp.ClientSession() + elif self.service_type == "reverse-ws": + self.app = web.Application() + self.app.add_routes([web.get(self.url, self.handle_reverse_ws_response)]) + else: + logger.error( + 'Config "service_type" must be "ws" or "reverse-ws", not ' + + self.service_type + ) + + async def run(self) -> None: + if self.service_type == "ws": + while True: + try: + await self.websocket_connect() + except aiohttp.ClientError as e: + self.core.error_or_exception("WebSocket connection error:", e) + if self.core.should_exit.is_set(): + break + await asyncio.sleep(self.reconnect_interval) + elif self.service_type == "reverse-ws": + assert self.app is not None + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + + async def shutdown(self) -> None: + """关闭并清理连接。""" + if self.websocket is not None: + await self.websocket.close() + if self.service_type == "ws": + if self.session is not None: + await self.session.close() + elif self.service_type == "reverse-ws": + if self.site is not None: + await self.site.stop() + if self.runner is not None: + await self.runner.cleanup() + + async def handle_reverse_ws_response( + self, request: web.Request + ) -> web.WebSocketResponse: + """处理 aiohttp WebSocket 服务器的接收。""" + self.websocket = web.WebSocketResponse() + await self.websocket.prepare(request) + await self.reverse_ws_connection_hook() + await self.handle_websocket() + return self.websocket + + async def reverse_ws_connection_hook(self) -> None: + """反向 WebSocket 连接建立时的钩子函数。""" + logger.info("WebSocket connected!") + + async def websocket_connect(self) -> None: + """创建正向 WebSocket 连接。""" + assert self.session is not None + logger.info("Tying to connect to WebSocket server...") + async with self.session.ws_connect( + f"ws://{self.host}:{self.port}{self.url}" + ) as self.websocket: + await self.handle_websocket() + + async def handle_websocket(self) -> None: + """处理 WebSocket。""" + if self.websocket is None or self.websocket.closed: + return + async for msg in self.websocket: + await self.handle_websocket_msg(msg) + if not self.core.should_exit.is_set(): + logger.warning("WebSocket connection closed!") + + @abstractmethod + async def handle_websocket_msg(self, msg: aiohttp.WSMessage) -> None: + """处理 WebSocket 消息。""" + raise NotImplementedError
\ No newline at end of file diff --git a/src/hrc/service/websocket/__init__.py b/src/hrc/service/websocket/__init__.py new file mode 100644 index 0000000..3a7f089 --- /dev/null +++ b/src/hrc/service/websocket/__init__.py @@ -0,0 +1,30 @@ +from typing import Any, Coroutine +from typing_extensions import override +from aiohttp import web, ClientWebSocketResponse + +from hrc.service.utils import WebSocketService +from hrc.event import Event +from hrc.log import logger + +from aiohttp import web + +class WebSocketTestEvent(Event["WebSocketTestEvent"]): + message: str + +class WebSocketTestService(WebSocketService[WebSocketTestEvent, None]): + name: str = "websocket_test_service" + service_type: str = "reverse-ws" + host: str = "127.0.0.1" + port: int = 8765 + url: str = "/" + + @override + async def handle_reverse_ws_response(self, request: web.Request) -> Coroutine[Any, Any, ClientWebSocketResponse]: + event = WebSocketTestEvent( + service=self, + type="message", + message=await request.text() + ) + logger.info(f"Receive {event}") + await self.handle_event(event) + return web.Response()
\ No newline at end of file diff --git a/src/hrc/typing.py b/src/hrc/typing.py new file mode 100644 index 0000000..a207c80 --- /dev/null +++ b/src/hrc/typing.py @@ -0,0 +1,23 @@ +# ruff: noqa: TCH001 +from typing import TYPE_CHECKING, Awaitable, Callable, Optional, TypeVar + +if TYPE_CHECKING: + from typing import Any + + from hrc.service import Service + from hrc.core import Core + from hrc.config import ConfigModel + from hrc.event import Event + from hrc.rule import Rule + + +StateT = TypeVar("StateT") +EventT = TypeVar("EventT", bound="Event[Any]") +RuleT = TypeVar("RuleT", bound="Rule[Any, Any, Any]") +ConfigT = TypeVar("ConfigT", bound=Optional["ConfigModel"]) +ServiceT = TypeVar("ServiceT", bound="Service[Any, Any]") + +CoreHook = Callable[["Core"], Awaitable[None]] +RuleHook = Callable[["Rule"], Awaitable[None]] +ServiceHook = Callable[["Service[Any, Any]"], Awaitable[None]] +EventHook = Callable[["Event[Any]"], Awaitable[None]] diff --git a/src/hrc/utils.py b/src/hrc/utils.py new file mode 100644 index 0000000..e1399f5 --- /dev/null +++ b/src/hrc/utils.py @@ -0,0 +1,291 @@ +"""A utility used internally by iamai.""" + +import asyncio +import importlib +import inspect +import json +import os +import os.path +import sys +import traceback +from abc import ABC +from contextlib import asynccontextmanager +from functools import partial +from importlib.abc import MetaPathFinder +from importlib.machinery import ModuleSpec, PathFinder +from types import GetSetDescriptorType, ModuleType +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Awaitable, + Callable, + ClassVar, + ContextManager, + Coroutine, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + cast, +) +from typing_extensions import ParamSpec, TypeAlias, TypeGuard + +from pydantic import BaseModel + +from hrc.config import ConfigModel +from hrc.typing import EventT + +if TYPE_CHECKING: + from os import PathLike + +__all__ = [ + "ModulePathFinder", + "is_config_class", + "get_classes_from_module", + "get_classes_from_module_name", + "PydanticEncoder", + "samefile", + "sync_func_wrapper", + "sync_ctx_manager_wrapper", + "wrap_get_func", + "get_annotations", +] + +_T = TypeVar("_T") +_P = ParamSpec("_P") +_R = TypeVar("_R") +_TypeT = TypeVar("_TypeT", bound=Type[Any]) + +StrOrBytesPath: TypeAlias = Union[str, bytes, "PathLike[str]", "PathLike[bytes]"] + + +class ModulePathFinder(MetaPathFinder): + """Meta path finder for finding iamai components.""" + + path: ClassVar[List[str]] = [] + + def find_spec( + self, + fullname: str, + path: Optional[Sequence[str]] = None, + target: Optional[ModuleType] = None, + ) -> Union[ModuleSpec, None]: + """Used to find the ``spec`` of a specified module.""" + if path is None: + path = [] + return PathFinder.find_spec(fullname, self.path + list(path), target) + + +def is_config_class(config_class: Any) -> TypeGuard[Type[ConfigModel]]: + return ( + inspect.isclass(config_class) + and issubclass(config_class, ConfigModel) + and isinstance(getattr(config_class, "__config_name__", None), str) + and ABC not in config_class.__bases__ + and not inspect.isabstract(config_class) + ) + + +def get_classes_from_module(module: ModuleType, super_class: _TypeT) -> List[_TypeT]: + """Find a class of the specified type from the module. + + Args: + module: Python module. + super_class: The superclass of the class to be found. + + Returns: + Returns a list of classes that meet the criteria. + """ + classes: List[_TypeT] = [] + for _, module_attr in inspect.getmembers(module, inspect.isclass): + if ( + (inspect.getmodule(module_attr) or module) is module + and issubclass(module_attr, super_class) + and module_attr != super_class + and ABC not in module_attr.__bases__ + and not inspect.isabstract(module_attr) + ): + classes.append(cast(_TypeT, module_attr)) + return classes + + +def get_classes_from_module_name( + name: str, super_class: _TypeT, *, reload: bool = False +) -> List[Tuple[_TypeT, ModuleType]]: + """Find a class of the specified type from the module with the specified name. + + Args: + name: module name, the format is the same as the Python ``import`` statement. + super_class: The superclass of the class to be found. + reload: Whether to reload the module. + + Returns: + Returns a list of tuples consisting of classes and modules that meet the criteria. + + Raises: + ImportError: An error occurred while importing the module. + """ + try: + importlib.invalidate_caches() + module = importlib.import_module(name) + if reload: + importlib.reload(module) + return [(x, module) for x in get_classes_from_module(module, super_class)] + except KeyboardInterrupt: + # Do not capture KeyboardInterrupt + # Catching KeyboardInterrupt will prevent the user from closing Python when the module being imported is stuck in an infinite loop + raise + except BaseException as e: + raise ImportError(e, traceback.format_exc()) from e + + +class PydanticEncoder(json.JSONEncoder): + """``JSONEncoder`` class for parsing ``pydantic.BaseModel``.""" + + def default(self, o: Any) -> Any: + """Returns a serializable object of ``o``.""" + if isinstance(o, BaseModel): + return o.model_dump(mode="json") + return super().default(o) + + +def samefile(path1: StrOrBytesPath, path2: StrOrBytesPath) -> bool: + """A simple wrapper around ``os.path.samefile``. + + Args: + path1: path1. + path2: path 2. + + Returns: + If two paths point to the same file or directory. + """ + try: + return path1 == path2 or os.path.samefile(path1, path2) # noqa: PTH121 + except OSError: + return False + + +def sync_func_wrapper( + func: Callable[_P, _R], *, to_thread: bool = False +) -> Callable[_P, Coroutine[None, None, _R]]: + """Wrap a synchronous function as an asynchronous function. + + Args: + func: synchronous function to be packaged. + to_thread: Whether to run the synchronization function in a separate thread. Defaults to ``False``. + + Returns: + Asynchronous functions. + """ + if to_thread: + + async def _wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + loop = asyncio.get_running_loop() + func_call = partial(func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + else: + + async def _wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + return func(*args, **kwargs) + + return _wrapper + + +@asynccontextmanager +async def sync_ctx_manager_wrapper( + cm: ContextManager[_T], *, to_thread: bool = False +) -> AsyncGenerator[_T, None]: + """Wrap a synchronous context manager into an asynchronous context manager. + + Args: + cm: The synchronization context manager to be wrapped. + to_thread: Whether to run the synchronization function in a separate thread. Defaults to ``False``. + + Returns: + Asynchronous context manager. + """ + try: + yield await sync_func_wrapper(cm.__enter__, to_thread=to_thread)() + except Exception as e: + if not await sync_func_wrapper(cm.__exit__, to_thread=to_thread)( + type(e), e, e.__traceback__ + ): + raise + else: + await sync_func_wrapper(cm.__exit__, to_thread=to_thread)(None, None, None) + + +def wrap_get_func( + func: Optional[Callable[[EventT], Union[bool, Awaitable[bool]]]], +) -> Callable[[EventT], Awaitable[bool]]: + """Wrap the parameters accepted by the ``get()`` function into an asynchronous function. + + Args: + func: The parameters accepted by the ``get()`` function. + + Returns: + Asynchronous functions. + """ + if func is None: + return sync_func_wrapper(lambda _: True) + if not asyncio.iscoroutinefunction(func): + return sync_func_wrapper(func) # type: ignore + return func + + +if sys.version_info >= (3, 10): # pragma: no cover + from inspect import get_annotations +else: # pragma: no cover + + def get_annotations( + obj: Union[Callable[..., object], Type[Any], ModuleType], + ) -> Dict[str, Any]: + """Compute the annotation dictionary of an object. + + Args: + obj: A callable object, class, or module. + + Raises: + TypeError: ``obj`` is not a callable object, class or module. + ValueError: Object's ``__annotations__`` is not a dictionary or ``None``. + + Returns: + Annotation dictionary for objects. + """ + ann: Union[Dict[str, Any], None] + + if isinstance(obj, type): + # class + obj_dict = getattr(obj, "__dict__", None) + if obj_dict and hasattr(obj_dict, "get"): + ann = obj_dict.get("__annotations__", None) + if isinstance(ann, GetSetDescriptorType): + ann = None + else: + ann = None + elif isinstance(obj, ModuleType) or callable(obj): + # this includes types.ModuleType, types.Function, types.BuiltinFunctionType, + # types.BuiltinMethodType, functools.partial, functools.singledispatch, + # "class funclike" from Lib/test/test_inspect... on and on it goes. + ann = getattr(obj, "__annotations__", None) + else: + raise TypeError(f"{obj!r} is not a module, class, or callable.") + + if ann is None: + return {} + + if not isinstance(ann, dict): + raise ValueError( # noqa: TRY004 + f"{obj!r}.__annotations__ is neither a dict nor None" + ) + + if not ann: + return {} + + return dict(ann) @@ -1,19 +1,24 @@ use pyo3::prelude::*; -use pyo3::wrap_pyfunction; #[pyfunction] -fn process_rule_pack(rule_pack: &str) -> PyResult<String> { - Ok(format!("Processed rule pack: {}", rule_pack)) +fn sum_as_string(a: usize, b: usize) -> PyResult<String> { + Ok((a + b).to_string()) } -#[pymodule] -#[pyo3(name = "LibCore")] -fn libcore(_py: Python, m: &PyModule) -> PyResult<()> { - let _py_hrc_log = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/hrc/log.py")); - let _py_hrc_event = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/hrc/event.py")); - let _py_hrc_core = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/hrc/core.py")); - let _py_hrc_const = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/hrc/const.py")); +#[pyclass] +pub struct Base { + pub value: i32, +} - m.add_function(wrap_pyfunction!(process_rule_pack, m)?)?; - Ok(()) +impl Base { + fn new(value: i32) -> Self { + Base { value } + } } + +#[pymodule] +fn _core(_py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(sum_as_string, m)?)?; + m.add_class::<Base>()?; + Ok(()) +}
\ No newline at end of file |
