mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-30 06:09:59 +08:00
fixed # 统一头像获取接口
This commit is contained in:
@@ -33,6 +33,11 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印
|
|||||||
真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。
|
真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。
|
||||||
本地模拟和ZIP校验不等于真实平台发图成功,需要部署者实际验证。
|
本地模拟和ZIP校验不等于真实平台发图成功,需要部署者实际验证。
|
||||||
|
|
||||||
|
QQ/OneBot(aiocqhttp)图片查询会读取发送者的腾讯头像,用于我的农场、
|
||||||
|
农场详述和地块编号。头像请求最多等待2秒、限制2MiB,成功缓存5分钟,
|
||||||
|
失败缓存30秒,最多缓存16项;失败时仍使用默认头像。不记录头像URL或
|
||||||
|
QQ号。其他平台暂用默认头像,不将其用户ID当作QQ号查询。
|
||||||
|
|
||||||
官方接口参考:
|
官方接口参考:
|
||||||
[插件入口](https://docs.astrbot.app/en/dev/star/guides/simple.html)、
|
[插件入口](https://docs.astrbot.app/en/dev/star/guides/simple.html)、
|
||||||
[插件配置](https://docs.astrbot.app/en/dev/star/guides/plugin-config.html)。
|
[插件配置](https://docs.astrbot.app/en/dev/star/guides/plugin-config.html)。
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""QQ/OneBot头像读取;固定来源、有界缓存和可关闭的异步请求。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import OrderedDict
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
MAX_ENTRIES = 16
|
||||||
|
FETCH_TIMEOUT = 2
|
||||||
|
|
||||||
|
|
||||||
|
async def download_avatar(user_id):
|
||||||
|
"""只访问固定腾讯HTTPS端点,不跟随重定向或读取用户提供的URL。"""
|
||||||
|
if not re.fullmatch(r'[0-9]{1,20}', user_id):
|
||||||
|
return None
|
||||||
|
url = f'https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100'
|
||||||
|
timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url, allow_redirects=False) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
if (response.content_length is not None
|
||||||
|
and response.content_length > MAX_BYTES):
|
||||||
|
return None
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in response.content.iter_chunked(65536):
|
||||||
|
if len(content) + len(chunk) > MAX_BYTES:
|
||||||
|
return None
|
||||||
|
content.extend(chunk)
|
||||||
|
return bytes(content) or None
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarCache:
|
||||||
|
"""单事件循环使用;缓存最多16项,同一QQ的并发请求共享下载。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache = OrderedDict()
|
||||||
|
self._pending = {}
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def get(self, platform, user_id):
|
||||||
|
user_id = str(user_id)
|
||||||
|
if (self._closed or platform != 'aiocqhttp'
|
||||||
|
or not re.fullmatch(r'[0-9]{1,20}', user_id)):
|
||||||
|
return None
|
||||||
|
cached = self._cache.get(user_id)
|
||||||
|
if cached and cached[0] > monotonic():
|
||||||
|
self._cache.move_to_end(user_id)
|
||||||
|
return cached[1]
|
||||||
|
task = self._pending.get(user_id)
|
||||||
|
if task is None:
|
||||||
|
if len(self._pending) >= MAX_ENTRIES:
|
||||||
|
return None
|
||||||
|
task = asyncio.create_task(self._fetch(user_id))
|
||||||
|
self._pending[user_id] = task
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
|
||||||
|
async def _fetch(self, user_id):
|
||||||
|
try:
|
||||||
|
content = await asyncio.wait_for(
|
||||||
|
download_avatar(user_id), timeout=FETCH_TIMEOUT,
|
||||||
|
)
|
||||||
|
if (not isinstance(content, bytes) or not content
|
||||||
|
or len(content) > MAX_BYTES):
|
||||||
|
content = None
|
||||||
|
except Exception:
|
||||||
|
# 不记录QQ号、完整URL和异常中的潜在身份信息。
|
||||||
|
logger.warning('farm astrbot avatar unavailable; using default')
|
||||||
|
content = None
|
||||||
|
finally:
|
||||||
|
self._pending.pop(user_id, None)
|
||||||
|
self._cache[user_id] = (
|
||||||
|
monotonic() + (300 if content else 30), content,
|
||||||
|
)
|
||||||
|
self._cache.move_to_end(user_id)
|
||||||
|
while len(self._cache) > MAX_ENTRIES:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self._closed = True
|
||||||
|
tasks = list(self._pending.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
self._cache.clear()
|
||||||
@@ -10,6 +10,7 @@ from astrbot.api.event import AstrMessageEvent, filter
|
|||||||
from astrbot.api.message_components import At, Image
|
from astrbot.api.message_components import At, Image
|
||||||
from astrbot.api.star import Context, Star, StarTools
|
from astrbot.api.star import Context, Star, StarTools
|
||||||
|
|
||||||
|
from .avatars import AvatarCache
|
||||||
from .runtime import COMMANDS, Runtime
|
from .runtime import COMMANDS, Runtime
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class FarmPlugin(Star):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self._lifecycle_lock = asyncio.Lock()
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
self._avatars = AvatarCache()
|
||||||
configured = config.get('data_dir', '')
|
configured = config.get('data_dir', '')
|
||||||
data_dir = (Path(configured).expanduser() if configured else
|
data_dir = (Path(configured).expanduser() if configured else
|
||||||
StarTools.get_data_dir('astrbot_plugin_farm'))
|
StarTools.get_data_dir('astrbot_plugin_farm'))
|
||||||
@@ -66,12 +68,17 @@ class FarmPlugin(Star):
|
|||||||
yield event.plain_result('无法取得稳定的平台或用户标识,未执行。')
|
yield event.plain_result('无法取得稳定的平台或用户标识,未执行。')
|
||||||
return
|
return
|
||||||
bot_id = json.dumps([str(platform_id), str(event.get_self_id())])
|
bot_id = json.dumps([str(platform_id), str(event.get_self_id())])
|
||||||
|
avatar = None
|
||||||
|
if action in ('farm', 'details', 'plot_ids') and self.runtime.rendering:
|
||||||
|
avatar = await self._avatars.get(
|
||||||
|
event.get_platform_name(), event.get_sender_id(),
|
||||||
|
)
|
||||||
response = await self.runtime.execute(
|
response = await self.runtime.execute(
|
||||||
action, arguments, bot_id=bot_id,
|
action, arguments, bot_id=bot_id,
|
||||||
platform=event.get_platform_name(), user_id=event.get_sender_id(),
|
platform=event.get_platform_name(), user_id=event.get_sender_id(),
|
||||||
message_id=getattr(event.message_obj, 'message_id', None),
|
message_id=getattr(event.message_obj, 'message_id', None),
|
||||||
display_name=event.get_sender_name() or '农场玩家',
|
display_name=event.get_sender_name() or '农场玩家',
|
||||||
target_user_id=target,
|
target_user_id=target, avatar=avatar,
|
||||||
)
|
)
|
||||||
if isinstance(response, bytes):
|
if isinstance(response, bytes):
|
||||||
yield event.chain_result([Image.fromBytes(response)])
|
yield event.chain_result([Image.fromBytes(response)])
|
||||||
@@ -83,5 +90,6 @@ class FarmPlugin(Star):
|
|||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
await self._avatars.close()
|
||||||
await self.runtime.close()
|
await self.runtime.close()
|
||||||
logger.info('farm astrbot plugin terminated')
|
logger.info('farm astrbot plugin terminated')
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
Pillow>=10.4
|
Pillow>=10.4
|
||||||
|
aiohttp>=3.9,<4
|
||||||
|
|||||||
@@ -27,11 +27,16 @@ def build(root, output):
|
|||||||
'_version.py', 'version.txt', 'LICENSE',
|
'_version.py', 'version.txt', 'LICENSE',
|
||||||
)}
|
)}
|
||||||
source = root / 'integrations/astrbot'
|
source = root / 'integrations/astrbot'
|
||||||
for name in ('main.py', '_conf_schema.json', 'requirements.txt'):
|
for name in ('main.py', 'avatars.py', '_conf_schema.json',
|
||||||
|
'requirements.txt'):
|
||||||
files[name] = (source / name).read_bytes()
|
files[name] = (source / name).read_bytes()
|
||||||
files['README.md'] = (source / 'README.md').read_bytes()
|
files['README.md'] = (source / 'README.md').read_bytes()
|
||||||
files['runtime.py'] = (root / 'integrations/zhenxun/runtime.py').read_bytes()
|
files['runtime.py'] = (
|
||||||
files['verify_package.py'] = (root / 'scripts/verify_astrbot.py').read_bytes()
|
root / 'integrations/zhenxun/runtime.py'
|
||||||
|
).read_bytes()
|
||||||
|
files['verify_package.py'] = (
|
||||||
|
root / 'scripts/verify_astrbot.py'
|
||||||
|
).read_bytes()
|
||||||
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
||||||
files['metadata.yaml'] = (json.dumps({
|
files['metadata.yaml'] = (json.dumps({
|
||||||
'name': ROOT_NAME, 'display_name': '农场',
|
'name': ROOT_NAME, 'display_name': '农场',
|
||||||
|
|||||||
@@ -29,13 +29,15 @@ def verify_files(files):
|
|||||||
or manifest.get('plugin') != ROOT_NAME):
|
or manifest.get('plugin') != ROOT_NAME):
|
||||||
raise ValueError('Invalid package identity')
|
raise ValueError('Invalid package identity')
|
||||||
expected = manifest.get('files')
|
expected = manifest.get('files')
|
||||||
if not isinstance(expected, dict) or set(files) != set(expected) | {MANIFEST}:
|
if (not isinstance(expected, dict)
|
||||||
|
or set(files) != set(expected) | {MANIFEST}):
|
||||||
raise ValueError('Package files differ from manifest')
|
raise ValueError('Package files differ from manifest')
|
||||||
for name, digest in expected.items():
|
for name, digest in expected.items():
|
||||||
safe_path(name)
|
safe_path(name)
|
||||||
if hashlib.sha256(files[name]).hexdigest() != digest:
|
if hashlib.sha256(files[name]).hexdigest() != digest:
|
||||||
raise ValueError('Package checksum mismatch')
|
raise ValueError('Package checksum mismatch')
|
||||||
required = {'main.py', 'metadata.yaml', '_conf_schema.json', 'runtime.py',
|
required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json',
|
||||||
|
'runtime.py',
|
||||||
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
|
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
|
||||||
'_vendor/farm_core/__init__.py',
|
'_vendor/farm_core/__init__.py',
|
||||||
'_vendor/farm_render/data/assets.zip'}
|
'_vendor/farm_render/data/assets.zip'}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
'farm_astrbot_avatars',
|
||||||
|
ROOT / 'integrations/astrbot/avatars.py',
|
||||||
|
)
|
||||||
|
self.module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(self.module)
|
||||||
|
self.cache = self.module.AvatarCache()
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.cache.close()
|
||||||
|
|
||||||
|
async def test_avatar_success_cache_and_concurrent_requests(self):
|
||||||
|
async def download(user_id):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return b'avatar'
|
||||||
|
|
||||||
|
with patch.object(self.module, 'download_avatar',
|
||||||
|
side_effect=download) as fetch:
|
||||||
|
results = await asyncio.gather(*(
|
||||||
|
self.cache.get('aiocqhttp', '123456') for _ in range(8)
|
||||||
|
))
|
||||||
|
self.assertEqual(results, [b'avatar'] * 8)
|
||||||
|
self.assertEqual(
|
||||||
|
await self.cache.get('aiocqhttp', '123456'), b'avatar',
|
||||||
|
)
|
||||||
|
fetch.assert_awaited_once_with('123456')
|
||||||
|
self.assertEqual(
|
||||||
|
await self.cache.get('aiocqhttp', '654321'), b'avatar',
|
||||||
|
)
|
||||||
|
self.assertEqual(fetch.await_count, 2)
|
||||||
|
|
||||||
|
async def test_failure_negative_cache_and_expiry(self):
|
||||||
|
with patch.object(self.module, 'download_avatar',
|
||||||
|
new=AsyncMock(side_effect=OSError('offline'))) as f:
|
||||||
|
with patch.object(self.module, 'monotonic', return_value=0):
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '12345'))
|
||||||
|
with patch.object(self.module, 'monotonic', return_value=20):
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '12345'))
|
||||||
|
self.assertEqual(f.await_count, 1)
|
||||||
|
with patch.object(self.module, 'monotonic', return_value=31):
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '12345'))
|
||||||
|
self.assertEqual(f.await_count, 2)
|
||||||
|
|
||||||
|
async def test_unknown_platform_invalid_id_and_closed_cache(self):
|
||||||
|
with patch.object(self.module, 'download_avatar') as fetch:
|
||||||
|
for platform, user in (
|
||||||
|
('telegram', '12345'), ('qq_official', '12345'),
|
||||||
|
('aiocqhttp', '../secret'), ('aiocqhttp', '12345'),
|
||||||
|
('aiocqhttp', ''), ('aiocqhttp', '1' * 21),
|
||||||
|
):
|
||||||
|
self.assertIsNone(await self.cache.get(platform, user))
|
||||||
|
await self.cache.close()
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '12345'))
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
|
async def test_timeout_size_and_cache_bound(self):
|
||||||
|
async def slow(user_id):
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
with patch.object(self.module, 'FETCH_TIMEOUT', 0.01):
|
||||||
|
with patch.object(self.module, 'download_avatar', side_effect=slow):
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '12345'))
|
||||||
|
with patch.object(self.module, 'download_avatar', new=AsyncMock(
|
||||||
|
return_value=b'x' * (self.module.MAX_BYTES + 1))):
|
||||||
|
self.assertIsNone(await self.cache.get('aiocqhttp', '54321'))
|
||||||
|
with patch.object(self.module, 'download_avatar',
|
||||||
|
new=AsyncMock(return_value=b'avatar')):
|
||||||
|
for index in range(30):
|
||||||
|
await self.cache.get('aiocqhttp', str(100000 + index))
|
||||||
|
self.assertLessEqual(len(self.cache._cache), 16)
|
||||||
|
|
||||||
|
async def test_close_and_independent_caller_cancellation(self):
|
||||||
|
started, finish = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
async def download(user_id):
|
||||||
|
started.set()
|
||||||
|
await finish.wait()
|
||||||
|
return b'avatar'
|
||||||
|
|
||||||
|
with patch.object(self.module, 'download_avatar', side_effect=download):
|
||||||
|
first = asyncio.create_task(self.cache.get('aiocqhttp', '12345'))
|
||||||
|
await started.wait()
|
||||||
|
second = asyncio.create_task(self.cache.get('aiocqhttp', '12345'))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
first.cancel()
|
||||||
|
with self.assertRaises(asyncio.CancelledError):
|
||||||
|
await first
|
||||||
|
finish.set()
|
||||||
|
self.assertEqual(await second, b'avatar')
|
||||||
|
started.clear()
|
||||||
|
finish.clear()
|
||||||
|
third = asyncio.create_task(self.cache.get('aiocqhttp', '54321'))
|
||||||
|
await started.wait()
|
||||||
|
await self.cache.close()
|
||||||
|
with self.assertRaises(asyncio.CancelledError):
|
||||||
|
await third
|
||||||
|
self.assertFalse(self.cache._pending)
|
||||||
|
self.assertFalse(self.cache._cache)
|
||||||
|
|
||||||
|
async def test_downloader_stream_limit_and_no_redirect(self):
|
||||||
|
class Body:
|
||||||
|
async def iter_chunked(self, size):
|
||||||
|
yield b'a' * (self_limit + 1)
|
||||||
|
|
||||||
|
self_limit = self.module.MAX_BYTES
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
status = 200
|
||||||
|
content_length = None
|
||||||
|
content = Body()
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Session(Response):
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
self_url.append((url, kwargs))
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
self_url = []
|
||||||
|
with patch.object(self.module.aiohttp, 'ClientSession',
|
||||||
|
return_value=Session()):
|
||||||
|
self.assertIsNone(await self.module.download_avatar('12345'))
|
||||||
|
self.assertEqual(self_url[0][0],
|
||||||
|
'https://q1.qlogo.cn/g?b=qq&nk=12345&s=100')
|
||||||
|
self.assertFalse(self_url[0][1]['allow_redirects'])
|
||||||
@@ -7,7 +7,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -115,7 +115,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
return 'aiocqhttp'
|
return 'aiocqhttp'
|
||||||
|
|
||||||
def get_sender_id(self):
|
def get_sender_id(self):
|
||||||
return 'player'
|
return '123456'
|
||||||
|
|
||||||
def get_sender_name(self):
|
def get_sender_name(self):
|
||||||
return '离线测试'
|
return '离线测试'
|
||||||
@@ -139,14 +139,37 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
again = await call('开通农场')
|
again = await call('开通农场')
|
||||||
self.assertIsInstance(again, str)
|
self.assertIsInstance(again, str)
|
||||||
self.assertIn('开通农场', await call('我的种子', 'bot-b'))
|
self.assertIn('开通农场', await call('我的种子', 'bot-b'))
|
||||||
result = await call('我的农场 高清')
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
avatar_buffer = BytesIO()
|
||||||
|
Image.new('RGB', (64, 64), '#ee2222').save(
|
||||||
|
avatar_buffer, format='PNG',
|
||||||
|
)
|
||||||
|
avatar_bytes = avatar_buffer.getvalue()
|
||||||
|
with patch.object(plugin._avatars, 'get', new=AsyncMock(
|
||||||
|
return_value=avatar_bytes)) as avatar_get:
|
||||||
|
with patch.object(plugin.runtime, 'execute',
|
||||||
|
wraps=plugin.runtime.execute) as execute:
|
||||||
|
result = await call('我的农场 高清')
|
||||||
|
self.assertEqual(execute.call_args.kwargs['avatar'],
|
||||||
|
avatar_bytes)
|
||||||
|
await call('农场详述')
|
||||||
|
await call('地块编号')
|
||||||
|
await call('我的种子 文字')
|
||||||
|
self.assertIsNone(execute.call_args.kwargs['avatar'])
|
||||||
|
self.assertEqual(avatar_get.await_count, 3)
|
||||||
|
avatar_get.assert_awaited_with('aiocqhttp', '123456')
|
||||||
with Image.open(BytesIO(result[0])) as image:
|
with Image.open(BytesIO(result[0])) as image:
|
||||||
self.assertEqual(image.width, 1536)
|
self.assertEqual(image.width, 1536)
|
||||||
|
red, green, blue = image.getpixel((70, 70))[:3]
|
||||||
|
self.assertGreater(red, 180)
|
||||||
|
self.assertLess(green, 90)
|
||||||
|
self.assertLess(blue, 90)
|
||||||
self.assertIn('仅我的农场', await call('我的种子 高清'))
|
self.assertIn('仅我的农场', await call('我的种子 高清'))
|
||||||
self.assertIn('暂无种子', await call('我的种子 文字'))
|
self.assertIn('暂无种子', await call('我的种子 文字'))
|
||||||
self.assertTrue((self.root / 'plugin-data/farm.sqlite3').is_file())
|
self.assertTrue(
|
||||||
|
(self.root / 'plugin-data/farm.sqlite3').is_file(),
|
||||||
|
)
|
||||||
await plugin.terminate()
|
await plugin.terminate()
|
||||||
self.assertFalse(plugin.runtime.started)
|
self.assertFalse(plugin.runtime.started)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user