mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-29 01:22:31 +08:00
Merge branch 'feat/2.6.0' of github.com:dataelement/bisheng into feat/2.6.0
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
@@ -5,43 +6,76 @@ from typing import Any
|
||||
from loguru import logger
|
||||
from minio import Minio
|
||||
|
||||
# Deliverables must be written to the RELATIVE ``output/`` (or intermediate
|
||||
# ``scratch/``) directory under the executor's working dir — that dir is the only
|
||||
# location harvested back into the linsight workspace. A leading-slash
|
||||
# ``/output/...`` lands at the container filesystem root, OUTSIDE the harvested
|
||||
# working dir (and, for the shared LocalExecutor, cannot be safely rescued from
|
||||
# there without leaking one task's files into another). Such a write therefore
|
||||
# vanishes silently from the result panel. This regex flags the pattern in
|
||||
# submitted code (string literal starting with ``/output`` or ``/scratch``) so the
|
||||
# executor can append a corrective notice and the model self-corrects next step.
|
||||
_ABSOLUTE_DELIVERABLE_RE = re.compile(r"""['"]/(?:output|scratch)(?:/|['"])""")
|
||||
|
||||
ABSOLUTE_PATH_NOTICE = (
|
||||
"\n\n[SYSTEM NOTICE] Your code wrote file(s) to an ABSOLUTE path "
|
||||
"(/output/... or /scratch/...). Files written outside the current working "
|
||||
"directory are DISCARDED and were NOT delivered to the user. Re-run and write "
|
||||
"to the RELATIVE path with no leading slash, e.g. `output/report.pdf` for "
|
||||
"deliverables or `scratch/temp.png` for intermediate files."
|
||||
)
|
||||
|
||||
|
||||
class BaseExecutor(ABC):
|
||||
def __init__(self, minio: dict, **kwargs):
|
||||
self.minio = minio
|
||||
# 将代码生成的文件同步到本地的路径
|
||||
self.local_sync_path = kwargs.get('local_sync_path', None)
|
||||
self.local_sync_path = kwargs.get("local_sync_path", None)
|
||||
|
||||
@abstractmethod
|
||||
def run(self, code: str) -> Any:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def absolute_path_advisory(code: str) -> str:
|
||||
"""Corrective notice to append when ``code`` writes to an absolute
|
||||
``/output``/``/scratch`` path (which escapes the harvested working dir and
|
||||
makes the deliverable silently vanish); empty string otherwise.
|
||||
|
||||
String-literal match only (leading-slash ``/output`` / ``/scratch``), which
|
||||
is specific enough that false positives are negligible, and the notice is
|
||||
non-blocking (appended to the tool result, never rejects the run).
|
||||
"""
|
||||
if code and _ABSOLUTE_DELIVERABLE_RE.search(code):
|
||||
return ABSOLUTE_PATH_NOTICE
|
||||
return ""
|
||||
|
||||
def upload_minio(
|
||||
self,
|
||||
object_name: str,
|
||||
file_path,
|
||||
self,
|
||||
object_name: str,
|
||||
file_path,
|
||||
) -> str:
|
||||
# 初始化minio
|
||||
if not self.minio:
|
||||
return ""
|
||||
|
||||
minio_client = Minio(
|
||||
endpoint=self.minio.get('endpoint'),
|
||||
access_key=self.minio.get('access_key'),
|
||||
secret_key=self.minio.get('secret_key'),
|
||||
secure=self.minio.get('schema') or self.minio.get('secure'),
|
||||
cert_check=self.minio.get('cert_check'),
|
||||
endpoint=self.minio.get("endpoint"),
|
||||
access_key=self.minio.get("access_key"),
|
||||
secret_key=self.minio.get("secret_key"),
|
||||
secure=self.minio.get("schema") or self.minio.get("secure"),
|
||||
cert_check=self.minio.get("cert_check"),
|
||||
)
|
||||
minio_share = Minio(
|
||||
endpoint=self.minio.get('sharepoint'),
|
||||
access_key=self.minio.get('access_key'),
|
||||
secret_key=self.minio.get('secret_key'),
|
||||
secure=self.minio.get('share_schema', False),
|
||||
cert_check=self.minio.get('share_cert_check', False),
|
||||
endpoint=self.minio.get("sharepoint"),
|
||||
access_key=self.minio.get("access_key"),
|
||||
secret_key=self.minio.get("secret_key"),
|
||||
secure=self.minio.get("share_schema", False),
|
||||
cert_check=self.minio.get("share_cert_check", False),
|
||||
)
|
||||
bucket = self.minio.get('tmp_bucket', 'tmp-dir')
|
||||
bucket = self.minio.get("tmp_bucket", "tmp-dir")
|
||||
logger.debug(
|
||||
'upload_file obj={} bucket={} file_path={}',
|
||||
"upload_file obj={} bucket={} file_path={}",
|
||||
object_name,
|
||||
bucket,
|
||||
file_path,
|
||||
|
||||
@@ -72,7 +72,13 @@ class E2bCodeExecutor(BaseExecutor):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A code interpreter that can execute python code. Input should be a valid python code. If you have any files outputted write them to `output/` relative to the execution path."
|
||||
return (
|
||||
"A code interpreter that can execute python code. Input should be valid python code. "
|
||||
"Write final deliverables to the RELATIVE directory `output/` (e.g. `output/report.pdf`) "
|
||||
"and intermediate files to `scratch/`. NEVER use an absolute path with a leading slash "
|
||||
"such as `/output/...` or `/scratch/...` — files written outside the current working "
|
||||
"directory are DISCARDED and will NOT be delivered to the user."
|
||||
)
|
||||
|
||||
def init_sandbox(self):
|
||||
if not self.sandbox:
|
||||
|
||||
@@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from hashlib import md5
|
||||
from os import DirEntry
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Any
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
from loguru import logger
|
||||
@@ -19,10 +19,10 @@ from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExec
|
||||
|
||||
CODE_BLOCK_PATTERN = r"```(\w*)\n(.*?)\n```"
|
||||
DEFAULT_TIMEOUT = 600
|
||||
WIN32 = sys.platform == 'win32'
|
||||
PATH_SEPARATOR = WIN32 and '\\' or '/'
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'extensions')
|
||||
TIMEOUT_MSG = 'Timeout'
|
||||
WIN32 = sys.platform == "win32"
|
||||
PATH_SEPARATOR = (WIN32 and "\\") or "/"
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "extensions")
|
||||
TIMEOUT_MSG = "Timeout"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
LOCAL_DESCRIPTION = """Evaluates python code in native environment. \
|
||||
@@ -30,11 +30,13 @@ You must send the whole script every time and print your outputs. \
|
||||
Script should be pure python code that can be evaluated. \
|
||||
It should be in python format NOT markdown. \
|
||||
The code should NOT be wrapped in backticks. \
|
||||
If you have any files outputted write them to "output/" relative to the execution \
|
||||
path. Output can only be read from the directory, stdout, and stdin. \
|
||||
Do not use things like plot.show() as it will \
|
||||
not work instead write them out `output/`\
|
||||
print() any output and results so you can capture the output.""" # noqa: T201
|
||||
FILE OUTPUT RULES (STRICT): write final deliverables to the RELATIVE directory \
|
||||
`output/` (e.g. `output/report.pdf`) and intermediate files to `scratch/`; these \
|
||||
are subfolders of the current working directory. NEVER use an absolute path with a \
|
||||
leading slash such as `/output/...` or `/scratch/...` — anything written outside the \
|
||||
current working directory is DISCARDED and will NOT be delivered to the user. \
|
||||
Do not use things like plot.show() as it will not work; save figures to `output/` \
|
||||
instead. print() any output and results so you can capture the output."""
|
||||
|
||||
|
||||
class LocalExecutor(BaseExecutor):
|
||||
@@ -66,25 +68,25 @@ class LocalExecutor(BaseExecutor):
|
||||
def insert_set_font_code(code: str) -> str:
|
||||
"""判断python代码中是否导入了matplotlib库,如果有则插入设置字体的代码"""
|
||||
|
||||
split_code = code.split('\n')
|
||||
split_code = code.split("\n")
|
||||
cache_file = matplotlib.get_cachedir()
|
||||
font_cache = glob.glob(f'{cache_file}/fontlist*')
|
||||
font_cache = glob.glob(f"{cache_file}/fontlist*")
|
||||
|
||||
for cache in font_cache:
|
||||
os.remove(cache)
|
||||
|
||||
# todo: 如果生成的代码中已经有了设置字体的代码,可能会导致该段代码失效
|
||||
if 'matplotlib' in code:
|
||||
pattern = re.compile(r'(import matplotlib|from matplotlib)')
|
||||
if "matplotlib" in code:
|
||||
pattern = re.compile(r"(import matplotlib|from matplotlib)")
|
||||
index = max(i for i, line in enumerate(split_code) if pattern.search(line))
|
||||
split_code.insert(index + 1, 'import matplotlib\nmatplotlib.rc("font", family="WenQuanYi Zen Hei")')
|
||||
|
||||
return '\n'.join(split_code)
|
||||
return "\n".join(split_code)
|
||||
|
||||
@staticmethod
|
||||
def extract_code(
|
||||
text: str, pattern: str = CODE_BLOCK_PATTERN, detect_single_line_code: bool = False
|
||||
) -> List[Tuple[str, str]]:
|
||||
text: str, pattern: str = CODE_BLOCK_PATTERN, detect_single_line_code: bool = False
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Extract code from a text.
|
||||
|
||||
Args:
|
||||
@@ -122,28 +124,30 @@ class LocalExecutor(BaseExecutor):
|
||||
|
||||
@staticmethod
|
||||
def _cmd(lang):
|
||||
if lang.startswith('python') or lang in ['bash', 'sh', 'powershell']:
|
||||
if lang.startswith("python") or lang in ["bash", "sh", "powershell"]:
|
||||
return lang
|
||||
if lang in ['shell']:
|
||||
return 'sh'
|
||||
if lang in ['ps1']:
|
||||
return 'powershell'
|
||||
raise NotImplementedError(f'{lang} not recognized in code execution')
|
||||
if lang in ["shell"]:
|
||||
return "sh"
|
||||
if lang in ["ps1"]:
|
||||
return "powershell"
|
||||
raise NotImplementedError(f"{lang} not recognized in code execution")
|
||||
|
||||
@classmethod
|
||||
def _execute_code(cls,
|
||||
code: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
filename: Optional[str] = None,
|
||||
work_dir: Optional[str] = None,
|
||||
lang: Optional[str] = 'python',
|
||||
file_path: Optional[str] = None):
|
||||
def _execute_code(
|
||||
cls,
|
||||
code: str | None = None,
|
||||
timeout: int | None = None,
|
||||
filename: str | None = None,
|
||||
work_dir: str | None = None,
|
||||
lang: str | None = "python",
|
||||
file_path: str | None = None,
|
||||
):
|
||||
cmd = [
|
||||
sys.executable if lang.startswith('python') else cls._cmd(lang),
|
||||
f'.\\{filename}' if WIN32 else filename,
|
||||
sys.executable if lang.startswith("python") else cls._cmd(lang),
|
||||
f".\\{filename}" if WIN32 else filename,
|
||||
]
|
||||
if WIN32:
|
||||
logger.warning('SIGALRM is not supported on Windows. No timeout will be enforced.')
|
||||
logger.warning("SIGALRM is not supported on Windows. No timeout will be enforced.")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=work_dir,
|
||||
@@ -167,25 +171,25 @@ class LocalExecutor(BaseExecutor):
|
||||
logs = result.stderr
|
||||
if file_path is not None:
|
||||
abs_path = str(Path(file_path).absolute())
|
||||
logs = logs.replace(str(abs_path), '').replace(filename, '')
|
||||
logs = logs.replace(str(abs_path), "").replace(filename, "")
|
||||
else:
|
||||
abs_path = str(Path(work_dir).absolute()) + PATH_SEPARATOR
|
||||
logs = logs.replace(str(abs_path), '')
|
||||
logs = logs.replace(str(abs_path), "")
|
||||
else:
|
||||
logs = result.stdout
|
||||
return result.returncode, logs, ""
|
||||
|
||||
@classmethod
|
||||
def execute_code(
|
||||
cls,
|
||||
code: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
filename: Optional[str] = None,
|
||||
work_dir: Optional[str] = None,
|
||||
lang: Optional[str] = 'python',
|
||||
) -> Tuple[int, str, str]:
|
||||
cls,
|
||||
code: str | None = None,
|
||||
timeout: int | None = None,
|
||||
filename: str | None = None,
|
||||
work_dir: str | None = None,
|
||||
lang: str | None = "python",
|
||||
) -> tuple[int, str, str]:
|
||||
if all((code is None, filename is None)):
|
||||
error_msg = f'Either {code=} or {filename=} must be provided.'
|
||||
error_msg = f"Either {code=} or {filename=} must be provided."
|
||||
logger.error(error_msg)
|
||||
raise AssertionError(error_msg)
|
||||
|
||||
@@ -201,13 +205,14 @@ class LocalExecutor(BaseExecutor):
|
||||
filepath = os.path.join(work_dir, filename)
|
||||
file_dir = os.path.dirname(filepath)
|
||||
os.makedirs(file_dir, exist_ok=True)
|
||||
(Path(file_dir) / 'output').mkdir(exist_ok=True, parents=True)
|
||||
(Path(file_dir) / "output").mkdir(exist_ok=True, parents=True)
|
||||
if code is not None:
|
||||
with open(filepath, 'w', encoding='utf-8') as fout:
|
||||
with open(filepath, "w", encoding="utf-8") as fout:
|
||||
fout.write(code)
|
||||
try:
|
||||
return cls._execute_code(code=code, timeout=timeout, filename=filename, work_dir=work_dir, lang=lang,
|
||||
file_path=filepath)
|
||||
return cls._execute_code(
|
||||
code=code, timeout=timeout, filename=filename, work_dir=work_dir, lang=lang, file_path=filepath
|
||||
)
|
||||
finally:
|
||||
if filepath is not None:
|
||||
os.remove(filepath)
|
||||
@@ -219,7 +224,7 @@ class LocalExecutor(BaseExecutor):
|
||||
work_dir=dir_path,
|
||||
lang=lang,
|
||||
)
|
||||
logs += '\n' + logs
|
||||
logs += "\n" + logs
|
||||
file_list = []
|
||||
if exitcode != 0:
|
||||
return exitcode, logs, file_list
|
||||
@@ -237,8 +242,9 @@ class LocalExecutor(BaseExecutor):
|
||||
return exitcode, logs, file_list
|
||||
|
||||
def run(self, code: str) -> Any:
|
||||
original_code = code
|
||||
code_blocks = self.extract_code(code)
|
||||
logs_all = ''
|
||||
logs_all = ""
|
||||
all_file_list = []
|
||||
for i, code_block in enumerate(code_blocks):
|
||||
lang, code = code_block
|
||||
@@ -250,13 +256,20 @@ class LocalExecutor(BaseExecutor):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
exit_code, logs, file_list = self.run_with_dir(code, dir_path=temp_dir, lang=lang)
|
||||
if exit_code != 0:
|
||||
return {'exitcode': exit_code, 'log': logs_all}
|
||||
return {"exitcode": exit_code, "log": logs_all}
|
||||
logs_all += "\n" + logs
|
||||
all_file_list += file_list
|
||||
|
||||
return {'exitcode': 0, 'log': logs_all, 'file_list': all_file_list}
|
||||
# Deterministic safety net: if the script wrote a deliverable to an absolute
|
||||
# /output//scratch path it escaped the harvested working dir and silently
|
||||
# vanished (see base_executor). Append a corrective notice so the model
|
||||
# re-writes with a relative path on the next step. Non-blocking.
|
||||
advisory = self.absolute_path_advisory(original_code)
|
||||
if advisory:
|
||||
logs_all += advisory
|
||||
return {"exitcode": 0, "log": logs_all, "file_list": all_file_list}
|
||||
|
||||
def sync_files_to_local(self, files_info: List[DirEntry], root_path: str):
|
||||
def sync_files_to_local(self, files_info: list[DirEntry], root_path: str):
|
||||
if not files_info:
|
||||
return
|
||||
for file in files_info:
|
||||
@@ -277,11 +290,15 @@ class LocalExecutor(BaseExecutor):
|
||||
shutil.move(file_info.path, local_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
tmp_executor = LocalExecutor(minio={}, )
|
||||
if __name__ == "__main__":
|
||||
tmp_executor = LocalExecutor(
|
||||
minio={},
|
||||
)
|
||||
result = tmp_executor.run(
|
||||
code="""import os\nwith open("output/test2.txt", "w") as f:\n f.write("Hello, E2222B!")\nprint("File written to output/test.txt")""")
|
||||
code="""import os\nwith open("output/test2.txt", "w") as f:\n f.write("Hello, E2222B!")\nprint("File written to output/test.txt")"""
|
||||
)
|
||||
result2 = tmp_executor.run(
|
||||
code="""import os\nwith open("output/test2.txt", "r") as f:\n content = f.read()\n print(f"File read from output/test2.txt=={content}")""")
|
||||
code="""import os\nwith open("output/test2.txt", "r") as f:\n content = f.read()\n print(f"File read from output/test2.txt=={content}")"""
|
||||
)
|
||||
print(result)
|
||||
print(result2)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Code-interpreter deliverables must land under the RELATIVE ``output/`` dir.
|
||||
|
||||
Root cause of the "PDF vanished from the workspace" bug: the model wrote the
|
||||
deliverable to an ABSOLUTE path (``/output/report.pdf``) which resolves to the
|
||||
container filesystem root — outside the per-task working dir the executor harvests
|
||||
into the linsight workspace. The file was uploaded nowhere (``file_list == []``),
|
||||
never synced, never picked up by ``get_final_result_file``, and the result panel
|
||||
fell back to a synthesized ``报告.md``.
|
||||
|
||||
The shared LocalExecutor cannot safely rescue container-root files (that would leak
|
||||
one task's output into another), so the fix is: (1) a strict relative-path contract
|
||||
in the tool description, and (2) a deterministic, non-blocking corrective notice
|
||||
appended to the tool result whenever a run wrote to an absolute ``/output``/
|
||||
``/scratch`` path, so the model self-corrects on the next step.
|
||||
|
||||
These tests cover the detection helper and the LocalExecutor notice wiring; no real
|
||||
subprocess / matplotlib / MinIO is involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bisheng_langchain.gpts.tools.code_interpreter.base_executor import (
|
||||
ABSOLUTE_PATH_NOTICE,
|
||||
BaseExecutor,
|
||||
)
|
||||
from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# absolute_path_advisory: flags leading-slash /output|/scratch string literals only
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"with open('/output/report.pdf', 'wb') as f: f.write(b'x')",
|
||||
'plt.savefig("/scratch/chart.png")',
|
||||
"pdf.output('/output/油脂油料市场早报.pdf')",
|
||||
"open('/output')", # no trailing slash, still absolute deliverable root
|
||||
"open('/scratch')",
|
||||
],
|
||||
)
|
||||
def test_advisory_flags_absolute_deliverable_paths(code):
|
||||
assert BaseExecutor.absolute_path_advisory(code) == ABSOLUTE_PATH_NOTICE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"with open('output/report.pdf', 'wb') as f: f.write(b'x')", # relative — correct
|
||||
"plt.savefig('scratch/chart.png')", # relative — correct
|
||||
"open('./output/report.pdf')", # relative with ./
|
||||
"url = 'https://host/output/y'", # /output mid-string, not a path root
|
||||
"open('/data/output/x')", # path root is /data, not /output
|
||||
"p = '/outputs/x'", # different word (/outputs), not /output
|
||||
"print('hello world')",
|
||||
"",
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_advisory_silent_for_relative_or_unrelated(code):
|
||||
assert BaseExecutor.absolute_path_advisory(code) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LocalExecutor.run: appends the corrective notice for absolute-path writes
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_executor(monkeypatch):
|
||||
"""LocalExecutor with subprocess execution + matplotlib font side effects
|
||||
stubbed out, so run() exercises only the loop + notice-append logic."""
|
||||
exe = LocalExecutor(minio={})
|
||||
exe.local_sync_path = None # take the TemporaryDirectory branch
|
||||
# stub the actual run so no python subprocess is spawned
|
||||
monkeypatch.setattr(exe, "run_with_dir", lambda code, dir_path, lang: (0, "stdout-ok\n", []))
|
||||
# stub the matplotlib font injection (touches the mpl cache otherwise)
|
||||
monkeypatch.setattr(exe, "insert_set_font_code", lambda code: code)
|
||||
return exe
|
||||
|
||||
|
||||
def test_run_appends_notice_for_absolute_output(monkeypatch):
|
||||
exe = _make_executor(monkeypatch)
|
||||
result = exe.run("with open('/output/report.pdf', 'wb') as f:\n f.write(b'x')")
|
||||
assert result["exitcode"] == 0
|
||||
assert ABSOLUTE_PATH_NOTICE in result["log"]
|
||||
# original stdout is preserved alongside the notice
|
||||
assert "stdout-ok" in result["log"]
|
||||
|
||||
|
||||
def test_run_no_notice_for_relative_output(monkeypatch):
|
||||
exe = _make_executor(monkeypatch)
|
||||
result = exe.run("with open('output/report.pdf', 'wb') as f:\n f.write(b'x')")
|
||||
assert result["exitcode"] == 0
|
||||
assert ABSOLUTE_PATH_NOTICE not in result["log"]
|
||||
assert "stdout-ok" in result["log"]
|
||||
|
||||
|
||||
def test_run_notice_uses_original_code_not_last_block(monkeypatch):
|
||||
"""The notice keys off the full submitted script, not the loop's reassigned
|
||||
``code`` var (which would otherwise hold only the last code block)."""
|
||||
exe = _make_executor(monkeypatch)
|
||||
result = exe.run("import os\nos.makedirs('/scratch/charts', exist_ok=True)\nprint('done')")
|
||||
assert ABSOLUTE_PATH_NOTICE in result["log"]
|
||||
|
||||
|
||||
def test_run_failure_path_returns_without_notice(monkeypatch):
|
||||
exe = LocalExecutor(minio={})
|
||||
exe.local_sync_path = None
|
||||
monkeypatch.setattr(exe, "insert_set_font_code", lambda code: code)
|
||||
monkeypatch.setattr(exe, "run_with_dir", lambda code, dir_path, lang: (1, "boom\n", []))
|
||||
result = exe.run("open('/output/x.pdf', 'wb')")
|
||||
assert result["exitcode"] == 1
|
||||
assert "log" in result
|
||||
# failure path returns early — no file_list, no notice appended
|
||||
assert "file_list" not in result
|
||||
Reference in New Issue
Block a user