Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a6d37fb
:sparkles: APIRequest/Response event for call_api_hook
RF-Tar-Railt Jul 24, 2026
ecb380d
:sparkles: add `startswith` `endswith` filter
RF-Tar-Railt Jul 31, 2026
763eb0a
:sparkles: improve MessageChain impl
RF-Tar-Railt Aug 3, 2026
ed43b45
:sparkles: add `fullmatch` `regexmatch` filter
RF-Tar-Railt Aug 3, 2026
ee27b19
:sparkles: improve plugin import code
RF-Tar-Railt Aug 4, 2026
08f567b
:sparkles: add plugin inspect for skip reload on semantic-identical f…
RF-Tar-Railt Aug 7, 2026
043fb95
:sparkles: add Lock in plugin reload (serialize)
RF-Tar-Railt Aug 7, 2026
2ab1441
:sparkles: support Hot-Swap function if only fn changes
RF-Tar-Railt Aug 7, 2026
7f2e313
:sparkles: hot-swap support append fn in file tail
RF-Tar-Railt Aug 9, 2026
9cf1eb7
:recycle: allow import changes when hot-swapping functions
RF-Tar-Railt Aug 10, 2026
45f544d
:sparkles: replace referent cascade with rebind driver and surface fi…
RF-Tar-Railt Aug 11, 2026
bb7d5e2
:sparkles: support staged plugin registration and atomic reload
RF-Tar-Railt Aug 11, 2026
090c24d
:sparkles: reload sub-plugins at sub-plugin granularity
RF-Tar-Railt Aug 11, 2026
01ce828
:bug: fix missing subtree match
RF-Tar-Railt Aug 11, 2026
071a5e2
:zap: cascade dependents in topological order and parallelize service…
RF-Tar-Railt Aug 11, 2026
1976690
:sparkles: keep module-level mutable state across full reload
RF-Tar-Railt Aug 11, 2026
73b2a96
:sparkles: reload upstream common modules and their dependent plugins
RF-Tar-Railt Aug 12, 2026
a2c8727
:bug: fix problem in subplugin-reload
RF-Tar-Railt Aug 12, 2026
fc068bd
:bug: fix more problem in subplugin-reload and adapt command delete i…
RF-Tar-Railt Aug 12, 2026
7b876bc
:bug: fix dispose referent handle, avoid dangling-binding
RF-Tar-Railt Aug 13, 2026
f0985c2
:bug: hot-swap do not replace old-fn object to new-fn
RF-Tar-Railt Aug 13, 2026
93b9b94
:arrow_up: use letoderea 0.22.0
RF-Tar-Railt Aug 14, 2026
bfa9673
:bookmark: version 0.19.0rc1
RF-Tar-Railt Aug 14, 2026
d03d800
:bug: fix namespace module import
RF-Tar-Railt Aug 20, 2026
c7b57c4
:label: improve MessageChain typing
RF-Tar-Railt Aug 20, 2026
2e05f3d
:bookmark: version 0.19.0rc2
RF-Tar-Railt Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions arclet/entari/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
from .event import BaseEvent as BaseEvent
from .event import attr as attr
from .event import register_internal_event as register_internal_event
from .event.api import SendRequest as SendRequest
from .event.api import SendResponse as SendResponse
from .event.base import MessageCreatedEvent as MessageCreatedEvent
from .event.base import MessageEvent as MessageEvent
from .event.base import Reply as Reply
Expand All @@ -63,8 +65,6 @@
from .event.lifespan import Cleanup as Cleanup
from .event.lifespan import Ready as Ready
from .event.lifespan import Startup as Startup
from .event.send import SendRequest as SendRequest
from .event.send import SendResponse as SendResponse
from .filter import filter_ as filter_
from .localdata import local_data as local_data
from .message import MessageChain as MessageChain
Expand All @@ -91,4 +91,4 @@
WS = WebsocketsInfo
WH = WebhookInfo

__version__ = "0.18.5"
__version__ = "0.19.0rc2"
175 changes: 153 additions & 22 deletions arclet/entari/builtins/auto_reload.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import ast
import asyncio
import importlib
import sys
from dataclasses import asdict
from pathlib import Path
from traceback import format_exception_only
from types import ModuleType

from arclet.letoderea import post, publish
from launart import Launart, Service, any_completed
Expand All @@ -16,7 +21,18 @@
from arclet.entari.config import BasicConfModel, EntariConfig, model_field
from arclet.entari.event.config import ConfigReload
from arclet.entari.logger import log
from arclet.entari.plugin import PluginRole, find_plugin, find_plugin_by_file, unload_plugin_async
from arclet.entari.plugin import (
Plugin,
PluginRole,
find_plugin,
find_plugin_by_file,
plugin_service,
reload_plugin,
reload_subplugin,
unload_plugin_async,
)
from arclet.entari.plugin.swap import classify, swap_functions
from arclet.entari.utils import escape_tag

# declare_static()
loguru_logger.disable("watchfiles.main")
Expand Down Expand Up @@ -67,6 +83,29 @@ class Config(BasicConfModel):
logger = log.wrapper("[AutoReload]").opt(colors=True)


def module_name_from_path(path: Path) -> str | None:
path = Path(path).resolve()

for entry in map(Path, sys.path):
try:
relative = path.relative_to(entry.resolve())
except ValueError:
continue

if relative.suffix == ".py":
relative = relative.with_suffix("")

parts = list(relative.parts)

# __init__.py 对应包本身
if parts[-1] == "__init__":
parts.pop()

return ".".join(parts)

return None


class Watcher(Service):
id = "entari.plugin.auto_reload/watcher"

Expand All @@ -81,36 +120,129 @@ def stages(self) -> set[Phase]:
def __init__(self, config: Config):
self.config = config
self.fail: dict[str, tuple[str, dict]] = {}
self._locks: dict[str, asyncio.Lock] = {}
super().__init__()

def _lock_for(self, plugin_id: str) -> asyncio.Lock:
if plugin_id not in self._locks:
self._locks[plugin_id] = asyncio.Lock()
return self._locks[plugin_id]

async def _reload(self, pid: str, cfg: dict) -> bool:
async with self._lock_for(pid):
if pid in plugin_service._subplugined:
return await reload_subplugin(pid, cfg)
return await reload_plugin(pid, cfg)

async def _reload_upstream(self, module_name: str) -> list[str]:
"""刷新非插件上游模块,并重载依赖它的插件

必须先刷新 sys.modules 中的内容, 否则依赖插件重载时 import 链命中 sys.modules 缓存,拿到的仍是旧模块。

Returns:
实际重载的插件 id 列表(供同批次去重)。
"""
if module_name in plugin_service.plugins or module_name in plugin_service._subplugined:
return []
mod = sys.modules.get(module_name)
if mod is None or not isinstance(mod, ModuleType):
return []
dependents = plugin_service.dependents_of(module_name, ensure=True)
if not dependents:
return []
plugins = ", ".join(sorted(dependents))
logger.debug(f"Reloading upstream module <y>{module_name!r}</y>, affected plugins: <red>{plugins}</red>")
try:
importlib.reload(mod)
except Exception as e:
logger.error(f"Failed to reload upstream module <blue>{module_name!r}</blue>: {e!r}")
return []
reloaded: list[str] = []
for dep_id in plugin_service.topo_dependents(set(dependents)):
async with self._lock_for(dep_id):
if await reload_plugin(dep_id):
reloaded.append(dep_id)
else:
logger.error(f"Failed to reload plugin <blue>{dep_id!r}</blue> after upstream module reload")
return reloaded

async def watch(self):
async for event in awatch(
*self.config.watch_dirs, debounce=self.config.debounce, step=self.config.step, watch_filter=PythonFilter()
):
pending: dict[str, tuple[str, Plugin]] = {}
failed: list[str] = []
upstream: set[str] = set()
for change in event:
if plugin := find_plugin_by_file(change[1]):
if plugin.is_static:
logger.info(f"Plugin <y>{plugin.id!r}</y> is static, ignored.")
continue
logger.info(f"Detected change in <blue>{plugin.id!r}</blue>, reloading...")
pid = plugin.id
_conf = plugin.config.copy()
del plugin
await unload_plugin_async(pid)
if plugin := load_plugin(pid, _conf):
logger.info(f"Reloaded <blue>{plugin.id!r}</blue>")
del plugin
else:
logger.error(f"Failed to reload <blue>{pid!r}</blue>")
self.fail[change[1]] = (pid, _conf)
pending.setdefault(plugin.id, (change[1], plugin))
elif change[1] in self.fail:
logger.info(f"Detected change in {change[1]!r} which failed to reload, retrying...")
if plugin := load_plugin(*self.fail[change[1]]):
logger.info(f"Reloaded <blue>{plugin.id!r}</blue>")
del plugin
del self.fail[change[1]]
failed.append(change[1])
elif module_name := module_name_from_path(Path(change[1])):
upstream.add(module_name)
reloaded: set[str] = set()
for module_name in upstream:
reloaded.update(await self._reload_upstream(module_name))
for pid, (file_path, plugin) in pending.items():
if pid in reloaded:
self.fail.pop(file_path, None)
continue
nodes: ast.Module | None = None
if (
plugin._inspect
and plugin.module.__file__
and (path := Path(file_path).resolve()) == Path(plugin.module.__file__).resolve()
):
try:
nodes = ast.parse(path.read_bytes(), filename=path, type_comments=True)
except (OSError, SyntaxError) as e:
trace = escape_tag("".join(format_exception_only(e)))
logger.error(f"Change in <blue>{pid!r}</blue> occurred exception, skipped:\n{trace}")
continue
else:
logger.error(f"Failed to reload <blue>{self.fail[change[1]][0]!r}</blue>")
if ast.dump(nodes, include_attributes=False) == plugin._inspect.dump:
logger.debug(f"Change in <y>{pid!r}</y> has no semantic difference, skipped.")
self.fail.pop(file_path, None)
continue
logger.info(f"Detected change in <blue>{pid!r}</blue>, reloading...")
if plugin._inspect and nodes:
changes = classify(plugin._inspect.nodes, nodes)
if changes is not None and swap_functions(plugin, nodes, changes):
if changes:
logger.info(
f"Hot swapped functions in <blue>{pid!r}</blue>: "
f"{', '.join(f'<m>{change.qualname}</m>' for change in changes)} "
f"successfully."
)
else:
logger.debug(f"Change in <y>{pid!r}</y> has no function-level diff, skipped.")
self.fail.pop(file_path, None)
continue
logger.debug(f"Hot swap functions in <y>{pid!r}</y> failed, falling back to full reload.")
_conf = plugin.config.copy()
del plugin
if await self._reload(pid, _conf):
logger.info(f"Reloaded <blue>{pid!r}</blue>")
self.fail.pop(file_path, None)
else:
logger.error(f"Failed to reload <blue>{pid!r}</blue>")
self.fail[file_path] = (pid, _conf)
pending.clear()
for file_path in failed:
if file_path not in self.fail:
continue
pid, _conf = self.fail[file_path]
if file_path not in self.fail:
continue
logger.info(f"Detected change in {file_path!r} which failed to reload, retrying...")
if await self._reload(pid, _conf):
logger.info(f"Reloaded <blue>{pid!r}</blue>")
del self.fail[file_path]
else:
logger.error(f"Failed to reload <blue>{pid!r}</blue>")

async def watch_config(self):
file = EntariConfig.instance.path.resolve()
Expand Down Expand Up @@ -196,10 +328,9 @@ async def watch_config(self):
_conf = plg.config.copy()

async def _():
await unload_plugin_async(pid)
if plg := load_plugin(plugin_name, new_conf):
logger.info(f"Reloaded <blue>{plg.id!r}</blue>")
del plg
if await self._reload(pid, new_conf):
logger.info(f"Reloaded <blue>{pid!r}</blue>")
self.fail.pop(plugin_file, None)
else:
logger.error(f"Failed to reload <blue>{plugin_name!r}</blue>")
self.fail[plugin_file] = (pid, _conf)
Expand Down
20 changes: 18 additions & 2 deletions arclet/entari/command/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,15 @@ def wrapper(func: Callable[..., TM]) -> Subscriber[TM]:
self.subscribers[target.id] = target

def _remove(_):
command_manager.delete(get_cmd(_))
_cmd = get_cmd(_)
# if returned, it means the subscriber is already staged reload.
try:
record = command_manager._resolve(_cmd._hash)
except KeyError:
pass
else:
if id(_cmd) == id(record):
command_manager.delete(_cmd)
self.trie[key].remove(target.id) # type: ignore
if not self.trie[key]:
self.trie.pop(key, None) # type: ignore
Expand Down Expand Up @@ -203,7 +211,15 @@ def _remove(_):
self.trie.setdefault(_key, []).append(target.id)

def _remove(_):
command_manager.delete(get_cmd(_))
_cmd = get_cmd(_)
# if returned, it means the subscriber is already staged reload.
try:
record = command_manager._resolve(_cmd._hash)
except KeyError:
pass
else:
if id(_cmd) == id(record):
command_manager.delete(_cmd)
self.subscribers.pop(target.id, None)
for _key in keys:
self.trie[_key].remove(target.id) # type: ignore
Expand Down
15 changes: 12 additions & 3 deletions arclet/entari/command/plugin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
from typing import Any
from typing_extensions import TypeVar, deprecated

Expand Down Expand Up @@ -28,7 +29,7 @@


async def _after_execute(ctx: Contexts, session: Session | None = None):
result = ctx[RESULT]
result: str | MessageChain | _ExitException | None = ctx[RESULT]
event = ctx[EVENT]
if result is not None:
if isinstance(result, _ExitException):
Expand All @@ -55,7 +56,7 @@ def assign(self, path: str, value: Any = _seminal, or_not: bool = False, priorit
class AlconnaPluginDispatcher(PluginDispatcher[T]):
def __init__(self, plugin: Plugin, command: Alconna, need_reply_me: bool = False, need_notice_me: bool = False, use_config_prefix: bool = True, block: bool = True, skip_for_unmatch: bool = True): # noqa: E501
plugin._extra.setdefault("commands", []).append((command.prefixes, command.command))
self.cache = LRU(10)
self.cache: "LRU[str, asyncio.Future]" = LRU(10) # noqa: UP037
self.supplier = AlconnaSuppiler(command, self.cache, block, skip_for_unmatch)
super().__init__(plugin, MessageCreatedEvent, command.path)
plugin.collect(
Expand All @@ -68,7 +69,15 @@ def __init__(self, plugin: Plugin, command: Alconna, need_reply_me: bool = False

@plugin.collect
def dispose():
command_manager.delete(self.supplier.cmd)
_cmd = self.supplier.cmd
# if returned, it means the subscriber is already staged reload.
try:
record = command_manager._resolve(_cmd._hash)
except KeyError:
pass
else:
if id(_cmd) == id(record):
command_manager.delete(_cmd)
del self.supplier.cmd
del self.supplier

Expand Down
2 changes: 1 addition & 1 deletion arclet/entari/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@
ITEM_SESSION,
ITEM_USER,
)
from .event.api import SendResponse
from .event.base import MessageCreatedEvent, event_parse
from .event.config import ConfigReload
from .event.lifespan import AccountUpdate
from .event.send import SendResponse
from .localdata import local_data
from .logger import apply_log_save, enable_rich_except, log
from .message import MessageChain
Expand Down
Loading
Loading