Skip to content

Commit 097bd8f

Browse files
committed
apr v2.0.0
logic rewritten for mcdr 2.13
1 parent e95ed4f commit 097bd8f

File tree

7 files changed

+187
-72
lines changed

7 files changed

+187
-72
lines changed

auto_plugin_reloader/common.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
from typing import TYPE_CHECKING
22

33
from mcdreforged.api.rtext import RTextMCDRTranslation
4-
from mcdreforged.api.types import ServerInterface
4+
from mcdreforged.api.types import ServerInterface, PluginServerInterface
55

66
if TYPE_CHECKING:
7-
from auto_plugin_reloader.config import Configure
7+
from auto_plugin_reloader.config import Configuration
88
from auto_plugin_reloader.reloader import PluginReloader
99

1010

11-
server_inst = ServerInterface.get_instance().as_plugin_server_interface()
11+
server_inst: PluginServerInterface = ServerInterface.psi()
1212
metadata = server_inst.get_self_metadata()
13-
config: 'Configure'
13+
config: 'Configuration'
1414
reloader: 'PluginReloader'
1515

1616

@@ -19,8 +19,8 @@ def tr(key: str, *args, **kwargs) -> RTextMCDRTranslation:
1919

2020

2121
def load_common():
22-
from auto_plugin_reloader.config import Configure
22+
from auto_plugin_reloader.config import Configuration
2323
from auto_plugin_reloader.reloader import PluginReloader
2424
global config, reloader
25-
config = Configure.load()
25+
config = Configuration.load()
2626
reloader = PluginReloader()

auto_plugin_reloader/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from auto_plugin_reloader import common
77

88

9-
class Configure(Serializable):
9+
class Configuration(Serializable):
1010
enabled: bool = True
1111
permission: int = PermissionLevel.PHYSICAL_SERVER_CONTROL_LEVEL
1212
detection_interval_sec: float = 10
@@ -18,7 +18,7 @@ def get_psi() -> PluginServerInterface:
1818
return common.server_inst
1919

2020
@classmethod
21-
def load(cls) -> 'Configure':
21+
def load(cls) -> 'Configuration':
2222
return cls.get_psi().load_config_simple(target_class=cls)
2323

2424
def save(self):

auto_plugin_reloader/reloader.py

Lines changed: 173 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,55 @@
1+
import contextlib
2+
import dataclasses
3+
import enum
14
import os
5+
import threading
26
import time
7+
from pathlib import Path
38
from threading import Thread, Lock
4-
from typing import List, NamedTuple, Dict, Iterable, Optional
9+
from typing import List, Dict, Iterable, Optional, Set
510

611
from mcdreforged.api.rtext import *
12+
from mcdreforged.api.types import PluginType
713

814
from auto_plugin_reloader import common
915
from auto_plugin_reloader.common import tr, metadata, server_inst
1016

11-
ModifyTimeMapping = Dict[str, int]
1217
PLUGIN_FILE_SUFFIXES = ['.py', '.mcdr', '.pyz']
1318

1419

15-
class Difference(NamedTuple):
16-
file_path: str
17-
reason: RTextBase
20+
@dataclasses.dataclass(frozen=True)
21+
class PluginFileInfo:
22+
plugin_id: Optional[str]
23+
path: Path
24+
mtime: Optional[int]
25+
26+
27+
@dataclasses.dataclass(frozen=True)
28+
class ScanResult:
29+
files: Dict[Path, PluginFileInfo] = dataclasses.field(default_factory=dict)
30+
plugin_files: Dict[str, PluginFileInfo] = dataclasses.field(default_factory=dict)
31+
32+
33+
class DiffReason(enum.Enum):
34+
file_added = enum.auto()
35+
file_modified = enum.auto()
36+
file_deleted = enum.auto()
37+
38+
39+
@dataclasses.dataclass(frozen=True)
40+
class Difference:
41+
file_path: Path
42+
reason: DiffReason
43+
plugin_id: Optional[str]
1844

1945

2046
class PluginReloader:
2147
def __init__(self):
22-
self.__stop_flag = False
48+
self.__stop_flag = threading.Event()
2349
self.last_detection_time = 0
2450
self.logger = server_inst.logger
25-
self.scan_result: ModifyTimeMapping = self.scan_files()
26-
self.__thread = None # type: Optional[Thread]
51+
self.scan_result: ScanResult = self.__scan_files()
52+
self.__thread: Optional[Thread] = None
2753
self.__start_stop_lock = Lock()
2854

2955
def on_config_changed(self):
@@ -46,8 +72,8 @@ def is_running(self):
4672
def start(self):
4773
with self.__start_stop_lock:
4874
if not self.is_running():
49-
self.__stop_flag = False
50-
self.reset_detection_time()
75+
self.__stop_flag.clear()
76+
self.__reset_detection_time()
5177
self.__thread = Thread(name='APR@{}'.format(self.unique_hex), target=self.thread_loop)
5278
self.__thread.start()
5379

@@ -58,14 +84,13 @@ def join_thread(self):
5884
thread.join()
5985

6086
def stop(self):
61-
with self.__start_stop_lock:
62-
self.__stop_flag = True
87+
self.__stop_flag.set()
6388

6489
# ------------------
6590
# Implementation
6691
# ------------------
6792

68-
def reset_detection_time(self):
93+
def __reset_detection_time(self):
6994
self.last_detection_time = time.time()
7095

7196
def get_pretty_next_detection_time(self) -> RTextBase:
@@ -75,59 +100,149 @@ def get_pretty_next_detection_time(self) -> RTextBase:
75100

76101
def thread_loop(self):
77102
self.logger.info('{} started'.format(self.unique_name))
78-
while not self.__stop_flag:
79-
while not self.__stop_flag and time.time() - self.last_detection_time < common.config.detection_interval_sec:
80-
time.sleep(0.005)
81-
if self.__stop_flag:
103+
104+
while True:
105+
next_detection_time = self.last_detection_time + common.config.detection_interval_sec
106+
time_to_wait = max(0.0, next_detection_time - time.time())
107+
if self.__stop_flag.wait(time_to_wait):
82108
break
109+
83110
try:
84-
self.check()
85-
except:
111+
self.__check_and_reload_once()
112+
except Exception:
86113
self.logger.exception('Error ticking {}'.format(metadata.name))
87114
self.stop()
88115
finally:
89-
self.reset_detection_time()
116+
self.__reset_detection_time()
117+
90118
self.logger.info('{} stopped'.format(self.unique_name))
91119

92-
@staticmethod
93-
def scan_files() -> ModifyTimeMapping:
120+
def __scan_files(self) -> ScanResult:
121+
self.logger.debug('Scan file start')
122+
start_time = time.time()
123+
94124
def has_suffix(text: str, suffixes: Iterable[str]) -> bool:
95125
return any(map(lambda sfx: text.endswith(sfx), suffixes))
96126

127+
def try_get_mtime(p: Path) -> Optional[int]:
128+
try:
129+
return p.stat().st_mtime_ns
130+
except OSError:
131+
return None
132+
133+
result = ScanResult()
134+
plugin_paths: Set[Path] = set()
135+
for pid in server_inst.get_plugin_list():
136+
path = server_inst.get_plugin_file_path(pid)
137+
pfmt = server_inst.get_plugin_type(pid)
138+
if path is not None and pfmt in [PluginType.solo, PluginType.packed]:
139+
path = Path(path).absolute()
140+
pfi = PluginFileInfo(plugin_id=pid, path=path, mtime=try_get_mtime(path))
141+
result.files[path] = pfi
142+
result.plugin_files[pid] = pfi
143+
plugin_paths.add(path)
144+
97145
plugin_directories: List[str] = server_inst.get_mcdr_config()['plugin_directories']
98-
ret = {}
99-
for plugin_directory in plugin_directories:
100-
if os.path.isdir(plugin_directory):
101-
for file_name in os.listdir(plugin_directory):
102-
file_path = os.path.join(plugin_directory, file_name)
103-
if os.path.isfile(file_path) and has_suffix(file_path, PLUGIN_FILE_SUFFIXES) and file_name not in common.config.blacklist:
104-
ret[file_path] = os.stat(file_path).st_mtime_ns
105-
return ret
106-
107-
def check(self):
108-
new_scan_result = self.scan_files()
109-
diffs: List[Difference] = []
110-
for file_path, current_mtime in new_scan_result.items():
111-
prev_mtime = self.scan_result.get(file_path, None)
112-
if prev_mtime is None:
113-
diffs.append(Difference(file_path, tr('file_added')))
114-
elif prev_mtime != current_mtime:
115-
diffs.append(Difference(file_path, tr('file_modified')))
116-
for file_path in self.scan_result.keys():
117-
if file_path not in new_scan_result:
118-
diffs.append(Difference(file_path, tr('file_deleted')))
119-
120-
if len(diffs) > 0:
121-
time.sleep(common.config.reload_delay_sec)
122-
if self.__stop_flag: # just in case
123-
return
124-
self.logger.info(tr('triggered.header'))
125-
for diff in diffs:
126-
self.logger.info('- {}: {}'.format(diff.file_path, diff.reason))
127-
self.logger.info(tr('triggered.footer'))
128-
self.scan_result = self.scan_files()
129-
130-
holder = Thread(name='OperationHolder', daemon=True, target=lambda: server_inst.schedule_task(server_inst.refresh_changed_plugins, block=True))
131-
holder.start()
132-
while holder.is_alive() and not self.__stop_flag: # stop waiting after being stopped
133-
holder.join(timeout=0.01)
146+
for plugin_directory in map(Path, plugin_directories):
147+
try:
148+
files = os.listdir(plugin_directory)
149+
except OSError as e:
150+
if not isinstance(e, FileNotFoundError):
151+
self.logger.warning('Skipping invalid plugin directory {!r}: {}'.format(plugin_directory, e))
152+
continue
153+
154+
for file_name in files:
155+
path = (plugin_directory / file_name).absolute()
156+
if path in plugin_paths:
157+
continue
158+
try:
159+
if path.is_file() and has_suffix(file_name, PLUGIN_FILE_SUFFIXES) and file_name not in common.config.blacklist:
160+
mtime = path.stat().st_mtime_ns
161+
result.files[path] = PluginFileInfo(plugin_id=None, path=path, mtime=mtime)
162+
except OSError as e:
163+
self.logger.warning('Check file {!r} failed: {}'.format(plugin_directory, e))
164+
165+
self.logger.debug('Scan file end, cost {:.2f}s'.format(time.time() - start_time))
166+
return result
167+
168+
@dataclasses.dataclass(frozen=True)
169+
class _CheckOnceResult:
170+
scan_result: ScanResult
171+
diffs: List[Difference] = dataclasses.field(default_factory=list)
172+
to_load: List[Path] = dataclasses.field(default_factory=list) # paths
173+
to_reload: List[str] = dataclasses.field(default_factory=list) # plugin id
174+
to_unload: List[str] = dataclasses.field(default_factory=list) # plugin id
175+
176+
def __scan_and_check(self) -> _CheckOnceResult:
177+
new_scan_result = self.__scan_files()
178+
cor = self._CheckOnceResult(scan_result=new_scan_result)
179+
180+
for pid, pfi in self.scan_result.plugin_files.items():
181+
if (new_pfi := new_scan_result.plugin_files.get(pid)) is None:
182+
continue
183+
# the plugin exists in both 2 scans
184+
# reload if mtime changes, and mcdr also says that it has changes
185+
# if pid == 'advanced_calculator':
186+
# self.logger.info('OLD {}'.format(pfi))
187+
# self.logger.info('NEW {}'.format(new_pfi))
188+
if pfi.mtime != new_pfi.mtime and server_inst.is_plugin_file_changed(pid) is True:
189+
if new_pfi.mtime is None:
190+
cor.diffs.append(Difference(pfi.path, DiffReason.file_deleted, pid))
191+
cor.to_unload.append(pid)
192+
else:
193+
cor.diffs.append(Difference(pfi.path, DiffReason.file_modified, pid))
194+
cor.to_reload.append(pid)
195+
196+
for path, new_pfi in new_scan_result.files.items():
197+
# newly found, not loaded, plugin file
198+
if new_pfi.plugin_id is None and ((old_pfi := self.scan_result.files.get(path)) is None or new_pfi.mtime != old_pfi.mtime):
199+
cor.diffs.append(Difference(path, DiffReason.file_added, None))
200+
cor.to_load.append(path)
201+
202+
if (cd_len := len(cor.diffs)) > 0:
203+
self.logger.info('Found {} plugin file changes'.format(cd_len))
204+
return cor
205+
206+
def __check_and_reload_once(self):
207+
# first check
208+
check_result = self.__scan_and_check()
209+
if len(check_result.diffs) == 0:
210+
self.scan_result = check_result.scan_result
211+
return
212+
213+
# wait for a while before the double check
214+
if self.__stop_flag.wait(common.config.reload_delay_sec):
215+
return
216+
217+
# second check
218+
check_result = self.__scan_and_check()
219+
self.scan_result = check_result.scan_result
220+
if len(check_result.diffs) == 0:
221+
self.logger.info('no diff in second check')
222+
return
223+
224+
self.logger.info(tr('triggered.header'))
225+
for diff in check_result.diffs:
226+
path = diff.file_path
227+
with contextlib.suppress(ValueError):
228+
path = path.relative_to(Path('.').absolute())
229+
msg = '- {}: {}'.format(path, tr(diff.reason.name))
230+
if diff.plugin_id:
231+
msg += ' (id={})'.format(diff.plugin_id)
232+
self.logger.info(msg)
233+
self.logger.info(tr('triggered.footer'))
234+
235+
def do_auto_reload():
236+
try:
237+
server_inst.manipulate_plugins(
238+
load=[str(p) for p in check_result.to_load],
239+
reload=check_result.to_reload,
240+
unload=check_result.to_unload,
241+
)
242+
except Exception:
243+
self.logger.exception('Auto plugin reload failed')
244+
245+
holder = Thread(name='OperationHolder', daemon=True, target=lambda: server_inst.schedule_task(do_auto_reload, block=True))
246+
holder.start()
247+
while holder.is_alive() and not self.__stop_flag.is_set(): # stop waiting after being stopped
248+
holder.join(timeout=0.01)

mcdreforged.plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "auto_plugin_reloader",
3-
"version": "1.1.3",
3+
"version": "2.0.0",
44
"name": "Auto Plugin Reloader",
55
"description": {
66
"en_us": "Automatically reload plugins when file changes",
@@ -11,7 +11,7 @@
1111
],
1212
"link": "https://github.com/TISUnion/AutoPluginReloader",
1313
"dependencies": {
14-
"mcdreforged": ">=2.1.0-beta"
14+
"mcdreforged": ">=2.13.0"
1515
},
1616
"entrypoint": "auto_plugin_reloader.entry",
1717
"resources": [

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
Auto Plugin Reloader
44
-----
55

6-
A [MCDReforged](https://github.com/Fallen-Breath/MCDReforged) (>=2.x) plugin
6+
A [MCDReforged](https://github.com/Fallen-Breath/MCDReforged) (>=2.13.0) plugin
77

88
Automatically trigger plugin reloading when any plugin file (files with extension `.py` and `.mcdr`) in MCDR plugin directories changes
99

1010
## Config
1111

12-
Configure file: `config/auto_plugin_reloader/config.json`
12+
Configuration file: `config/auto_plugin_reloader/config.json`
1313

1414
`enabled`, if the reloader is enabled, default: `true`
1515

readme_cn.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Auto Plugin Reloader
44
-----
55

6-
一个 [MCDReforged](https://github.com/Fallen-Breath/MCDReforged) (>=2.x) 插件
6+
一个 [MCDReforged](https://github.com/Fallen-Breath/MCDReforged) (>=2.13.0) 插件
77

88
在 MCDR 插件文件夹中任何插件文件变更时(后缀为 `.py``.mcdr` 的文件),触发一次插件重载
99

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# Add your python package requirements here, just like regular requirements.txt
1+
mcdreforged

0 commit comments

Comments
 (0)