From 288ffdafa83b115af66e7c560cb236b3bb64364e Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 15:36:59 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=94=81=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=96=87=E4=BB=B6=E5=88=A0=E9=99=A4=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/resource_utils/download_assets.py | 142 ++--- .../model/resource_utils/process_with_lock.py | 80 +++ llm_web_kit/model/resource_utils/unzip_ext.py | 109 ++-- llm_web_kit/model/resource_utils/utils.py | 62 +- requirements/runtime.txt | 1 + .../resource_utils/test_download_assets.py | 556 +++++------------- .../resource_utils/test_process_with_lock.py | 209 +++++++ .../resource_utils/test_resource_utils.py | 129 +--- .../model/resource_utils/test_unzip_ext.py | 257 ++++---- 9 files changed, 690 insertions(+), 855 deletions(-) create mode 100644 llm_web_kit/model/resource_utils/process_with_lock.py create mode 100644 tests/llm_web_kit/model/resource_utils/test_process_with_lock.py diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index ab7cbead..dc2094b5 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -1,7 +1,7 @@ import hashlib import os -import shutil import tempfile +from functools import partial from typing import Iterable, Optional import requests @@ -13,7 +13,8 @@ from llm_web_kit.model.resource_utils.boto3_ext import (get_s3_client, is_s3_path, split_s3_path) -from llm_web_kit.model.resource_utils.utils import FileLockContext, try_remove +from llm_web_kit.model.resource_utils.process_with_lock import \ + process_and_verify_file_with_lock def decide_cache_dir(): @@ -133,36 +134,45 @@ def verify_file_checksum( return True -def download_to_temp(conn, progress_bar) -> str: +def download_to_temp(conn, progress_bar, download_path): """下载到临时文件.""" - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_path = tmp_file.name - logger.info(f'Downloading to temporary file: {tmp_path}') + with open(download_path, 'wb') as f: + for chunk in conn.read_stream(): + if chunk: # 防止空chunk导致进度条卡死 + f.write(chunk) + progress_bar.update(len(chunk)) + + +def download_auto_file_core( + resource_path: str, + target_path: str, +) -> str: + # 创建连接 + conn_cls = S3Connection if is_s3_path(resource_path) else HttpConnection + conn = conn_cls(resource_path) + total_size = conn.get_size() + + # 下载流程 + logger.info(f'Downloading {resource_path} => {target_path}') + progress = tqdm(total=total_size, unit='iB', unit_scale=True) + + with tempfile.TemporaryDirectory() as temp_dir: + download_path = os.path.join(temp_dir, 'download_file') try: - with open(tmp_path, 'wb') as f: - for chunk in conn.read_stream(): - if chunk: # 防止空chunk导致进度条卡死 - f.write(chunk) - progress_bar.update(len(chunk)) - return tmp_path - except Exception: - try_remove(tmp_path) - raise - - -def move_to_target(tmp_path: str, target_path: str, expected_size: int): - """移动文件并验证.""" - if os.path.getsize(tmp_path) != expected_size: - raise ModelResourceException( - f'File size mismatch: {os.path.getsize(tmp_path)} vs {expected_size}' - ) + download_to_temp(conn, progress, download_path) + if not total_size == os.path.getsize(download_path): + raise ModelResourceException( + f'Downloaded {resource_path} to {download_path}, but size mismatch' + ) + + os.makedirs(os.path.dirname(target_path), exist_ok=True) + os.rename(download_path, target_path) - os.makedirs(os.path.dirname(target_path), exist_ok=True) - shutil.move(tmp_path, target_path) # 原子操作替换 + return target_path - if not os.path.exists(target_path): - raise ModelResourceException(f'Move failed: {tmp_path} -> {target_path}') + finally: + progress.close() def download_auto_file( @@ -170,71 +180,25 @@ def download_auto_file( target_path: str, md5_sum: str = '', sha256_sum: str = '', - exist_ok=True, - lock_timeout: int = 300, + lock_suffix: str = '.lock', + lock_timeout: float = 60, ) -> str: - """Download a file from a given resource path (either an S3 path or an HTTP - URL) to a target path on the local file system. - - This function will first download the file to a temporary file, then move the temporary file to the target path after - the download is complete. A progress bar will be displayed during the download. - - If the size of the downloaded file does not match the expected size, an exception will be raised. + """Download a file from the web or S3, verify its checksum, and return the + target path. Use SoftFileLock to prevent concurrent downloads. Args: - resource_path (str): The path of the resource to download. This can be either an S3 path (e.g., "s3://bucket/key") - or an HTTP URL (e.g., "http://example.com/file"). - target_path (str): The path on the local file system where the downloaded file should be saved.\ - exist_ok (bool, optional): If False, raise an exception if the target path already exists. Defaults to True. + resource_path (str): the source URL or S3 path to download from + target_path (str): the target path to save the downloaded file + md5_sum (str, optional): the expected MD5 checksum of the file. Defaults to ''. + sha256_sum (str, optional): the expected SHA256 checksum of the file. Defaults to ''. + lock_suffix (str, optional): the suffix of the lock file. Defaults to '.lock'. + lock_timeout (float, optional): the timeout of the lock file. Defaults to 60. Returns: - str: The path where the downloaded file was saved. - - Raises: - Exception: If an error occurs during the download, or if the size of the downloaded file does not match the - expected size, or if the temporary file cannot be moved to the target path. + str: the target path of the downloaded file """ - - """线程安全的文件下载函数""" - lock_path = f'{target_path}.lock' - - def check_callback(): - return verify_file_checksum(target_path, md5_sum, sha256_sum) - - if os.path.exists(target_path): - if not exist_ok: - raise ModelResourceException( - f'File exists with invalid checksum: {target_path}' - ) - - if verify_file_checksum(target_path, md5_sum, sha256_sum): - logger.info(f'File already exists with valid checksum: {target_path}') - return target_path - else: - logger.warning(f'Removing invalid file: {target_path}') - try_remove(target_path) - - with FileLockContext(lock_path, check_callback, timeout=lock_timeout) as lock: - if lock is True: - logger.info( - f'File already exists with valid checksum: {target_path} while waiting' - ) - return target_path - - # 创建连接 - conn_cls = S3Connection if is_s3_path(resource_path) else HttpConnection - conn = conn_cls(resource_path) - total_size = conn.get_size() - - # 下载流程 - logger.info(f'Downloading {resource_path} => {target_path}') - progress = tqdm(total=total_size, unit='iB', unit_scale=True) - - try: - tmp_path = download_to_temp(conn, progress) - move_to_target(tmp_path, target_path, total_size) - - return target_path - finally: - progress.close() - try_remove(tmp_path) # 确保清理临时文件 + process_func = partial(download_auto_file_core, resource_path, target_path) + verify_func = partial(verify_file_checksum, target_path, md5_sum, sha256_sum) + return process_and_verify_file_with_lock( + process_func, verify_func, target_path, lock_suffix, lock_timeout + ) diff --git a/llm_web_kit/model/resource_utils/process_with_lock.py b/llm_web_kit/model/resource_utils/process_with_lock.py new file mode 100644 index 00000000..ed904bdb --- /dev/null +++ b/llm_web_kit/model/resource_utils/process_with_lock.py @@ -0,0 +1,80 @@ +import os +import time +from typing import Callable + +from filelock import SoftFileLock, Timeout + +from llm_web_kit.model.resource_utils.utils import try_remove + + +def get_path_mtime(target_path: str) -> float: + if os.path.isdir(target_path): + # walk through the directory and get the latest mtime + latest_mtime = None + for root, _, files in os.walk(target_path): + for file in files: + file_path = os.path.join(root, file) + mtime = os.path.getmtime(file_path) + if latest_mtime is None or mtime > latest_mtime: + latest_mtime = mtime + return latest_mtime + else: + return os.path.getmtime(target_path) + + +def process_and_verify_file_with_lock( + process_func: Callable[[], str], # 无参数,返回目标路径 + verify_func: Callable[[], bool], # 无参数,返回验证结果 + target_path: str, + lock_suffix: str = '.lock', + timeout: float = 60, +) -> str: + """通用处理验证框架. + + :param process_func: 无参数的处理函数,返回最终目标路径 + :param verify_func: 无参数的验证函数,返回布尔值 + :param target_path: 目标路径(文件或目录) + :param lock_suffix: 锁文件后缀 + :param timeout: 处理超时时间(秒) + """ + lock_path = target_path + lock_suffix + + while True: + # 检查目标是否存在且有效 + if os.path.exists(target_path): + if verify_func(): + return target_path + else: + # 目标存在但验证失败 + if os.path.exists(lock_path): + now = time.time() + try: + mtime = get_path_mtime(target_path) + print(f'now: {now}, mtime: {mtime}') + if now - mtime < timeout: + time.sleep(1) + continue + else: + try_remove(lock_path) + try_remove(target_path) + except FileNotFoundError: + pass + else: + try_remove(target_path) + else: + + # 尝试获取锁 + file_lock = SoftFileLock(lock_path) + try: + file_lock.acquire(timeout=1) + # 二次验证(可能其他进程已处理完成) + if os.path.exists(target_path) and verify_func(): + return target_path + # 执行处理 + return process_func() + except Timeout: + time.sleep(1) + continue + finally: + if file_lock.is_locked: + file_lock.release() diff --git a/llm_web_kit/model/resource_utils/unzip_ext.py b/llm_web_kit/model/resource_utils/unzip_ext.py index 6579fd62..50c4e363 100644 --- a/llm_web_kit/model/resource_utils/unzip_ext.py +++ b/llm_web_kit/model/resource_utils/unzip_ext.py @@ -1,12 +1,13 @@ import os -import shutil import tempfile import zipfile +from functools import partial from typing import Optional from llm_web_kit.exception.exception import ModelResourceException from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.utils import FileLockContext, try_remove +from llm_web_kit.model.resource_utils.process_with_lock import \ + process_and_verify_file_with_lock def get_unzip_dir(zip_path: str) -> str: @@ -25,33 +26,37 @@ def get_unzip_dir(zip_path: str) -> str: return os.path.join(zip_dir, base_name + '_unzip') -def check_zip_file(zip_ref: zipfile.ZipFile, target_dir: str) -> bool: +def check_zip_path( + zip_path: str, target_dir: str, password: Optional[str] = None +) -> bool: """Check if the zip file is correctly unzipped to the target directory. Args: - zip_ref (zipfile.ZipFile): The zip file object. + zip_path (str): The path to the zip file. target_dir (str): The target directory. + password (Optional[str], optional): The password to the zip file. Defaults to None. Returns: bool: True if the zip file is correctly unzipped to the target directory, False otherwise. """ + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + if password: + zip_ref.setpassword(password.encode()) - zip_info_list = [info for info in zip_ref.infolist() if not info.is_dir()] - for info in zip_info_list: - file_path = os.path.join(target_dir, info.filename) - if not os.path.exists(file_path): - return False - if os.path.getsize(file_path) != info.file_size: - return False - return True + zip_info_list = [info for info in zip_ref.infolist() if not info.is_dir()] + for info in zip_info_list: + file_path = os.path.join(target_dir, info.filename) + if not os.path.exists(file_path): + return False + if os.path.getsize(file_path) != info.file_size: + return False + return True -def unzip_local_file( +def unzip_local_file_core( zip_path: str, target_dir: str, password: Optional[str] = None, - exist_ok: bool = True, - lock_timeout: float = 300, ) -> str: """Unzip a zip file to a target directory. @@ -59,64 +64,52 @@ def unzip_local_file( zip_path (str): The path to the zip file. target_dir (str): The directory to unzip the files to. password (Optional[str], optional): The password to the zip file. Defaults to None. - exist_ok (bool, optional): If True, overwrite the files in the target directory if it already exists. - If False, raise an exception if the target directory already exists. Defaults to False. Raises: ModelResourceException: If the zip file does not exist. - ModelResourceException: If the target directory already exists and exist_ok is False + ModelResourceException: If the target directory already exists. Returns: str: The path to the target directory. """ - lock_path = f'{zip_path}.lock' - if not os.path.exists(zip_path): logger.error(f'zip file {zip_path} does not exist') raise ModelResourceException(f'zip file {zip_path} does not exist') - def check_zip(): - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - if password: - zip_ref.setpassword(password.encode()) - return check_zip_file(zip_ref, target_dir) - if os.path.exists(target_dir): - if not exist_ok: - raise ModelResourceException( - f'Target directory {target_dir} already exists' - ) - - if check_zip(): - logger.info(f'zip file {zip_path} is already unzipped to {target_dir}') - return target_dir - else: - logger.warning( - f'zip file {zip_path} is not correctly unzipped to {target_dir}, retry to unzip' - ) - try_remove(target_dir) - - with FileLockContext(lock_path, check_zip, timeout=lock_timeout) as lock: - if lock is True: - logger.info( - f'zip file {zip_path} is already unzipped to {target_dir} while waiting' - ) - return target_dir - - # ensure target directory not exists - - # 创建临时解压目录 + raise ModelResourceException(f'Target directory {target_dir} already exists') + + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + if password: + zip_ref.setpassword(password.encode()) with tempfile.TemporaryDirectory() as temp_dir: extract_dir = os.path.join(temp_dir, 'temp') os.makedirs(extract_dir, exist_ok=True) + zip_ref.extractall(extract_dir) + os.rename(extract_dir, target_dir) + return target_dir - # 解压到临时目录 - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - if password: - zip_ref.setpassword(password.encode()) - zip_ref.extractall(extract_dir) - # 原子性复制到目标目录 - shutil.copytree(extract_dir, target_dir) +def unzip_local_file( + zip_path: str, + target_dir: str, + password: Optional[str] = None, + lock_suffix: str = '.unzip.lock', + timeout: float = 60, +) -> str: + """Unzip a zip file to a target directory with a lock. - return target_dir + Args: + zip_path (str): The path to the zip file. + target_dir (str): The directory to unzip the files to. + password (Optional[str], optional): The password to the zip file. Defaults to None. + timeout (float, optional): The timeout for the lock. Defaults to 60. + + Returns: + str: The path to the target directory. + """ + process_func = partial(unzip_local_file_core, zip_path, target_dir, password) + verify_func = partial(check_zip_path, zip_path, target_dir, password) + return process_and_verify_file_with_lock( + process_func, verify_func, target_dir, lock_suffix, timeout + ) diff --git a/llm_web_kit/model/resource_utils/utils.py b/llm_web_kit/model/resource_utils/utils.py index b80a35d0..961a6275 100644 --- a/llm_web_kit/model/resource_utils/utils.py +++ b/llm_web_kit/model/resource_utils/utils.py @@ -1,62 +1,14 @@ -import errno import os -import time +import shutil def try_remove(path: str): - """Attempt to remove a file, but ignore any exceptions that occur.""" + """Attempt to remove a file by os.remove or to remove a directory by + shutil.rmtree and ignore exceptions.""" try: - os.remove(path) + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) except Exception: pass - - -class FileLockContext: - """基于文件锁的上下文管理器(跨平台兼容版)""" - - def __init__(self, lock_path: str, check_callback=None, timeout: float = 300): - self.lock_path = lock_path - self.check_callback = check_callback - self.timeout = timeout - self._fd = None - - def __enter__(self): - start_time = time.time() - while True: - if self.check_callback: - if self.check_callback(): - return True - try: - # 原子性创建锁文件(O_EXCL标志是关键) - self._fd = os.open( - self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644 - ) - # 写入进程信息和时间戳 - with os.fdopen(self._fd, 'w') as f: - f.write(f'{os.getpid()}\n{time.time()}') - return self - except OSError as e: - if e.errno != errno.EEXIST: - raise - - # 检查锁是否过期 - try: - with open(self.lock_path, 'r') as f: - pid, timestamp = f.read().split('\n')[:2] - if time.time() - float(timestamp) > self.timeout: - os.remove(self.lock_path) - except (FileNotFoundError, ValueError): - pass - - if time.time() - start_time > self.timeout: - raise TimeoutError(f'Could not acquire lock after {self.timeout}s') - time.sleep(0.1) - - def __exit__(self, exc_type, exc_val, exc_tb): - try: - if self._fd: - os.close(self._fd) - except OSError: - pass - finally: - try_remove(self.lock_path) diff --git a/requirements/runtime.txt b/requirements/runtime.txt index 786070f7..cec5055c 100644 --- a/requirements/runtime.txt +++ b/requirements/runtime.txt @@ -3,6 +3,7 @@ cairosvg==2.7.1 click==8.1.8 commentjson==0.9.0 fasttext-wheel==0.9.2 +filelock==3.16.1 jieba-fast==0.53 lightgbm==4.5.0 loguru==0.7.2 diff --git a/tests/llm_web_kit/model/resource_utils/test_download_assets.py b/tests/llm_web_kit/model/resource_utils/test_download_assets.py index b3e55e74..ed7372dd 100644 --- a/tests/llm_web_kit/model/resource_utils/test_download_assets.py +++ b/tests/llm_web_kit/model/resource_utils/test_download_assets.py @@ -1,482 +1,212 @@ -import io +import hashlib import os import tempfile +import time import unittest -from typing import Tuple -from unittest.mock import MagicMock, call, mock_open, patch +from unittest.mock import MagicMock, call, patch from llm_web_kit.exception.exception import ModelResourceException from llm_web_kit.model.resource_utils.download_assets import ( HttpConnection, S3Connection, calc_file_md5, calc_file_sha256, - decide_cache_dir, download_auto_file, download_to_temp, move_to_target, - verify_file_checksum) + decide_cache_dir, download_auto_file, download_auto_file_core, + download_to_temp, verify_file_checksum) -class Test_decide_cache_dir: +class TestDecideCacheDir: @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_only_env(self, get_configMock): - get_configMock.side_effect = Exception + def test_only_env(self, get_config_mock): + get_config_mock.side_effect = Exception assert decide_cache_dir() == '/env/cache_dir' @patch('os.environ', {}) @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_only_config(self, get_configMock): - get_configMock.return_value = { + def test_only_config(self, get_config_mock): + get_config_mock.return_value = { 'resources': {'common': {'cache_path': '/config/cache_dir'}} } assert decide_cache_dir() == '/config/cache_dir' @patch('os.environ', {}) @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_default(self, get_configMock): - get_configMock.side_effect = Exception - # if no env or config, use default + def test_default(self, get_config_mock): + get_config_mock.side_effect = Exception assert decide_cache_dir() == os.path.expanduser('~/.llm_web_kit_cache') @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_both(self, get_configMock): - get_configMock.return_value = { + def test_priority(self, get_config_mock): + get_config_mock.return_value = { 'resources': {'common': {'cache_path': '/config/cache_dir'}} } - # config is preferred assert decide_cache_dir() == '/config/cache_dir' -class Test_calc_file_md5: +class TestChecksumCalculations: def test_calc_file_md5(self): - import hashlib - with tempfile.NamedTemporaryFile() as f: - test_bytes = b'hello world' * 10000 - f.write(test_bytes) + test_data = b'hello world' * 100 + f.write(test_data) f.flush() - assert calc_file_md5(f.name) == hashlib.md5(test_bytes).hexdigest() - - -class Test_calc_file_sha256: + expected = hashlib.md5(test_data).hexdigest() + assert calc_file_md5(f.name) == expected def test_calc_file_sha256(self): - import hashlib - with tempfile.NamedTemporaryFile() as f: - test_bytes = b'hello world' * 10000 - f.write(test_bytes) + test_data = b'hello world' * 100 + f.write(test_data) f.flush() - assert calc_file_sha256(f.name) == hashlib.sha256(test_bytes).hexdigest() + expected = hashlib.sha256(test_data).hexdigest() + assert calc_file_sha256(f.name) == expected + + +class TestConnections: + + @patch('requests.get') + def test_http_connection(self, mock_get): + test_data = b'test data' + mock_response = MagicMock() + mock_response.headers = {'content-length': str(len(test_data))} + mock_response.iter_content.return_value = [test_data] + mock_get.return_value = mock_response + + conn = HttpConnection('http://example.com') + assert conn.get_size() == len(test_data) + assert next(conn.read_stream()) == test_data + del conn + mock_response.close.assert_called() + + @patch('llm_web_kit.model.resource_utils.download_assets.get_s3_client') + def test_s3_connection(self, mock_client): + mock_body = MagicMock() + mock_body.read.side_effect = [b'chunk1', b'chunk2', b''] + mock_client.return_value.get_object.return_value = { + 'ContentLength': 100, + 'Body': mock_body, + } + conn = S3Connection('s3://bucket/key') + assert conn.get_size() == 100 + assert list(conn.read_stream()) == [b'chunk1', b'chunk2'] + del conn + mock_body.close.assert_called() -def read_mockio_size(mock_io: io.BytesIO, size: int): - while True: - data = mock_io.read(size) - if not data: - break - yield data +class TestDownloadCoreFunctionality(unittest.TestCase): -def get_mock_http_response(test_data: bytes) -> Tuple[MagicMock, int]: - mock_io = io.BytesIO(test_data) - content_length = len(test_data) - response_mock = MagicMock() - response_mock.headers = {'content-length': str(content_length)} - response_mock.iter_content.return_value = read_mockio_size(mock_io, 1024) - return response_mock, content_length + @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') + def test_successful_download(self, mock_conn): + # Mock connection + download_data = b'data' + mock_instance = MagicMock() + mock_instance.read_stream.return_value = [download_data] + mock_instance.get_size.return_value = len(download_data) + mock_conn.return_value = mock_instance + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, 'target.file') + result = download_auto_file_core('s3://bucket/key', target) -def get_mock_s3_response(test_data: bytes) -> Tuple[MagicMock, int]: - mock_io = io.BytesIO(test_data) - content_length = len(test_data) - clientMock = MagicMock() - body = MagicMock() - body.read.side_effect = read_mockio_size(mock_io, 1024) - clientMock.get_object.return_value = {'ContentLength': content_length, 'Body': body} - return clientMock, content_length + assert result == target + assert os.path.exists(target) + @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') + def test_size_mismatch(self, mock_conn): + download_data = b'data' + mock_instance = MagicMock() + mock_instance.read_stream.return_value = [download_data] + mock_instance.get_size.return_value = len(download_data) + 1 -@patch('llm_web_kit.model.resource_utils.download_assets.get_s3_client') -@patch('llm_web_kit.model.resource_utils.download_assets.split_s3_path') -def test_S3Connection(split_s3_pathMock, get_s3_clientMock): - test_data = b'hello world' * 100 + mock_conn.return_value = mock_instance - # Mock the split_s3_path function - split_s3_pathMock.return_value = ('bucket', 'key') + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, 'target.file') + with self.assertRaises(ModelResourceException): + download_auto_file_core('http://example.com', target) - # Mock the S3 client - clientMock, content_length = get_mock_s3_response(test_data) - get_s3_clientMock.return_value = clientMock - # Test the S3Connection class - conn = S3Connection('s3://bucket/key') - assert conn.get_size() == content_length - assert b''.join(conn.read_stream()) == test_data +class TestDownloadToTemp: + def test_normal_download(self): + mock_conn = MagicMock() + mock_conn.read_stream.return_value = [b'chunk1', b'chunk2'] + mock_progress = MagicMock() -@patch('requests.get') -def test_HttpConnection(requests_get_mock): - test_data = b'hello world' * 100 - response_mock, content_length = get_mock_http_response(test_data) - requests_get_mock.return_value = response_mock + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = os.path.join(tmpdir, 'temp.file') + download_to_temp(mock_conn, mock_progress, temp_path) - # Test the HttpConnection class - conn = HttpConnection('http://example.com/file') - assert conn.get_size() == content_length - assert b''.join(conn.read_stream()) == test_data + with open(temp_path, 'rb') as f: + assert f.read() == b'chunk1chunk2' + mock_progress.update.assert_has_calls([call(6), call(6)]) + def test_empty_chunk_handling(self): + mock_conn = MagicMock() + mock_conn.read_stream.return_value = [b'', b'data', b''] + mock_progress = MagicMock() -class TestDownloadAutoFile(unittest.TestCase): + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = os.path.join(tmpdir, 'temp.file') + download_to_temp(mock_conn, mock_progress, temp_path) - @patch('llm_web_kit.model.resource_utils.download_assets.os.path.exists') - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.is_s3_path') - @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') - @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') - def test_file_exists_correct_md5( - self, - mock_http_conn, - mock_s3_conn, - mock_is_s3_path, - mock_calc_file_md5, - mock_os_path_exists, - ): - # Arrange - mock_os_path_exists.return_value = True - mock_calc_file_md5.return_value = 'correct_md5' - mock_is_s3_path.return_value = False - mock_http_conn.return_value = MagicMock(get_size=MagicMock(return_value=100)) - - # Act - result = download_auto_file( - 'http://example.com', 'target_path', md5_sum='correct_md5' - ) - - # Assert - assert result == 'target_path' - - mock_os_path_exists.assert_called_once_with('target_path') - mock_calc_file_md5.assert_called_once_with('target_path') - mock_http_conn.assert_not_called() - mock_s3_conn.assert_not_called() - try: - os.remove('target_path.lock') - except FileNotFoundError: - pass - - @patch('llm_web_kit.model.resource_utils.download_assets.os.path.exists') - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - @patch('llm_web_kit.model.resource_utils.download_assets.is_s3_path') - @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') - @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') - def test_file_exists_correct_sha256( - self, - mock_http_conn, - mock_s3_conn, - mock_is_s3_path, - mock_calc_file_sha256, - mock_os_path_exists, - ): - # Arrange - mock_os_path_exists.return_value = True - mock_calc_file_sha256.return_value = 'correct_sha256' - mock_is_s3_path.return_value = False - mock_http_conn.return_value = MagicMock(get_size=MagicMock(return_value=100)) - - # Act - result = download_auto_file( - 'http://example.com', 'sha256_target_path', sha256_sum='correct_sha256' - ) - - # Assert - assert result == 'sha256_target_path' - - mock_os_path_exists.assert_called_once_with('sha256_target_path') - mock_calc_file_sha256.assert_called_once_with('sha256_target_path') - mock_http_conn.assert_not_called() - mock_s3_conn.assert_not_called() - try: - os.remove('sha256_target_path.lock') - except FileNotFoundError: - pass + with open(temp_path, 'rb') as f: + assert f.read() == b'data' - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.os.remove') - @patch('llm_web_kit.model.resource_utils.download_assets.is_s3_path') - @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') - @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') - def test_file_exists_wrong_md5_download_http( - self, - mock_http_conn, - mock_s3_conn, - mock_is_s3_path, - mock_os_remove, - mock_calc_file_md5, - ): - # Arrange - mock_calc_file_md5.return_value = 'wrong_md5' - mock_is_s3_path.return_value = False - - with tempfile.TemporaryDirectory() as tmp_dir: - with open(os.path.join(tmp_dir, 'target_path'), 'wb') as f: - f.write(b'hello world') - response_mock, content_length = get_mock_http_response(b'hello world') - mock_http_conn.return_value = MagicMock( - get_size=MagicMock(return_value=content_length), - read_stream=MagicMock(return_value=response_mock.iter_content()), - ) - - target_path = os.path.join(tmp_dir, 'target_path') - # Act - result = download_auto_file( - 'http://example.com', target_path, md5_sum='correct_md5' - ) - - assert result == target_path - with open(target_path, 'rb') as f: - assert f.read() == b'hello world' - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - @patch('llm_web_kit.model.resource_utils.download_assets.os.remove') - @patch('llm_web_kit.model.resource_utils.download_assets.is_s3_path') - @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') - @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') - def test_file_exists_wrong_sha256_download_http( - self, - mock_http_conn, - mock_s3_conn, - mock_is_s3_path, - mock_os_remove, - mock_calc_file_sha256, - ): - # Arrange - mock_calc_file_sha256.return_value = 'wrong_sha256' - mock_is_s3_path.return_value = False - - with tempfile.TemporaryDirectory() as tmp_dir: - with open(os.path.join(tmp_dir, 'target_path'), 'wb') as f: - f.write(b'hello world') - response_mock, content_length = get_mock_http_response(b'hello world') - mock_http_conn.return_value = MagicMock( - get_size=MagicMock(return_value=content_length), - read_stream=MagicMock(return_value=response_mock.iter_content()), - ) - - target_path = os.path.join(tmp_dir, 'target_path') - # Act - result = download_auto_file( - 'http://example.com', target_path, sha256_sum='correct_sha256' - ) - - assert result == target_path - with open(target_path, 'rb') as f: - assert f.read() == b'hello world' +class TestVerifyChecksum(unittest.TestCase): @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.os.remove') - @patch('llm_web_kit.model.resource_utils.download_assets.is_s3_path') - @patch('llm_web_kit.model.resource_utils.download_assets.S3Connection') - @patch('llm_web_kit.model.resource_utils.download_assets.HttpConnection') - def test_file_not_exists_download_http( - self, - mock_http_conn, - mock_s3_conn, - mock_is_s3_path, - mock_os_remove, - mock_calc_file_md5, - ): - # Arrange - mock_is_s3_path.return_value = False - - with tempfile.TemporaryDirectory() as tmp_dir: - response_mock, content_length = get_mock_http_response(b'hello world') - mock_http_conn.return_value = MagicMock( - get_size=MagicMock(return_value=content_length), - read_stream=MagicMock(return_value=response_mock.iter_content()), - ) - - target_path = os.path.join(tmp_dir, 'target_path') - # Act - result = download_auto_file( - 'http://example.com', target_path, md5_sum='correct_md5' - ) - - assert result == target_path - with open(target_path, 'rb') as f: - assert f.read() == b'hello world' - - -# def verify_file_checksum( -# file_path: str, md5_sum: Optional[str] = None, sha256_sum: Optional[str] = None -# ) -> bool: -# """校验文件哈希值.""" -# if not sum([bool(md5_sum), bool(sha256_sum)]) == 1: -# raise ModelResourceException('Exactly one of md5_sum or sha256_sum must be provided') - -# if md5_sum: -# actual = calc_file_md5(file_path) -# if actual != md5_sum: -# logger.warning( -# f'MD5 mismatch: expect {md5_sum[:8]}..., got {actual[:8]}...' -# ) -# return False - -# if sha256_sum: -# actual = calc_file_sha256(file_path) -# if actual != sha256_sum: -# logger.warning( -# f'SHA256 mismatch: expect {sha256_sum[:8]}..., got {actual[:8]}...' -# ) -# return False - - -# return True -class Test_verify_file_checksum(unittest.TestCase): - # test pass two value - # test pass two None - # test pass one value correct - # test pass one value incorrect + def test_valid_md5(self, mock_md5): + mock_md5.return_value = 'correct_md5' + assert verify_file_checksum('dummy', md5_sum='correct_md5') is True - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - def test_pass_two_value(self, mock_calc_file_sha256, mock_calc_file_md5): - file_path = 'file_path' - md5_sum = 'md5_sum' - sha256_sum = 'sha256_sum' - mock_calc_file_md5.return_value = md5_sum - mock_calc_file_sha256.return_value = sha256_sum - # will raise ModelResourceException - with self.assertRaises(ModelResourceException): - verify_file_checksum(file_path, md5_sum, sha256_sum) + def test_invalid_sha256(self, mock_sha): + mock_sha.return_value = 'wrong_sha' + assert verify_file_checksum('dummy', sha256_sum='correct_sha') is False - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - def test_pass_two_None(self, mock_calc_file_sha256, mock_calc_file_md5): - file_path = 'file_path' - md5_sum = None - sha256_sum = None - # will raise ModelResourceException + def test_invalid_arguments(self): with self.assertRaises(ModelResourceException): - verify_file_checksum(file_path, md5_sum, sha256_sum) - - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - def test_pass_one_value_correct(self, mock_calc_file_sha256, mock_calc_file_md5): - file_path = 'file_path' - md5_sum = 'md5_sum' - sha256_sum = None - mock_calc_file_md5.return_value = md5_sum - mock_calc_file_sha256.return_value = None - assert verify_file_checksum(file_path, md5_sum, sha256_sum) is True - - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') - @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') - def test_pass_one_value_incorrect(self, mock_calc_file_sha256, mock_calc_file_md5): - file_path = 'file_path' - md5_sum = 'md5_sum' - sha256_sum = None - mock_calc_file_md5.return_value = 'wrong_md5' - mock_calc_file_sha256.return_value = None - assert verify_file_checksum(file_path, md5_sum, sha256_sum) is False - - -class TestDownloadToTemp(unittest.TestCase): - - def setUp(self): - self.mock_conn = MagicMock() - self.mock_progress = MagicMock() - - # mock_open - @patch('builtins.open', new_callable=mock_open) - @patch('tempfile.NamedTemporaryFile') - def test_normal_download(self, mock_temp, mock_open_func): - # 模拟下载流数据 - test_data = [b'chunk1', b'chunk2', b'chunk3'] - self.mock_conn.read_stream.return_value = iter(test_data) - - # 配置临时文件mock - mock_temp.return_value.__enter__.return_value.name = '/tmp/fake.tmp' - - result = download_to_temp(self.mock_conn, self.mock_progress) - - mock_open_func.return_value.write.assert_has_calls( - [call(b'chunk1'), call(b'chunk2'), call(b'chunk3')] - ) - # 验证进度条更新 - self.mock_progress.update.assert_has_calls( - [call(6), call(6), call(6)] # 每个chunk的长度是6 - ) - self.assertEqual(result, '/tmp/fake.tmp') - - @patch('builtins.open', new_callable=mock_open) - @patch('tempfile.NamedTemporaryFile') - def test_exception_handling(self, mock_temp, mock_open_func): - # 模拟写入时发生异常 - self.mock_conn.read_stream.return_value = iter([b'data']) - mock_temp.return_value.__enter__.return_value.name = '/tmp/fail.tmp' - - # file_mock = mock_temp.return_value.__enter__.return_value.__enter__.return_value - # file_mock.write.side_effect = IOError("Disk failure") - - mock_open_func.return_value.write.side_effect = IOError('Disk failure') - with self.assertRaises(IOError): - download_to_temp(self.mock_conn, self.mock_progress) - - def test_empty_chunk_handling(self): - # 测试包含空chunk的情况 - self.mock_conn.read_stream.return_value = iter([b'', b'valid', b'']) - - with tempfile.NamedTemporaryFile(delete=False) as real_temp: - with patch('tempfile.NamedTemporaryFile') as mock_temp: - mock_temp.return_value.__enter__.return_value.name = real_temp.name - download_to_temp(self.mock_conn, self.mock_progress) + verify_file_checksum('dummy', md5_sum='a', sha256_sum='b') - # 验证只有有效chunk被写入 - with open(real_temp.name, 'rb') as f: - self.assertEqual(f.read(), b'valid') - os.unlink(real_temp.name) - -class TestMoveToTarget(unittest.TestCase): - - def setUp(self): - self.tmp_dir = tempfile.TemporaryDirectory() - self.target_path = os.path.join(self.tmp_dir.name, 'subdir/target.file') - - def tearDown(self): - self.tmp_dir.cleanup() - - def test_normal_move(self): - # 创建测试文件 - tmp_path = os.path.join(self.tmp_dir.name, 'test.tmp') - with open(tmp_path, 'wb') as f: - f.write(b'test content') - - move_to_target(tmp_path, self.target_path, 12) - - # 验证结果 - self.assertTrue(os.path.exists(self.target_path)) - self.assertFalse(os.path.exists(tmp_path)) - self.assertEqual(os.path.getsize(self.target_path), 12) - - def test_size_mismatch(self): - tmp_path = os.path.join(self.tmp_dir.name, 'bad.tmp') - with open(tmp_path, 'wb') as f: - f.write(b'short') - - with self.assertRaisesRegex(ModelResourceException, 'size mismatch'): - move_to_target(tmp_path, self.target_path, 100) - - def test_directory_creation(self): - tmp_path = os.path.join(self.tmp_dir.name, 'test.tmp') - with open(tmp_path, 'wb') as f: - f.write(b'content') - - # 目标目录不存在 - deep_path = os.path.join(self.tmp_dir.name, 'a/b/c/target.file') - move_to_target(tmp_path, deep_path, 7) - - self.assertTrue(os.path.exists(deep_path)) +class TestDownloadAutoFile(unittest.TestCase): + @patch( + 'llm_web_kit.model.resource_utils.download_assets.process_and_verify_file_with_lock' + ) + @patch('llm_web_kit.model.resource_utils.download_assets.verify_file_checksum') + @patch('llm_web_kit.model.resource_utils.download_assets.download_auto_file_core') + def test_download(self, mock_download, mock_verify, mock_process): + def download_func(resource_path, target_path): + dir = os.path.dirname(target_path) + os.makedirs(dir, exist_ok=True) + with open(target_path, 'w') as f: + time.sleep(1) + f.write(resource_path) + + mock_download.side_effect = download_func + + def verify_func(target_path, md5 ,sha): + with open(target_path, 'r') as f: + return f.read() == md5 + + mock_verify.side_effect = verify_func + + def process_and_verify( + process_func, verify_func, target_path, lock_suffix, timeout + ): + process_func() + if verify_func(): + return target_path + + mock_process.side_effect = process_and_verify + with tempfile.TemporaryDirectory() as tmpdir: + resource_url = 'http://example.com/resource' + target_dir = os.path.join(tmpdir, 'target') + result = download_auto_file(resource_url, target_dir, md5_sum=resource_url) + assert result == os.path.join(tmpdir, 'target') if __name__ == '__main__': diff --git a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py new file mode 100644 index 00000000..41adc698 --- /dev/null +++ b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py @@ -0,0 +1,209 @@ +import os +import shutil +import tempfile +import time +import unittest +from unittest.mock import Mock, patch + +from filelock import Timeout + +from llm_web_kit.model.resource_utils.process_with_lock import ( + get_path_mtime, process_and_verify_file_with_lock) + + +class TestGetPathMtime(unittest.TestCase): + """测试 get_path_mtime 函数.""" + + def setUp(self): + self.test_dir = 'test_dir' + self.test_file = 'test_file.txt' + os.makedirs(self.test_dir, exist_ok=True) + with open(self.test_file, 'w') as f: + f.write('test') + + def tearDown(self): + if os.path.exists(self.test_file): + os.remove(self.test_file) + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_file_mtime(self): + # 测试文件路径 + expected_mtime = os.path.getmtime(self.test_file) + result = get_path_mtime(self.test_file) + self.assertEqual(result, expected_mtime) + + def test_dir_with_files(self): + # 测试包含文件的目录 + file1 = os.path.join(self.test_dir, 'file1.txt') + file2 = os.path.join(self.test_dir, 'file2.txt') + + with open(file1, 'w') as f: + f.write('test1') + time.sleep(0.1) # 确保mtime不同 + with open(file2, 'w') as f: + f.write('test2') + + latest_mtime = max(os.path.getmtime(file1), os.path.getmtime(file2)) + result = get_path_mtime(self.test_dir) + self.assertEqual(result, latest_mtime) + + def test_empty_dir(self): + # 测试空目录(预期返回0) + empty_dir = 'empty_dir' + os.makedirs(empty_dir, exist_ok=True) + try: + result = get_path_mtime(empty_dir) + self.assertEqual(result, None) # 根据当前函数逻辑返回0 + finally: + shutil.rmtree(empty_dir) + + +class TestProcessAndVerifyFileWithLock(unittest.TestCase): + """测试 process_and_verify_file_with_lock 函数.""" + + def setUp(self): + self.target_path = 'target.txt' + self.lock_path = self.target_path + '.lock' + + def tearDown(self): + if os.path.exists(self.target_path): + os.remove(self.target_path) + if os.path.exists(self.lock_path): + os.remove(self.lock_path) + + @patch('os.path.exists') + @patch('llm_web_kit.model.resource_utils.process_with_lock.try_remove') + def test_target_exists_and_valid(self, mock_remove, mock_exists): + # 目标存在且验证成功 + mock_exists.side_effect = lambda path: path == self.target_path + process_func = Mock() + verify_func = Mock(return_value=True) + + result = process_and_verify_file_with_lock( + process_func, verify_func, self.target_path + ) + + self.assertEqual(result, self.target_path) + process_func.assert_not_called() + verify_func.assert_called_once() + + @patch('os.path.exists') + @patch('llm_web_kit.model.resource_utils.process_with_lock.try_remove') + @patch('time.sleep') + def test_target_not_exists_acquire_lock_success( + self, mock_sleep, mock_remove, mock_exists + ): + # 目标不存在,成功获取锁 + mock_exists.side_effect = lambda path: False + process_func = Mock(return_value=self.target_path) + verify_func = Mock() + + result = process_and_verify_file_with_lock( + process_func, verify_func, self.target_path + ) + + process_func.assert_called_once() + self.assertEqual(result, self.target_path) + + @patch('os.path.exists') + @patch('llm_web_kit.model.resource_utils.process_with_lock.try_remove') + @patch('time.sleep') + def test_second_validation_after_lock(self, mock_sleep, mock_remove, mock_exists): + # 获取锁后二次验证成功(其他进程已完成) + mock_exists.side_effect = lambda path: { + self.lock_path: False, + self.target_path: True, + } + verify_func = Mock(return_value=True) + process_func = Mock() + + result = process_and_verify_file_with_lock( + process_func, verify_func, self.target_path + ) + + process_func.assert_not_called() + self.assertEqual(result, self.target_path) + + @patch('os.path.exists') + @patch('llm_web_kit.model.resource_utils.process_with_lock.SoftFileLock') + @patch('time.sleep') + def test_lock_timeout_retry_success(self, mock_sleep, mock_lock, mock_exists): + # 第一次获取锁超时,重试后成功 + lock_str = self.target_path + '.lock' + mock_exists.return_value = False + lock_instance = Mock() + mock_lock.return_value = lock_instance + + # 第一次acquire抛出Timeout,第二次成功 + lock_instance.acquire.side_effect = [Timeout(lock_str), None] + process_func = Mock(return_value=self.target_path) + verify_func = Mock() + + process_and_verify_file_with_lock( + process_func, verify_func, self.target_path + ) + + self.assertEqual(lock_instance.acquire.call_count, 2) + process_func.assert_called_once() + + +class TestProcessWithLockRealFiles(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.target_name = 'test_target.dat' + self.lock_suffix = '.lock' + + self.target_path = os.path.join(self.temp_dir.name, self.target_name) + self.lock_path = self.target_path + self.lock_suffix + + def tearDown(self): + self.temp_dir.cleanup() + + def test_zombie_process_recovery(self): + # 准备过期文件和僵尸锁文件 + with open(self.target_path, 'w') as f: + f.write('old content') + with open(self.lock_path, 'w') as f: + f.write('lock') + + # 设置文件修改时间为超时前(60秒超时,设置为2分钟前) + old_mtime = time.time() - 59 + os.utime(self.target_path, (old_mtime, old_mtime)) + + # Mock验证函数和处理函数 + def verify_func(): + # 验证文件内容 + with open(self.target_path) as f: + content = f.read() + return content == 'new content' + + process_called = [False] # 使用list实现nonlocal效果 + + def real_process(): + # 真实写入文件 + with open(self.target_path, 'w') as f: + f.write('new content') + process_called[0] = True + return self.target_path + + # 执行测试 + result = process_and_verify_file_with_lock( + process_func=real_process, + verify_func=verify_func, + target_path=self.target_path, + lock_suffix=self.lock_suffix, + timeout=60, + ) + + # 验证结果 + self.assertTrue(os.path.exists(self.target_path)) + self.assertFalse(os.path.exists(self.lock_path)) + self.assertTrue(process_called[0]) + self.assertEqual(result, self.target_path) + + # 验证文件内容 + with open(self.target_path) as f: + content = f.read() + print(content) + self.assertEqual(content, 'new content') diff --git a/tests/llm_web_kit/model/resource_utils/test_resource_utils.py b/tests/llm_web_kit/model/resource_utils/test_resource_utils.py index 15448bcf..f6ed4a14 100644 --- a/tests/llm_web_kit/model/resource_utils/test_resource_utils.py +++ b/tests/llm_web_kit/model/resource_utils/test_resource_utils.py @@ -1,9 +1,6 @@ -import errno -import os -import unittest -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import patch -from llm_web_kit.model.resource_utils.utils import FileLockContext, try_remove +from llm_web_kit.model.resource_utils.utils import try_remove class Test_try_remove: @@ -18,125 +15,3 @@ def test_remove_exception(self, removeMock): removeMock.side_effect = Exception try_remove('path') removeMock.assert_called_once_with('path') - - -class TestFileLock(unittest.TestCase): - - def setUp(self): - self.lock_path = 'test.lock' - - @patch('os.fdopen') - @patch('os.open') - @patch('os.close') - @patch('os.remove') - def test_acquire_and_release_lock( - self, mock_remove, mock_close, mock_open, mock_os_fdopen - ): - # 模拟成功获取锁 - mock_open.return_value = 123 # 假设文件描述符为123 - # 模拟文件描述符 - mock_fd = MagicMock() - mock_fd.__enter__.return_value = mock_fd - mock_fd.write.return_value = None - mock_os_fdopen.return_value = mock_fd - - with FileLockContext(self.lock_path): - mock_open.assert_called_once_with( - self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644 - ) - mock_close.assert_called_once_with(123) - mock_remove.assert_called_once_with(self.lock_path) - - @patch('os.fdopen') - @patch('os.open') - @patch('builtins.open', new_callable=mock_open, read_data='1234\n100') - @patch('time.time') - @patch('os.remove') - def test_remove_stale_lock( - self, mock_remove, mock_time, mock_file_open, mock_os_open, mock_os_fdopen - ): - # 第一次尝试创建锁文件失败(锁已存在) - mock_os_open.side_effect = [ - OSError(errno.EEXIST, 'File exists'), - 123, # 第二次成功 - ] - - # 模拟文件描述符 - mock_fd = MagicMock() - mock_fd.__enter__.return_value = mock_fd - mock_fd.write.return_value = None - mock_os_fdopen.return_value = mock_fd - - # 当前时间设置为超过超时时间(timeout=300) - mock_time.return_value = 401 # 100 + 300 + 1 - - with FileLockContext(self.lock_path, timeout=300): - mock_remove.assert_called_once_with(self.lock_path) - mock_os_open.assert_any_call( - self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644 - ) - - @patch('os.open') - @patch('time.time') - def test_timeout_acquiring_lock(self, mock_time, mock_os_open): - # 总是返回EEXIST错误 - mock_os_open.side_effect = OSError(errno.EEXIST, 'File exists') - # 时间累计超过超时时间 - start_time = 1000 - mock_time.side_effect = [ - start_time, - start_time + 301, - start_time + 302, - start_time + 303, - ] - - with self.assertRaises(TimeoutError): - with FileLockContext(self.lock_path, timeout=300): - pass - - @patch('os.open') - def test_other_os_error(self, mock_os_open): - # 模拟其他OS错误(如权限不足) - mock_os_open.side_effect = OSError(errno.EACCES, 'Permission denied') - with self.assertRaises(OSError): - with FileLockContext(self.lock_path): - pass - - @patch('os.close') - @patch('os.remove') - def test_cleanup_on_exit(self, mock_remove, mock_close): - - mock_close.side_effect = None - # 确保退出上下文时执行清理 - lock_path = 'test.lock' - lock = FileLockContext(lock_path) - lock._fd = 123 # 模拟已打开的文件描述符 - lock.__exit__('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', None, None) - mock_remove.assert_called_once_with(self.lock_path) - - @patch('os.remove') - def test_cleanup_failure_handled(self, mock_remove): - # 模拟删除锁文件时失败 - mock_remove.side_effect = OSError - lock = FileLockContext(self.lock_path) - lock._fd = 123 - # 不应抛出异常 - lock.__exit__(None, None, None) - - @patch('os.getpid') - @patch('time.time') - def test_lock_file_content(self, mock_time, mock_pid): - # 验证锁文件内容格式 - mock_pid.return_value = 9999 - mock_time.return_value = 123456.789 - - with patch('os.open') as mock_os_open: - mock_os_open.return_value = 123 - with patch('os.fdopen') as mock_fdopen: - # 模拟写入文件描述符 - mock_file = MagicMock() - mock_fdopen.return_value.__enter__.return_value = mock_file - - with FileLockContext(self.lock_path): - mock_fdopen.assert_called_once_with(123, 'w') - mock_file.write.assert_called_once_with('9999\n123456.789') diff --git a/tests/llm_web_kit/model/resource_utils/test_unzip_ext.py b/tests/llm_web_kit/model/resource_utils/test_unzip_ext.py index bf514d14..4daeccd1 100644 --- a/tests/llm_web_kit/model/resource_utils/test_unzip_ext.py +++ b/tests/llm_web_kit/model/resource_utils/test_unzip_ext.py @@ -2,122 +2,153 @@ import tempfile import zipfile from unittest import TestCase +from unittest.mock import patch from llm_web_kit.exception.exception import ModelResourceException -from llm_web_kit.model.resource_utils.unzip_ext import (check_zip_file, +from llm_web_kit.model.resource_utils.unzip_ext import (check_zip_path, get_unzip_dir, - unzip_local_file) - - -def get_assert_dir(): - file_path = os.path.abspath(__file__) - assert_dir = os.path.join(os.path.dirname(os.path.dirname(file_path)), 'assets') - return assert_dir + unzip_local_file, + unzip_local_file_core) class TestGetUnzipDir(TestCase): - - def test_get_unzip_dir_case1(self): - assert get_unzip_dir('/path/to/test.zip') == '/path/to/test_unzip' - - def test_get_unzip_dir_case2(self): - assert get_unzip_dir('/path/to/test') == '/path/to/test_unzip' - - -class TestCheckZipFile(TestCase): - # # test_zip/ - # # ├── test1.txt "test1\n" - # # ├── folder1 - # # │ └── test2.txt "test2\n" - # # └── folder2 - - def get_zipfile(self): - # 创建一个临时文件夹 - zip_path = os.path.join(get_assert_dir(), 'zip_demo.zip') - zip_file = zipfile.ZipFile(zip_path, 'r') - return zip_file - - def test_check_zip_file_cese1(self): - zip_file = self.get_zipfile() - # # test_zip/ - # # ├── test1.txt - # # ├── folder1 - # # │ └── test2.txt - # # └── folder2 - - with tempfile.TemporaryDirectory() as temp_dir: - root_dir = os.path.join(temp_dir, 'test_zip') - os.makedirs(os.path.join(root_dir, 'test_zip')) - os.makedirs(os.path.join(root_dir, 'folder1')) - os.makedirs(os.path.join(root_dir, 'folder2')) - with open(os.path.join(root_dir, 'test1.txt'), 'w') as f: - f.write('test1\n') - with open(os.path.join(root_dir, 'folder1', 'test2.txt'), 'w') as f: - f.write('test2\n') - - assert check_zip_file(zip_file, temp_dir) is True - - def test_check_zip_file_cese2(self): - zip_file = self.get_zipfile() - with tempfile.TemporaryDirectory() as temp_dir: - root_dir = os.path.join(temp_dir, 'test_zip') - os.makedirs(os.path.join(root_dir, 'test_zip')) - os.makedirs(os.path.join(root_dir, 'folder1')) - with open(os.path.join(root_dir, 'test1.txt'), 'w') as f: - f.write('test1\n') - with open(os.path.join(root_dir, 'folder1', 'test2.txt'), 'w') as f: - f.write('test2\n') - - assert check_zip_file(zip_file, temp_dir) is True - - def test_check_zip_file_cese3(self): - zip_file = self.get_zipfile() - with tempfile.TemporaryDirectory() as temp_dir: - root_dir = os.path.join(temp_dir, 'test_zip') - os.makedirs(os.path.join(root_dir, 'test_zip')) - os.makedirs(os.path.join(root_dir, 'folder1')) - with open(os.path.join(root_dir, 'folder1', 'test2.txt'), 'w') as f: - f.write('test2\n') - - assert check_zip_file(zip_file, temp_dir) is False - - def test_check_zip_file_cese4(self): - zip_file = self.get_zipfile() - with tempfile.TemporaryDirectory() as temp_dir: - root_dir = os.path.join(temp_dir, 'test_zip') - os.makedirs(os.path.join(root_dir, 'test_zip')) - os.makedirs(os.path.join(root_dir, 'folder1')) - with open(os.path.join(root_dir, 'test1.txt'), 'w') as f: - f.write('test1\n') - with open(os.path.join(root_dir, 'folder1', 'test2.txt'), 'w') as f: - f.write('test123\n') - - assert check_zip_file(zip_file, temp_dir) is False - - -def test_unzip_local_file(): - # creat a temp dir to test - with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2: - # test unzip a zip file with 2 txt files - zip_path = os.path.join(temp_dir1, 'test.zip') - target_dir = os.path.join(temp_dir2, 'target') - # zip 2 txt files - with zipfile.ZipFile(zip_path, 'w') as zipf: - zipf.writestr('test1.txt', 'This is a test file') - zipf.writestr('test2.txt', 'This is another test file') - - unzip_local_file(zip_path, target_dir) - with open(os.path.join(target_dir, 'test1.txt')) as f: - assert f.read() == 'This is a test file' - with open(os.path.join(target_dir, 'test2.txt')) as f: - assert f.read() == 'This is another test file' - - unzip_local_file(zip_path, target_dir, exist_ok=True) - with open(os.path.join(target_dir, 'test1.txt')) as f: - assert f.read() == 'This is a test file' - with open(os.path.join(target_dir, 'test2.txt')) as f: - assert f.read() == 'This is another test file' - try: - unzip_local_file(zip_path, target_dir, exist_ok=False) - except ModelResourceException as e: - assert e.custom_message == f'Target directory {target_dir} already exists' + def test_get_unzip_dir_with_zip_extension(self): + self.assertEqual( + get_unzip_dir('/path/to/test.zip'), + '/path/to/test_unzip', + ) + + def test_get_unzip_dir_without_zip_extension(self): + self.assertEqual( + get_unzip_dir('/path/to/test'), + '/path/to/test_unzip', + ) + + +class TestCheckZipPath(TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.zip_path = os.path.join(self.temp_dir.name, 'test.zip') + self.target_dir = os.path.join(self.temp_dir.name, 'target') + os.makedirs(self.target_dir, exist_ok=True) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_check_valid_zip(self): + # Create valid zip with test file + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + # Properly extract files + with zipfile.ZipFile(self.zip_path, 'r') as zip_ref: + zip_ref.extractall(self.target_dir) + + self.assertTrue(check_zip_path(self.zip_path, self.target_dir)) + + def test_check_missing_file(self): + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + # Extract and then delete file + with zipfile.ZipFile(self.zip_path, 'r') as zip_ref: + zip_ref.extractall(self.target_dir) + os.remove(os.path.join(self.target_dir, 'file.txt')) + + self.assertFalse(check_zip_path(self.zip_path, self.target_dir)) + + def test_check_corrupted_file_size(self): + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'original content') + # Modify extracted file + with zipfile.ZipFile(self.zip_path, 'r') as zip_ref: + zip_ref.extractall(self.target_dir) + with open(os.path.join(self.target_dir, 'file.txt'), 'w') as f: + f.write('modified') + + self.assertFalse(check_zip_path(self.zip_path, self.target_dir)) + + def test_password_protected_zip(self): + password = 'secret' + # Create encrypted zip + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + zipf.setpassword(password.encode()) + # Extract with correct password + with zipfile.ZipFile(self.zip_path, 'r') as zip_ref: + zip_ref.setpassword(password.encode()) + zip_ref.extractall(self.target_dir) + + self.assertTrue( + check_zip_path(self.zip_path, self.target_dir, password=password) + ) + + +class TestUnzipLocalFileCore(TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.zip_path = os.path.join(self.temp_dir.name, 'test.zip') + self.target_dir = os.path.join(self.temp_dir.name, 'target') + + def tearDown(self): + self.temp_dir.cleanup() + + def test_nonexistent_zip_file(self): + with self.assertRaises(ModelResourceException) as cm: + unzip_local_file_core('invalid.zip', self.target_dir) + self.assertIn('does not exist', str(cm.exception)) + + def test_target_directory_conflict(self): + # Create target directory first + os.makedirs(self.target_dir) + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + + with self.assertRaises(ModelResourceException) as cm: + unzip_local_file_core(self.zip_path, self.target_dir) + self.assertIn('already exists', str(cm.exception)) + + def test_successful_extraction(self): + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + + result = unzip_local_file_core(self.zip_path, self.target_dir) + self.assertEqual(result, self.target_dir) + self.assertTrue(os.path.exists(os.path.join(self.target_dir, 'file.txt'))) + + def test_password_protected_extraction(self): + password = 'secret' + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + zipf.setpassword(password.encode()) + + unzip_local_file_core(self.zip_path, self.target_dir, password=password) + self.assertTrue(os.path.exists(os.path.join(self.target_dir, 'file.txt'))) + + +class TestUnzipLocalFile(TestCase): + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.zip_path = os.path.join(self.temp_dir.name, 'test.zip') + self.target_dir = os.path.join(self.temp_dir.name, 'target') + with zipfile.ZipFile(self.zip_path, 'w') as zipf: + zipf.writestr('file.txt', 'content') + + def tearDown(self): + self.temp_dir.cleanup() + + @patch( + 'llm_web_kit.model.resource_utils.unzip_ext.process_and_verify_file_with_lock' + ) + def test_unzip(self, mock_process): + + def process_and_verify( + process_func, verify_func, target_path, lock_suffix, timeout + ): + process_func() + if verify_func(): + return target_path + + mock_process.side_effect = process_and_verify + result = unzip_local_file(self.zip_path, self.target_dir) + self.assertEqual(result, self.target_dir) + self.assertTrue(os.path.exists(os.path.join(self.target_dir, 'file.txt'))) From 78669e2e28459d69d013e55cfd718ed1bd0a51df Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 16:03:54 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=8C=E4=BC=98=E5=8C=96=E9=94=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/resource_utils/process_with_lock.py | 1 - .../resource_utils/test_process_with_lock.py | 101 +++++++++++++++++- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/llm_web_kit/model/resource_utils/process_with_lock.py b/llm_web_kit/model/resource_utils/process_with_lock.py index ed904bdb..2064091e 100644 --- a/llm_web_kit/model/resource_utils/process_with_lock.py +++ b/llm_web_kit/model/resource_utils/process_with_lock.py @@ -50,7 +50,6 @@ def process_and_verify_file_with_lock( now = time.time() try: mtime = get_path_mtime(target_path) - print(f'now: {now}, mtime: {mtime}') if now - mtime < timeout: time.sleep(1) continue diff --git a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py index 41adc698..7f0091ea 100644 --- a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py +++ b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py @@ -1,8 +1,10 @@ +import multiprocessing import os import shutil import tempfile import time import unittest +from functools import partial from unittest.mock import Mock, patch from filelock import Timeout @@ -140,9 +142,7 @@ def test_lock_timeout_retry_success(self, mock_sleep, mock_lock, mock_exists): process_func = Mock(return_value=self.target_path) verify_func = Mock() - process_and_verify_file_with_lock( - process_func, verify_func, self.target_path - ) + process_and_verify_file_with_lock(process_func, verify_func, self.target_path) self.assertEqual(lock_instance.acquire.call_count, 2) process_func.assert_called_once() @@ -207,3 +207,98 @@ def real_process(): content = f.read() print(content) self.assertEqual(content, 'new content') + + +def dummy_process_func(target_path, data_content): + # 写入文件 + with open(target_path, 'w') as f: + time.sleep(1) + f.write(data_content) + return target_path + + +def dummy_verify_func(target_path, data_content): + # 验证文件内容 + with open(target_path) as f: + content = f.read() + return content == data_content + + +class TestMultiProcessWithLock(unittest.TestCase): + + def setUp(self): + # 临时文件夹 + self.temp_dir = tempfile.TemporaryDirectory() + self.target_name = 'test_target.dat' + self.lock_suffix = '.lock' + self.data_content = 'test content' + self.target_path = os.path.join(self.temp_dir.name, self.target_name) + self.lock_path = self.target_path + self.lock_suffix + + def tearDown(self): + self.temp_dir.cleanup() + + # 开始时什么都没有,多个进程尝试拿锁,一个进程拿到锁并用1s写入一个资源文件(指定内容,verify尝试检查)。然后所有的进程都发现这个文件被写入,成功返回。资源文件存在,并且锁文件被删掉 + def test_multi_process_with_lock(self): + process_func = partial(dummy_process_func, self.target_path, self.data_content) + verify_func = partial(dummy_verify_func, self.target_path, self.data_content) + + # 多进程同时执行 + # 构建多个进程 然后同时执行 + pool = multiprocessing.Pool(16) + process = partial( + process_and_verify_file_with_lock, + process_func, + verify_func, + self.target_path, + self.lock_suffix, + ) + results = pool.map(process, [60] * 32) + pool.close() + # 检查文件是否存在 + self.assertTrue(os.path.exists(self.target_path)) + self.assertFalse(os.path.exists(self.lock_path)) + # 检查结果 + for result in results: + self.assertEqual(result, self.target_path) + # 检查文件内容 + with open(self.target_path) as f: + content = f.read() + self.assertEqual(content, self.data_content) + + # 开始时有个文件,这个文件mtime比较早,且有个锁文件(模拟之前下载失败了)。然后同样多进程尝试执行,有某个进程尝试删掉这个文件和锁文件,然后还原为场景1,最终大家成功返回。 + def test_multi_process_with_zombie_files(self): + # 准备过期文件和僵尸锁文件 + with open(self.target_path, 'w') as f: + f.write('old content') + with open(self.lock_path, 'w') as f: + f.write('lock') + + # 设置文件修改时间为超时前(60秒超时,设置为2分钟前) + old_mtime = time.time() - 59 + os.utime(self.target_path, (old_mtime, old_mtime)) + + process_func = partial(dummy_process_func, self.target_path, self.data_content) + verify_func = partial(dummy_verify_func, self.target_path, self.data_content) + + # 多进程同时执行 + pool = multiprocessing.Pool(16) + process = partial( + process_and_verify_file_with_lock, + process_func, + verify_func, + self.target_path, + self.lock_suffix, + ) + results = pool.map(process, [60] * 32) + pool.close() + # 检查文件是否存在 + self.assertTrue(os.path.exists(self.target_path)) + self.assertFalse(os.path.exists(self.lock_path)) + # 检查结果 + for result in results: + self.assertEqual(result, self.target_path) + # 检查文件内容 + with open(self.target_path) as f: + content = f.read() + self.assertEqual(content, self.data_content) From 6916243fd969962e59004392b0c5c573a7c7ae4e Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 17:31:24 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E7=BC=93=E5=AD=98=E7=9B=AE=E5=BD=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=92=8C=E8=A7=A3=E5=8E=8B=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/resource_utils/download_assets.py | 12 +++++++++++- llm_web_kit/model/resource_utils/unzip_ext.py | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index dc2094b5..054789b6 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -41,6 +41,14 @@ def decide_cache_dir(): CACHE_DIR = decide_cache_dir() +CACHE_TMP_DIR = os.path.join(CACHE_DIR, 'tmp') + + +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + +if not os.path.exists(CACHE_TMP_DIR): + os.makedirs(CACHE_TMP_DIR) def calc_file_md5(file_path: str) -> str: @@ -157,7 +165,7 @@ def download_auto_file_core( logger.info(f'Downloading {resource_path} => {target_path}') progress = tqdm(total=total_size, unit='iB', unit_scale=True) - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(dir=CACHE_TMP_DIR) as temp_dir: download_path = os.path.join(temp_dir, 'download_file') try: download_to_temp(conn, progress, download_path) @@ -167,6 +175,8 @@ def download_auto_file_core( ) os.makedirs(os.path.dirname(target_path), exist_ok=True) + print(f'download_path: {download_path}') + print(f'target_path: {target_path}') os.rename(download_path, target_path) return target_path diff --git a/llm_web_kit/model/resource_utils/unzip_ext.py b/llm_web_kit/model/resource_utils/unzip_ext.py index 50c4e363..f687288f 100644 --- a/llm_web_kit/model/resource_utils/unzip_ext.py +++ b/llm_web_kit/model/resource_utils/unzip_ext.py @@ -6,6 +6,7 @@ from llm_web_kit.exception.exception import ModelResourceException from llm_web_kit.libs.logger import mylogger as logger +from llm_web_kit.model.resource_utils.download_assets import CACHE_TMP_DIR from llm_web_kit.model.resource_utils.process_with_lock import \ process_and_verify_file_with_lock @@ -82,7 +83,7 @@ def unzip_local_file_core( with zipfile.ZipFile(zip_path, 'r') as zip_ref: if password: zip_ref.setpassword(password.encode()) - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(dir=CACHE_TMP_DIR) as temp_dir: extract_dir = os.path.join(temp_dir, 'temp') os.makedirs(extract_dir, exist_ok=True) zip_ref.extractall(extract_dir) From c859c87d91e6273e700a1fc3859a1301d5950401 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 21:44:55 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8A=A0=E8=BD=BD=E5=92=8C=E4=B8=8B=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=BC=93=E5=AD=98=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/policical.py | 24 ++++++++++++++----- .../model/resource_utils/download_assets.py | 3 ++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/llm_web_kit/model/policical.py b/llm_web_kit/model/policical.py index a46e933d..0cc72f71 100644 --- a/llm_web_kit/model/policical.py +++ b/llm_web_kit/model/policical.py @@ -25,7 +25,9 @@ def __init__(self, model_path: str = None): tokenizer_path = os.path.join(model_path, 'internlm2-chat-20b') self.model = fasttext.load_model(model_bin_path) - self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path, use_fast=False, trust_remote_code=True) + self.tokenizer = AutoTokenizer.from_pretrained( + tokenizer_path, use_fast=False, trust_remote_code=True, cache_dir=CACHE_DIR + ) def auto_download(self): """Default download the 24m7.zip model.""" @@ -46,7 +48,9 @@ def auto_download(self): if not os.path.exists(zip_path): logger.info(f'zip_path: {zip_path} does not exist') logger.info(f'downloading {political_24m7_s3}') - zip_path = download_auto_file(political_24m7_s3, zip_path, political_24m7_md5) + zip_path = download_auto_file( + political_24m7_s3, zip_path, political_24m7_md5 + ) logger.info(f'unzipping {zip_path}') unzip_path = unzip_local_file(zip_path, unzip_path) return unzip_path @@ -54,7 +58,9 @@ def auto_download(self): def predict(self, text: str) -> Tuple[str, float]: text = text.replace('\n', ' ') input_ids = self.tokenizer(text)['input_ids'] - predictions, probabilities = self.model.predict(' '.join([str(i) for i in input_ids]), k=-1) + predictions, probabilities = self.model.predict( + ' '.join([str(i) for i in input_ids]), k=-1 + ) return predictions, probabilities @@ -77,13 +83,17 @@ def get_singleton_political_detect() -> PoliticalDetector: return singleton_resource_manager.get_resource('political_detect') -def decide_political_by_prob(predictions: Tuple[str], probabilities: Tuple[float]) -> float: +def decide_political_by_prob( + predictions: Tuple[str], probabilities: Tuple[float] +) -> float: idx = predictions.index('__label__normal') normal_score = probabilities[idx] return normal_score -def decide_political_func(content_str: str, political_detect: PoliticalDetector) -> float: +def decide_political_func( + content_str: str, political_detect: PoliticalDetector +) -> float: # Limit the length of the content to 2560000 content_str = content_str[:2560000] predictions, probabilities = political_detect.predict(content_str) @@ -111,7 +121,9 @@ def political_filter_cpu(data_dict: Dict[str, Any], language: str): test_cases.append('hello, nice to meet you!') test_cases.append('你好,唔該幫我一個忙?') test_cases.append('Bawo ni? Mo nife Yoruba. ') - test_cases.append('你好,我很高兴见到你,请多多指教!你今天吃饭了吗?hello, nice to meet you!') + test_cases.append( + '你好,我很高兴见到你,请多多指教!你今天吃饭了吗?hello, nice to meet you!' + ) test_cases.append('איך בין אַ גרויסער פֿאַן פֿון די וויסנשאַפֿט. מיר האָבן פֿיל צו לערנען.') test_cases.append('გამარჯობა, როგორ ხარ? მე ვარ კარგად, მადლობა.') test_cases.append('გამარჯობა, როგორ ხართ? ეს ჩემი ქვეყანაა, საქართველო.') diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index 054789b6..605cb39c 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -42,7 +42,8 @@ def decide_cache_dir(): CACHE_DIR = decide_cache_dir() CACHE_TMP_DIR = os.path.join(CACHE_DIR, 'tmp') - +# TODO set environment variable for huggingface cache in other places +os.environ['HF_HOME'] = CACHE_DIR if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) From 26b0a46e04517d9a5ca599d852fbf5e55257b59d Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 21:50:17 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AEHF=5FHOME?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?Hugging=20Face=E7=BC=93=E5=AD=98=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/policical.py | 5 ++++- llm_web_kit/model/resource_utils/download_assets.py | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/llm_web_kit/model/policical.py b/llm_web_kit/model/policical.py index 0cc72f71..1e55dcca 100644 --- a/llm_web_kit/model/policical.py +++ b/llm_web_kit/model/policical.py @@ -2,7 +2,6 @@ from typing import Any, Dict, Tuple import fasttext -from transformers import AutoTokenizer from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.exception.exception import ModelInputException @@ -19,6 +18,10 @@ class PoliticalDetector: def __init__(self, model_path: str = None): + # import AutoTokenizer here to avoid isort error + # must set the HF_HOME to the CACHE_DIR at this point + os.environ['HF_HOME'] = CACHE_DIR + from transformers import AutoTokenizer if not model_path: model_path = self.auto_download() model_bin_path = os.path.join(model_path, 'model.bin') diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index 605cb39c..054789b6 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -42,8 +42,7 @@ def decide_cache_dir(): CACHE_DIR = decide_cache_dir() CACHE_TMP_DIR = os.path.join(CACHE_DIR, 'tmp') -# TODO set environment variable for huggingface cache in other places -os.environ['HF_HOME'] = CACHE_DIR + if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) From d54b4f3d3a0fd7df91aa02d7d3496f829c1abb09 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Fri, 7 Mar 2025 22:01:15 +0800 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=9D=E5=A7=8B=E5=8C=96=E4=B8=AD=E7=9A=84tokenizer?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=BC=95=E7=94=A8=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/policical.py | 2 +- tests/llm_web_kit/model/test_political.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/llm_web_kit/model/policical.py b/llm_web_kit/model/policical.py index 1e55dcca..1733a038 100644 --- a/llm_web_kit/model/policical.py +++ b/llm_web_kit/model/policical.py @@ -29,7 +29,7 @@ def __init__(self, model_path: str = None): self.model = fasttext.load_model(model_bin_path) self.tokenizer = AutoTokenizer.from_pretrained( - tokenizer_path, use_fast=False, trust_remote_code=True, cache_dir=CACHE_DIR + tokenizer_path, use_fast=False, trust_remote_code=True ) def auto_download(self): diff --git a/tests/llm_web_kit/model/test_political.py b/tests/llm_web_kit/model/test_political.py index 3be90563..54cc3437 100644 --- a/tests/llm_web_kit/model/test_political.py +++ b/tests/llm_web_kit/model/test_political.py @@ -21,7 +21,7 @@ class TestPoliticalDetector: - @patch('llm_web_kit.model.policical.AutoTokenizer.from_pretrained') + @patch('transformers.AutoTokenizer.from_pretrained') @patch('llm_web_kit.model.policical.fasttext.load_model') @patch('llm_web_kit.model.policical.PoliticalDetector.auto_download') def test_init(self, mock_auto_download, mock_load_model, mock_auto_tokenizer): @@ -46,7 +46,7 @@ def test_init(self, mock_auto_download, mock_load_model, mock_auto_tokenizer): trust_remote_code=True, ) - @patch('llm_web_kit.model.policical.AutoTokenizer.from_pretrained') + @patch('transformers.AutoTokenizer.from_pretrained') @patch('llm_web_kit.model.policical.fasttext.load_model') @patch('llm_web_kit.model.policical.PoliticalDetector.auto_download') def test_predict(self, mock_auto_download, mock_load_model, mock_auto_tokenizer): From a43ce1a7fb1ff168890db888c9003c61d02d7fd9 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 10:46:00 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=AE=A1=E7=90=86=EF=BC=8C=E7=BB=9F=E4=B8=80CACHE=5FD?= =?UTF-8?q?IR=E7=9A=84=E5=BC=95=E7=94=A8=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/code_detector.py | 18 +++++--- llm_web_kit/model/html_layout_cls.py | 4 +- llm_web_kit/model/lang_id.py | 4 +- llm_web_kit/model/policical.py | 4 +- llm_web_kit/model/porn_detector.py | 4 +- llm_web_kit/model/quality_model.py | 4 +- .../model/resource_utils/download_assets.py | 36 +-------------- llm_web_kit/model/resource_utils/utils.py | 37 ++++++++++++++++ llm_web_kit/model/unsafe_words_detector.py | 4 +- .../resource_utils/test_download_assets.py | 35 +-------------- .../resource_utils/test_resource_utils.py | 44 ++++++++++++++++++- tests/llm_web_kit/model/test_quality_model.py | 3 +- 12 files changed, 108 insertions(+), 89 deletions(-) diff --git a/llm_web_kit/model/code_detector.py b/llm_web_kit/model/code_detector.py index dac7dd4c..74d47533 100644 --- a/llm_web_kit/model/code_detector.py +++ b/llm_web_kit/model/code_detector.py @@ -7,12 +7,12 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.singleton_resource_manager import \ singleton_resource_manager from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, unzip_local_file) +from llm_web_kit.model.resource_utils.utils import CACHE_DIR class CodeClassification: @@ -139,13 +139,17 @@ def decide_code_func(content_str: str, code_detect: CodeClassification) -> float if str_len > 10000: logger.warning('Content string is too long, truncate to 10000 characters') start_idx = (str_len - 10000) // 2 - content_str = content_str[start_idx:start_idx + 10000] + content_str = content_str[start_idx : start_idx + 10000] # check if the content string contains latex environment if detect_latex_env(content_str): - logger.warning('Content string contains latex environment, may be misclassified') + logger.warning( + 'Content string contains latex environment, may be misclassified' + ) - def decide_code_by_prob_v3(predictions: Tuple[str], probabilities: Tuple[float]) -> float: + def decide_code_by_prob_v3( + predictions: Tuple[str], probabilities: Tuple[float] + ) -> float: idx = predictions.index('__label__1') true_prob = probabilities[idx] return true_prob @@ -154,7 +158,9 @@ def decide_code_by_prob_v3(predictions: Tuple[str], probabilities: Tuple[float]) predictions, probabilities = code_detect.predict(content_str) result = decide_code_by_prob_v3(predictions, probabilities) else: - raise ValueError(f'Unsupported version: {code_detect.version}. Supported versions: {[CODE_CL_SUPPORTED_VERSIONS]}') + raise ValueError( + f'Unsupported version: {code_detect.version}. Supported versions: {[CODE_CL_SUPPORTED_VERSIONS]}' + ) return result diff --git a/llm_web_kit/model/html_layout_cls.py b/llm_web_kit/model/html_layout_cls.py index 8f566709..7ab35c92 100644 --- a/llm_web_kit/model/html_layout_cls.py +++ b/llm_web_kit/model/html_layout_cls.py @@ -4,10 +4,10 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger from llm_web_kit.model.html_classify.model import Markuplm -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, unzip_local_file) +from llm_web_kit.model.resource_utils.utils import CACHE_DIR class HTMLLayoutClassifier: diff --git a/llm_web_kit/model/lang_id.py b/llm_web_kit/model/lang_id.py index a2e898ce..5aa7d1d7 100644 --- a/llm_web_kit/model/lang_id.py +++ b/llm_web_kit/model/lang_id.py @@ -6,10 +6,10 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.singleton_resource_manager import \ singleton_resource_manager +from llm_web_kit.model.resource_utils.utils import CACHE_DIR language_dict = { 'srp': 'sr', 'swe': 'sv', 'dan': 'da', 'ita': 'it', 'spa': 'es', 'pes': 'fa', 'slk': 'sk', 'hun': 'hu', 'bul': 'bg', 'cat': 'ca', diff --git a/llm_web_kit/model/policical.py b/llm_web_kit/model/policical.py index 1733a038..7f07545d 100644 --- a/llm_web_kit/model/policical.py +++ b/llm_web_kit/model/policical.py @@ -7,12 +7,12 @@ from llm_web_kit.exception.exception import ModelInputException from llm_web_kit.input.datajson import DataJson from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.singleton_resource_manager import \ singleton_resource_manager from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, unzip_local_file) +from llm_web_kit.model.resource_utils.utils import CACHE_DIR class PoliticalDetector: diff --git a/llm_web_kit/model/porn_detector.py b/llm_web_kit/model/porn_detector.py index ae3df185..e6429757 100644 --- a/llm_web_kit/model/porn_detector.py +++ b/llm_web_kit/model/porn_detector.py @@ -7,10 +7,10 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, unzip_local_file) +from llm_web_kit.model.resource_utils.utils import CACHE_DIR class BertModel(): diff --git a/llm_web_kit/model/quality_model.py b/llm_web_kit/model/quality_model.py index 837c91ea..0d0c84af 100644 --- a/llm_web_kit/model/quality_model.py +++ b/llm_web_kit/model/quality_model.py @@ -19,10 +19,10 @@ stats_html_entity, stats_ngram_mini, stats_punctuation_end_sentence, stats_stop_words, stats_unicode) from llm_web_kit.model.basic_functions.utils import div_zero -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, unzip_local_file) +from llm_web_kit.model.resource_utils.utils import CACHE_DIR _global_quality_model = {} _model_resource_map = { diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index 054789b6..4b61bbc6 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -7,7 +7,6 @@ import requests from tqdm import tqdm -from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.exception.exception import ModelResourceException from llm_web_kit.libs.logger import mylogger as logger from llm_web_kit.model.resource_utils.boto3_ext import (get_s3_client, @@ -15,40 +14,7 @@ split_s3_path) from llm_web_kit.model.resource_utils.process_with_lock import \ process_and_verify_file_with_lock - - -def decide_cache_dir(): - """Get the cache directory for the web kit. The. - - Returns: - _type_: _description_ - """ - cache_dir = '~/.llm_web_kit_cache' - - if 'WEB_KIT_CACHE_DIR' in os.environ: - cache_dir = os.environ['WEB_KIT_CACHE_DIR'] - - try: - config = load_config() - cache_dir = config['resources']['common']['cache_path'] - except Exception: - pass - - if cache_dir.startswith('~/'): - cache_dir = os.path.expanduser(cache_dir) - - return cache_dir - - -CACHE_DIR = decide_cache_dir() -CACHE_TMP_DIR = os.path.join(CACHE_DIR, 'tmp') - - -if not os.path.exists(CACHE_DIR): - os.makedirs(CACHE_DIR) - -if not os.path.exists(CACHE_TMP_DIR): - os.makedirs(CACHE_TMP_DIR) +from llm_web_kit.model.resource_utils.utils import CACHE_TMP_DIR def calc_file_md5(file_path: str) -> str: diff --git a/llm_web_kit/model/resource_utils/utils.py b/llm_web_kit/model/resource_utils/utils.py index 961a6275..9f09f99c 100644 --- a/llm_web_kit/model/resource_utils/utils.py +++ b/llm_web_kit/model/resource_utils/utils.py @@ -1,6 +1,43 @@ import os import shutil +from llm_web_kit.config.cfg_reader import load_config + + +def decide_cache_dir(): + """Get the cache directory for the web kit. The. + + Returns: + _type_: _description_ + """ + cache_dir = '~/.llm_web_kit_cache' + + if 'WEB_KIT_CACHE_DIR' in os.environ: + cache_dir = os.environ['WEB_KIT_CACHE_DIR'] + + try: + config = load_config() + cache_dir = config['resources']['common']['cache_path'] + except Exception: + pass + + if cache_dir.startswith('~/'): + cache_dir = os.path.expanduser(cache_dir) + + cache_tmp_dir = os.path.join(cache_dir, 'tmp') + + return cache_dir, cache_tmp_dir + + +CACHE_DIR, CACHE_TMP_DIR = decide_cache_dir() + + +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) + +if not os.path.exists(CACHE_TMP_DIR): + os.makedirs(CACHE_TMP_DIR) + def try_remove(path: str): """Attempt to remove a file by os.remove or to remove a directory by diff --git a/llm_web_kit/model/unsafe_words_detector.py b/llm_web_kit/model/unsafe_words_detector.py index 8e336d9f..beb08bdc 100644 --- a/llm_web_kit/model/unsafe_words_detector.py +++ b/llm_web_kit/model/unsafe_words_detector.py @@ -10,10 +10,10 @@ from llm_web_kit.libs.standard_utils import json_loads from llm_web_kit.model.basic_functions.format_check import (is_en_letter, is_pure_en_word) -from llm_web_kit.model.resource_utils.download_assets import ( - CACHE_DIR, download_auto_file) +from llm_web_kit.model.resource_utils.download_assets import download_auto_file from llm_web_kit.model.resource_utils.singleton_resource_manager import \ singleton_resource_manager +from llm_web_kit.model.resource_utils.utils import CACHE_DIR xyz_language_lst = [ 'ar', diff --git a/tests/llm_web_kit/model/resource_utils/test_download_assets.py b/tests/llm_web_kit/model/resource_utils/test_download_assets.py index ed7372dd..907edfc6 100644 --- a/tests/llm_web_kit/model/resource_utils/test_download_assets.py +++ b/tests/llm_web_kit/model/resource_utils/test_download_assets.py @@ -8,39 +8,8 @@ from llm_web_kit.exception.exception import ModelResourceException from llm_web_kit.model.resource_utils.download_assets import ( HttpConnection, S3Connection, calc_file_md5, calc_file_sha256, - decide_cache_dir, download_auto_file, download_auto_file_core, - download_to_temp, verify_file_checksum) - - -class TestDecideCacheDir: - - @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) - @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_only_env(self, get_config_mock): - get_config_mock.side_effect = Exception - assert decide_cache_dir() == '/env/cache_dir' - - @patch('os.environ', {}) - @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_only_config(self, get_config_mock): - get_config_mock.return_value = { - 'resources': {'common': {'cache_path': '/config/cache_dir'}} - } - assert decide_cache_dir() == '/config/cache_dir' - - @patch('os.environ', {}) - @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_default(self, get_config_mock): - get_config_mock.side_effect = Exception - assert decide_cache_dir() == os.path.expanduser('~/.llm_web_kit_cache') - - @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) - @patch('llm_web_kit.model.resource_utils.download_assets.load_config') - def test_priority(self, get_config_mock): - get_config_mock.return_value = { - 'resources': {'common': {'cache_path': '/config/cache_dir'}} - } - assert decide_cache_dir() == '/config/cache_dir' + download_auto_file, download_auto_file_core, download_to_temp, + verify_file_checksum) class TestChecksumCalculations: diff --git a/tests/llm_web_kit/model/resource_utils/test_resource_utils.py b/tests/llm_web_kit/model/resource_utils/test_resource_utils.py index f6ed4a14..0206e631 100644 --- a/tests/llm_web_kit/model/resource_utils/test_resource_utils.py +++ b/tests/llm_web_kit/model/resource_utils/test_resource_utils.py @@ -1,6 +1,7 @@ +import os from unittest.mock import patch -from llm_web_kit.model.resource_utils.utils import try_remove +from llm_web_kit.model.resource_utils.utils import decide_cache_dir, try_remove class Test_try_remove: @@ -15,3 +16,44 @@ def test_remove_exception(self, removeMock): removeMock.side_effect = Exception try_remove('path') removeMock.assert_called_once_with('path') + + +class TestDecideCacheDir: + + @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) + @patch('llm_web_kit.model.resource_utils.utils.load_config') + def test_only_env(self, get_config_mock): + get_config_mock.side_effect = Exception + cache_dir, cache_tmp_dir = decide_cache_dir() + assert cache_dir == '/env/cache_dir' + assert cache_tmp_dir == '/env/cache_dir/tmp' + + @patch('os.environ', {}) + @patch('llm_web_kit.model.resource_utils.utils.load_config') + def test_only_config(self, get_config_mock): + get_config_mock.return_value = { + 'resources': {'common': {'cache_path': '/config/cache_dir'}} + } + + cache_dir, cache_tmp_dir = decide_cache_dir() + assert cache_dir == '/config/cache_dir' + assert cache_tmp_dir == '/config/cache_dir/tmp' + + @patch('os.environ', {}) + @patch('llm_web_kit.model.resource_utils.utils.load_config') + def test_default(self, get_config_mock): + get_config_mock.side_effect = Exception + env_result = os.path.expanduser('~/.llm_web_kit_cache') + cache_dir, cache_tmp_dir = decide_cache_dir() + assert cache_dir == env_result + assert cache_tmp_dir == f'{env_result}/tmp' + + @patch('os.environ', {'WEB_KIT_CACHE_DIR': '/env/cache_dir'}) + @patch('llm_web_kit.model.resource_utils.utils.load_config') + def test_priority(self, get_config_mock): + get_config_mock.return_value = { + 'resources': {'common': {'cache_path': '/config/cache_dir'}} + } + cache_dir, cache_tmp_dir = decide_cache_dir() + assert cache_dir == '/config/cache_dir' + assert cache_tmp_dir == '/config/cache_dir/tmp' diff --git a/tests/llm_web_kit/model/test_quality_model.py b/tests/llm_web_kit/model/test_quality_model.py index 4a879eec..4172be95 100644 --- a/tests/llm_web_kit/model/test_quality_model.py +++ b/tests/llm_web_kit/model/test_quality_model.py @@ -9,8 +9,7 @@ from llm_web_kit.model.quality_model import get_quality_model # noqa: E402 from llm_web_kit.model.quality_model import quality_prober # noqa: E402 from llm_web_kit.model.quality_model import QualityFilter -from llm_web_kit.model.resource_utils.download_assets import \ - CACHE_DIR # noqa: E402 +from llm_web_kit.model.resource_utils.utils import CACHE_DIR current_file_path = os.path.abspath(__file__) parent_dir_path = os.path.join(current_file_path, *[os.pardir] * 4) From ae5ba225ee7c8604f6973308698ed32c853f22a6 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 10:56:47 +0800 Subject: [PATCH 08/14] =?UTF-8?q?doc:=20=E4=B8=BA=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=B7=BB=E5=8A=A0=E6=B3=A8=E9=87=8A=E5=92=8C?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/resource_utils/download_assets.py | 183 ++++++++++++------ 1 file changed, 127 insertions(+), 56 deletions(-) diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index 4b61bbc6..280b9676 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -1,3 +1,23 @@ +"""本模块提供从 S3 或 HTTP 下载文件的功能,支持校验和验证和并发下载锁机制。 + +主要功能: +1. 计算文件的 MD5 和 SHA256 校验和 +2. 通过 S3 或 HTTP 连接下载文件 +3. 使用文件锁防止并发下载冲突 +4. 自动校验文件完整性 + +类说明: +- Connection: 抽象基类,定义下载连接接口 +- S3Connection: 实现 S3 文件下载连接 +- HttpConnection: 实现 HTTP 文件下载连接 + +函数说明: +- calc_file_md5/sha256: 计算文件哈希值 +- verify_file_checksum: 校验文件哈希 +- download_auto_file_core: 核心下载逻辑 +- download_auto_file: 自动下载入口函数(含锁机制) +""" + import hashlib import os import tempfile @@ -18,33 +38,88 @@ def calc_file_md5(file_path: str) -> str: - """Calculate the MD5 checksum of a file.""" + """计算文件的 MD5 校验和. + + Args: + file_path: 文件路径 + + Returns: + MD5 哈希字符串(32位十六进制) + """ with open(file_path, 'rb') as f: return hashlib.md5(f.read()).hexdigest() def calc_file_sha256(file_path: str) -> str: - """Calculate the sha256 checksum of a file.""" + """计算文件的 SHA256 校验和. + + Args: + file_path: 文件路径 + + Returns: + SHA256 哈希字符串(64位十六进制) + """ with open(file_path, 'rb') as f: return hashlib.sha256(f.read()).hexdigest() -class Connection: +def verify_file_checksum( + file_path: str, md5_sum: Optional[str] = None, sha256_sum: Optional[str] = None +) -> bool: + """验证文件的 MD5 或 SHA256 校验和. + + Args: + file_path: 待验证文件路径 + md5_sum: 预期 MD5 值(与 sha256_sum 二选一) + sha256_sum: 预期 SHA256 值(与 md5_sum 二选一) + + Returns: + bool: 校验是否通过 - def __init__(self, *args, **kwargs): - pass + Raises: + ModelResourceException: 当未提供或同时提供两个校验和时 + """ + if not (bool(md5_sum) ^ bool(sha256_sum)): + raise ModelResourceException( + 'Exactly one of md5_sum or sha256_sum must be provided' + ) + + if md5_sum: + actual = calc_file_md5(file_path) + if actual != md5_sum: + logger.warning( + f'MD5 mismatch: expect {md5_sum[:8]}..., got {actual[:8]}...' + ) + return False + + if sha256_sum: + actual = calc_file_sha256(file_path) + if actual != sha256_sum: + logger.warning( + f'SHA256 mismatch: expect {sha256_sum[:8]}..., got {actual[:8]}...' + ) + return False + + return True + + +class Connection: + """下载连接的抽象基类.""" def get_size(self) -> int: + """获取文件大小(字节)""" raise NotImplementedError def read_stream(self) -> Iterable[bytes]: + """返回数据流的迭代器.""" raise NotImplementedError class S3Connection(Connection): + """S3 文件下载连接.""" def __init__(self, resource_path: str): - super().__init__(resource_path) + super().__init__() self.client = get_s3_client(resource_path) self.bucket, self.key = split_s3_path(resource_path) self.obj = self.client.get_object(Bucket=self.bucket, Key=self.key) @@ -58,13 +133,15 @@ def read_stream(self) -> Iterable[bytes]: yield chunk def __del__(self): - self.obj['Body'].close() + if hasattr(self, 'obj') and 'Body' in self.obj: + self.obj['Body'].close() class HttpConnection(Connection): + """HTTP 文件下载连接.""" def __init__(self, resource_path: str): - super().__init__(resource_path) + super().__init__() self.response = requests.get(resource_path, stream=True) self.response.raise_for_status() @@ -77,39 +154,18 @@ def read_stream(self) -> Iterable[bytes]: yield chunk def __del__(self): - self.response.close() - - -def verify_file_checksum( - file_path: str, md5_sum: Optional[str] = None, sha256_sum: Optional[str] = None -) -> bool: - """校验文件哈希值.""" - if not sum([bool(md5_sum), bool(sha256_sum)]) == 1: - raise ModelResourceException( - 'Exactly one of md5_sum or sha256_sum must be provided' - ) - - if md5_sum: - actual = calc_file_md5(file_path) - if actual != md5_sum: - logger.warning( - f'MD5 mismatch: expect {md5_sum[:8]}..., got {actual[:8]}...' - ) - return False - - if sha256_sum: - actual = calc_file_sha256(file_path) - if actual != sha256_sum: - logger.warning( - f'SHA256 mismatch: expect {sha256_sum[:8]}..., got {actual[:8]}...' - ) - return False + if hasattr(self, 'response'): + self.response.close() - return True +def download_to_temp(conn: Connection, progress_bar: tqdm, download_path: str): + """下载文件到临时目录. -def download_to_temp(conn, progress_bar, download_path): - """下载到临时文件.""" + Args: + conn: 下载连接 + progress_bar: 进度条 + download_path: 临时文件路径 + """ with open(download_path, 'wb') as f: for chunk in conn.read_stream(): @@ -122,31 +178,44 @@ def download_auto_file_core( resource_path: str, target_path: str, ) -> str: - # 创建连接 + """下载文件的核心逻辑(无锁) + + Args: + resource_path: 源文件路径(S3或HTTP URL) + target_path: 目标保存路径 + + Returns: + 下载后的文件路径 + + Raises: + ModelResourceException: 下载失败或文件大小不匹配时 + """ + # 初始化连接 conn_cls = S3Connection if is_s3_path(resource_path) else HttpConnection conn = conn_cls(resource_path) total_size = conn.get_size() - # 下载流程 + # 配置进度条 logger.info(f'Downloading {resource_path} => {target_path}') progress = tqdm(total=total_size, unit='iB', unit_scale=True) + # 使用临时目录确保原子性 with tempfile.TemporaryDirectory(dir=CACHE_TMP_DIR) as temp_dir: download_path = os.path.join(temp_dir, 'download_file') try: download_to_temp(conn, progress, download_path) - if not total_size == os.path.getsize(download_path): + + # 验证文件大小 + actual_size = os.path.getsize(download_path) + if total_size != actual_size: raise ModelResourceException( - f'Downloaded {resource_path} to {download_path}, but size mismatch' + f'Size mismatch: expected {total_size}, got {actual_size}' ) + # 移动到目标路径 os.makedirs(os.path.dirname(target_path), exist_ok=True) - print(f'download_path: {download_path}') - print(f'target_path: {target_path}') - os.rename(download_path, target_path) - + os.rename(download_path, target_path) # 替换 os.rename return target_path - finally: progress.close() @@ -159,19 +228,21 @@ def download_auto_file( lock_suffix: str = '.lock', lock_timeout: float = 60, ) -> str: - """Download a file from the web or S3, verify its checksum, and return the - target path. Use SoftFileLock to prevent concurrent downloads. + """自动下载文件(含锁机制和校验) Args: - resource_path (str): the source URL or S3 path to download from - target_path (str): the target path to save the downloaded file - md5_sum (str, optional): the expected MD5 checksum of the file. Defaults to ''. - sha256_sum (str, optional): the expected SHA256 checksum of the file. Defaults to ''. - lock_suffix (str, optional): the suffix of the lock file. Defaults to '.lock'. - lock_timeout (float, optional): the timeout of the lock file. Defaults to 60. + resource_path: 源文件路径 + target_path: 目标保存路径 + md5_sum: 预期 MD5 值(与 sha256_sum 二选一) + sha256_sum: 预期 SHA256 值(与 md5_sum 二选一) + lock_suffix: 锁文件后缀 + lock_timeout: 锁超时时间(秒) Returns: - str: the target path of the downloaded file + 下载后的文件路径 + + Raises: + ModelResourceException: 校验失败或下载错误时 """ process_func = partial(download_auto_file_core, resource_path, target_path) verify_func = partial(verify_file_checksum, target_path, md5_sum, sha256_sum) From f977d4941f11cd463bbf429017ca6c0673bcd5c3 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 11:04:14 +0800 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=E4=B8=BA=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=87=BD=E6=95=B0=E6=B7=BB=E5=8A=A0=E8=AF=A6?= =?UTF-8?q?=E7=BB=86=E6=96=87=E6=A1=A3=E6=B3=A8=E9=87=8A=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/resource_utils/process_with_lock.py | 42 ++++++++++++++++--- .../resource_utils/test_process_with_lock.py | 1 - 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/llm_web_kit/model/resource_utils/process_with_lock.py b/llm_web_kit/model/resource_utils/process_with_lock.py index 2064091e..b9c7fef3 100644 --- a/llm_web_kit/model/resource_utils/process_with_lock.py +++ b/llm_web_kit/model/resource_utils/process_with_lock.py @@ -8,6 +8,14 @@ def get_path_mtime(target_path: str) -> float: + """获得文件或目录的最新修改时间. 如果是文件,则直接返回 mtime. 如果是目录,则遍历目录获取最新的 mtime. + + Args: + target_path: 文件或目录路径 + + Returns: + float: 最新修改时间 + """ if os.path.isdir(target_path): # walk through the directory and get the latest mtime latest_mtime = None @@ -29,13 +37,35 @@ def process_and_verify_file_with_lock( lock_suffix: str = '.lock', timeout: float = 60, ) -> str: - """通用处理验证框架. + # """通用处理验证框架. + + # :param process_func: 无参数的处理函数,返回最终目标路径 + # :param verify_func: 无参数的验证函数,返回布尔值 + # :param target_path: 目标路径(文件或目录) + # :param lock_suffix: 锁文件后缀 + # :param timeout: 处理超时时间(秒) + # """ + """ + 通用使用文件锁进行资源处理与资源验证的框架. + 使用文件锁保证处理函数调用时是唯一的。 + 资源校验不在锁保护范围内从而提高效率。 + 当资源校验不通过时,会删除目标文件并重新处理。 + 简易逻辑为: + 1. 检查目标是否存在且有效,如果是则直接返回目标路径 + 2. 如果目标不存在或无效,则尝试获取锁 + 3. 如果锁存在且陈旧,则删除锁和目标文件重新处理 + 4. 如果锁存在且未陈旧,则等待锁释放 + 5. 如果锁不存在,则执行处理函数 + 6. 处理完成后返回目标路径 - :param process_func: 无参数的处理函数,返回最终目标路径 - :param verify_func: 无参数的验证函数,返回布尔值 - :param target_path: 目标路径(文件或目录) - :param lock_suffix: 锁文件后缀 - :param timeout: 处理超时时间(秒) + Args: + process_func: 无参数的处理函数,返回最终目标路径 + verify_func: 无参数的验证函数,返回布尔值 + target_path: 目标路径(文件或目录) + lock_suffix: 锁文件后缀 + timeout: 处理超时时间(秒) + Returns: + str: 最终目标路径 """ lock_path = target_path + lock_suffix diff --git a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py index 7f0091ea..5e780b0a 100644 --- a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py +++ b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py @@ -205,7 +205,6 @@ def real_process(): # 验证文件内容 with open(self.target_path) as f: content = f.read() - print(content) self.assertEqual(content, 'new content') From d65570402a8c2f4b8fff7c0b3c8067bba8d5e576 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 11:50:54 +0800 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=AA=8C=E8=AF=81=E5=92=8C=E8=A7=A3=E5=8E=8B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=B7=AF=E5=BE=84=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E6=80=A7=E6=A3=80=E6=9F=A5=E4=BB=A5=E6=8F=90=E9=AB=98?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/resource_utils/download_assets.py | 3 ++- llm_web_kit/model/resource_utils/unzip_ext.py | 3 +++ .../model/resource_utils/test_process_with_lock.py | 11 +++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/llm_web_kit/model/resource_utils/download_assets.py b/llm_web_kit/model/resource_utils/download_assets.py index 280b9676..9ea85f95 100644 --- a/llm_web_kit/model/resource_utils/download_assets.py +++ b/llm_web_kit/model/resource_utils/download_assets.py @@ -83,7 +83,8 @@ def verify_file_checksum( raise ModelResourceException( 'Exactly one of md5_sum or sha256_sum must be provided' ) - + if not os.path.exists(file_path): + return False if md5_sum: actual = calc_file_md5(file_path) if actual != md5_sum: diff --git a/llm_web_kit/model/resource_utils/unzip_ext.py b/llm_web_kit/model/resource_utils/unzip_ext.py index f687288f..66622595 100644 --- a/llm_web_kit/model/resource_utils/unzip_ext.py +++ b/llm_web_kit/model/resource_utils/unzip_ext.py @@ -40,6 +40,9 @@ def check_zip_path( Returns: bool: True if the zip file is correctly unzipped to the target directory, False otherwise. """ + if not os.path.exists(zip_path): + logger.error(f'zip file {zip_path} does not exist') + return False with zipfile.ZipFile(zip_path, 'r') as zip_ref: if password: zip_ref.setpassword(password.encode()) diff --git a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py index 5e780b0a..69b24db8 100644 --- a/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py +++ b/tests/llm_web_kit/model/resource_utils/test_process_with_lock.py @@ -218,8 +218,11 @@ def dummy_process_func(target_path, data_content): def dummy_verify_func(target_path, data_content): # 验证文件内容 - with open(target_path) as f: - content = f.read() + try: + with open(target_path) as f: + content = f.read() + except FileNotFoundError: + return False return content == data_content @@ -301,3 +304,7 @@ def test_multi_process_with_zombie_files(self): with open(self.target_path) as f: content = f.read() self.assertEqual(content, self.data_content) + + +if __name__ == '__main__': + unittest.main() From 3af595141797eb41889a25e67297bc629edb0128 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 13:24:02 +0800 Subject: [PATCH 11/14] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=A0=A1=E9=AA=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=92=8C=E5=BC=82=E5=B8=B8=E6=83=85=E5=86=B5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/resource_utils/test_download_assets.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/llm_web_kit/model/resource_utils/test_download_assets.py b/tests/llm_web_kit/model/resource_utils/test_download_assets.py index 907edfc6..f93dba3f 100644 --- a/tests/llm_web_kit/model/resource_utils/test_download_assets.py +++ b/tests/llm_web_kit/model/resource_utils/test_download_assets.py @@ -126,15 +126,26 @@ def test_empty_chunk_handling(self): class TestVerifyChecksum(unittest.TestCase): + def setUp(self): + self.temp_file = tempfile.NamedTemporaryFile() + self.temp_file.write(b'test data') + self.temp_file.flush() + + def tearDown(self): + self.temp_file.close() + @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_md5') def test_valid_md5(self, mock_md5): mock_md5.return_value = 'correct_md5' - assert verify_file_checksum('dummy', md5_sum='correct_md5') is True + assert verify_file_checksum(self.temp_file.name, md5_sum='correct_md5') is True @patch('llm_web_kit.model.resource_utils.download_assets.calc_file_sha256') def test_invalid_sha256(self, mock_sha): mock_sha.return_value = 'wrong_sha' - assert verify_file_checksum('dummy', sha256_sum='correct_sha') is False + assert verify_file_checksum(self.temp_file.name, sha256_sum='correct_sha') is False + + def test_no_such_file(self): + assert verify_file_checksum('dummy', md5_sum='a') is False def test_invalid_arguments(self): with self.assertRaises(ModelResourceException): From 650aa09e544b62bce3dedeea05046a0b8c93d9cd Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 14:26:23 +0800 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=AE=A1=E7=90=86=EF=BC=8C=E7=BB=9F=E4=B8=80=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=AF=BC=E5=85=A5=E6=96=B9=E5=BC=8F=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/code_detector.py | 10 ++-- llm_web_kit/model/html_layout_cls.py | 6 +-- llm_web_kit/model/lang_id.py | 6 +-- llm_web_kit/model/policical.py | 11 ++--- llm_web_kit/model/porn_detector.py | 48 ++++++++++++++------ llm_web_kit/model/quality_model.py | 6 +-- llm_web_kit/model/resource_utils/__init__.py | 6 +++ llm_web_kit/model/unsafe_words_detector.py | 8 ++-- 8 files changed, 58 insertions(+), 43 deletions(-) diff --git a/llm_web_kit/model/code_detector.py b/llm_web_kit/model/code_detector.py index 74d47533..c419d1c4 100644 --- a/llm_web_kit/model/code_detector.py +++ b/llm_web_kit/model/code_detector.py @@ -7,12 +7,10 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.singleton_resource_manager import \ - singleton_resource_manager -from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, - unzip_local_file) -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + get_unzip_dir, + singleton_resource_manager, + unzip_local_file) class CodeClassification: diff --git a/llm_web_kit/model/html_layout_cls.py b/llm_web_kit/model/html_layout_cls.py index 7ab35c92..e4c86694 100644 --- a/llm_web_kit/model/html_layout_cls.py +++ b/llm_web_kit/model/html_layout_cls.py @@ -4,10 +4,8 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger from llm_web_kit.model.html_classify.model import Markuplm -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, - unzip_local_file) -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + get_unzip_dir, unzip_local_file) class HTMLLayoutClassifier: diff --git a/llm_web_kit/model/lang_id.py b/llm_web_kit/model/lang_id.py index 5aa7d1d7..7b08c04f 100644 --- a/llm_web_kit/model/lang_id.py +++ b/llm_web_kit/model/lang_id.py @@ -6,10 +6,8 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.singleton_resource_manager import \ - singleton_resource_manager -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + singleton_resource_manager) language_dict = { 'srp': 'sr', 'swe': 'sv', 'dan': 'da', 'ita': 'it', 'spa': 'es', 'pes': 'fa', 'slk': 'sk', 'hun': 'hu', 'bul': 'bg', 'cat': 'ca', diff --git a/llm_web_kit/model/policical.py b/llm_web_kit/model/policical.py index 7f07545d..dff0e50a 100644 --- a/llm_web_kit/model/policical.py +++ b/llm_web_kit/model/policical.py @@ -7,12 +7,10 @@ from llm_web_kit.exception.exception import ModelInputException from llm_web_kit.input.datajson import DataJson from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.singleton_resource_manager import \ - singleton_resource_manager -from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, - unzip_local_file) -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + get_unzip_dir, + singleton_resource_manager, + unzip_local_file) class PoliticalDetector: @@ -22,6 +20,7 @@ def __init__(self, model_path: str = None): # must set the HF_HOME to the CACHE_DIR at this point os.environ['HF_HOME'] = CACHE_DIR from transformers import AutoTokenizer + if not model_path: model_path = self.auto_download() model_bin_path = os.path.join(model_path, 'model.bin') diff --git a/llm_web_kit/model/porn_detector.py b/llm_web_kit/model/porn_detector.py index e6429757..d60c6b7a 100644 --- a/llm_web_kit/model/porn_detector.py +++ b/llm_web_kit/model/porn_detector.py @@ -7,24 +7,28 @@ from llm_web_kit.config.cfg_reader import load_config from llm_web_kit.libs.logger import mylogger as logger -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, - unzip_local_file) -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + get_unzip_dir, unzip_local_file) -class BertModel(): +class BertModel: def __init__(self, model_path: str = None) -> None: if not model_path: model_path = self.auto_download() - self.model = AutoModelForSequenceClassification.from_pretrained(os.path.join(model_path, 'porn_classifier/classifier_hf')) - with open(os.path.join(model_path, 'porn_classifier/extra_parameters.json')) as reader: + self.model = AutoModelForSequenceClassification.from_pretrained( + os.path.join(model_path, 'porn_classifier/classifier_hf') + ) + with open( + os.path.join(model_path, 'porn_classifier/extra_parameters.json') + ) as reader: model_config = json.load(reader) self.cls_index = int(model_config.get('cls_index', 1)) self.use_sigmoid = bool(model_config.get('use_sigmoid', False)) self.max_tokens = int(model_config.get('max_tokens', 512)) - self.remain_tail = min(self.max_tokens - 1, int(model_config.get('remain_tail', -1))) + self.remain_tail = min( + self.max_tokens - 1, int(model_config.get('remain_tail', -1)) + ) self.device = model_config.get('device', 'cpu') self.model.eval() @@ -33,7 +37,9 @@ def __init__(self, model_path: str = None) -> None: if hasattr(self.model, 'to_bettertransformer'): self.model = self.model.to_bettertransformer() - self.tokenizer = AutoTokenizer.from_pretrained(os.path.join(model_path, 'porn_classifier/classifier_hf')) + self.tokenizer = AutoTokenizer.from_pretrained( + os.path.join(model_path, 'porn_classifier/classifier_hf') + ) self.tokenizer_config = { 'padding': True, 'truncation': self.remain_tail <= 0, @@ -86,22 +92,36 @@ def pre_process(self, samples: Union[List[str], str]) -> Dict: length = tokens_id.index(self.tokenizer.sep_token_id) + 1 # 如果tokens的长度小于等于max_tokens,则直接在尾部补0,不需要截断 if length <= self.max_tokens: - tokens = tokens_id[:length] + [self.tokenizer.pad_token_id] * (self.max_tokens - length) + tokens = tokens_id[:length] + [self.tokenizer.pad_token_id] * ( + self.max_tokens - length + ) attn = [1] * length + [0] * (self.max_tokens - length) # 如果tokens的长度大于max_tokens,则需要取头部max_tokens-remain_tail个tokens和尾部remain_tail个tokens else: head_length = self.max_tokens - self.remain_tail tail_length = self.remain_tail - tokens = tokens_id[:head_length] + tokens_id[length - tail_length : length] + tokens = ( + tokens_id[:head_length] + + tokens_id[length - tail_length : length] + ) attn = [1] * self.max_tokens # 将处理后的tokens添加到新的inputs列表中 - processed_inputs.append({'input_ids': torch.tensor(tokens), 'attention_mask': torch.tensor(attn)}) + processed_inputs.append( + { + 'input_ids': torch.tensor(tokens), + 'attention_mask': torch.tensor(attn), + } + ) # 将所有inputs整合成一个batch inputs = { - 'input_ids': torch.cat([inp['input_ids'].unsqueeze(0) for inp in processed_inputs]), - 'attention_mask': torch.cat([inp['attention_mask'].unsqueeze(0) for inp in processed_inputs]), + 'input_ids': torch.cat( + [inp['input_ids'].unsqueeze(0) for inp in processed_inputs] + ), + 'attention_mask': torch.cat( + [inp['attention_mask'].unsqueeze(0) for inp in processed_inputs] + ), } inputs = {name: tensor.to(self.device) for name, tensor in inputs.items()} return {'inputs': inputs} diff --git a/llm_web_kit/model/quality_model.py b/llm_web_kit/model/quality_model.py index 0d0c84af..f6d95bd1 100644 --- a/llm_web_kit/model/quality_model.py +++ b/llm_web_kit/model/quality_model.py @@ -19,10 +19,8 @@ stats_html_entity, stats_ngram_mini, stats_punctuation_end_sentence, stats_stop_words, stats_unicode) from llm_web_kit.model.basic_functions.utils import div_zero -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.unzip_ext import (get_unzip_dir, - unzip_local_file) -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + get_unzip_dir, unzip_local_file) _global_quality_model = {} _model_resource_map = { diff --git a/llm_web_kit/model/resource_utils/__init__.py b/llm_web_kit/model/resource_utils/__init__.py index e69de29b..79ea734a 100644 --- a/llm_web_kit/model/resource_utils/__init__.py +++ b/llm_web_kit/model/resource_utils/__init__.py @@ -0,0 +1,6 @@ +from .download_assets import download_auto_file +from .singleton_resource_manager import singleton_resource_manager +from .unzip_ext import get_unzip_dir, unzip_local_file +from .utils import CACHE_DIR, CACHE_TMP_DIR + +__all__ = ['download_auto_file', 'unzip_local_file', 'get_unzip_dir', 'CACHE_DIR', 'CACHE_TMP_DIR', 'singleton_resource_manager'] diff --git a/llm_web_kit/model/unsafe_words_detector.py b/llm_web_kit/model/unsafe_words_detector.py index beb08bdc..8c05059c 100644 --- a/llm_web_kit/model/unsafe_words_detector.py +++ b/llm_web_kit/model/unsafe_words_detector.py @@ -10,10 +10,8 @@ from llm_web_kit.libs.standard_utils import json_loads from llm_web_kit.model.basic_functions.format_check import (is_en_letter, is_pure_en_word) -from llm_web_kit.model.resource_utils.download_assets import download_auto_file -from llm_web_kit.model.resource_utils.singleton_resource_manager import \ - singleton_resource_manager -from llm_web_kit.model.resource_utils.utils import CACHE_DIR +from llm_web_kit.model.resource_utils import (CACHE_DIR, download_auto_file, + singleton_resource_manager) xyz_language_lst = [ 'ar', @@ -257,5 +255,5 @@ def unsafe_words_filter_overall( unsafe_range = ('L1',) else: unsafe_range = ('L1', 'L2') - hit = (unsafe_word_min_level in unsafe_range) + hit = unsafe_word_min_level in unsafe_range return {'hit_unsafe_words': hit} From c2ed5ce2a5bee8c6e3ada62568ff16fda8a0a545 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 14:43:05 +0800 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=9B=AE=E5=BD=95=E5=88=9B=E5=BB=BA=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E7=9B=AE=E5=BD=95=E5=AD=98=E5=9C=A8=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/resource_utils/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/llm_web_kit/model/resource_utils/utils.py b/llm_web_kit/model/resource_utils/utils.py index 9f09f99c..4ea78dda 100644 --- a/llm_web_kit/model/resource_utils/utils.py +++ b/llm_web_kit/model/resource_utils/utils.py @@ -33,10 +33,10 @@ def decide_cache_dir(): if not os.path.exists(CACHE_DIR): - os.makedirs(CACHE_DIR) + os.makedirs(CACHE_DIR, exist_ok=True) if not os.path.exists(CACHE_TMP_DIR): - os.makedirs(CACHE_TMP_DIR) + os.makedirs(CACHE_TMP_DIR, exist_ok=True) def try_remove(path: str): From 18172c738e7ce0a64f2238d24dda312b22947de3 Mon Sep 17 00:00:00 2001 From: qiujiantao Date: Mon, 10 Mar 2025 15:50:51 +0800 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20libgomp.so.1?= =?UTF-8?q?=20=E4=BA=8C=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_web_kit/model/libgomp.so.1 | Bin 0 -> 154840 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 llm_web_kit/model/libgomp.so.1 diff --git a/llm_web_kit/model/libgomp.so.1 b/llm_web_kit/model/libgomp.so.1 new file mode 100644 index 0000000000000000000000000000000000000000..91f39643ef69dff804ccecec7b709771ae41434d GIT binary patch literal 154840 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�llEw3b$%(W2}GfC-C?eLvLbW~4^ z6hFdMArtXW_v(*;?hl_ny!m8lTzDy8B)nnxC%kcy@H)GW#@H{JzJDf*C2dD2#JOJ^=YAVE-9>$WsWU$OM`PVl_me&< z@uX%;NxIG`u>WXJf&Fp5){cv6-6Ite{yaADlh_IPzlKW!{uudzzRwU$t!p&pwsyJQ zHCl@PGxEn}pLn7(LztuW{|SWX_s``MG;<-hEaz}F5+>{Ym}-kz3+};7 zz?Q>=3suxc8IPUN?5hoH@N0kVH6Qi4QxCLYQD2GfZwR}A%ee@ z2!_kUf63qF!W`wl^Nv_~ou5sJm;VWr&nwJW*mbnOs6NJ(ucDmoliPg$nLxWrRh(g-$4jW4;89?%>GbMd}nKIK6{xh1l`1;zId^vgAs{~NU@#$ENAZ`lRMHE@tF#BiHtQt6|P?4=GC zm1Wv0@7UsUm;V?#GKpCC_H~rUJ+8ntF8f1&S#$wX2Abjcln4rEnqFsj?U56YA=iOc z*Ps)8)k=jM?aqCJ<5l`z6Y(nJA1>*ArLerj-#y~EX?vAI=QKTmLX87ok)#WL{E}oa zOTfS3jS{m2$5MaL+|$#T{j4yNWd+Fd_Gi4aq{5xIYw@(wyq)V8f3=hg75pDL{X0r_ zbdt93jPF1GS=-(9AqwV+Lf50JFsBJoYChBWDmTCR&?X zqtdBx3ddXZmIXcvBiwbPzX~^7)_)@M0;8AlMk0K)&0&mh9Qt<&h5qwxLiK+-K9C!v zW544ArZHdkp2hcdGsuyQ-R3OZe&2_|Cpys-<8wALeuE4u-ViL#LAn2z3it;|>MD!8 ze=PZdOk@vn<-PfobdndJNoDX}>+)ZC4?Kz_iu;4+V0_nzze{B~O3GY9%8({yRP`7R zobqvEHXPS0aqap&dCvDOt&dyMuCF|Lg0TEg6-a3cjBXGO{g25uS(IDNTU2YWZf@r4 zPe}s5+UYLm*vva*y=Mpa%dVQdT`v3BT@XbC_s?c1N#@qPpULh>H5dFaMwz+f-vjcq z*Pq4EuTCk!=D&s%lnz{Ih%SMVNx+{f7ed;jPSA-59%mp}9%pgw?(X96``8fhRhjqK zSCVeKqGnmm6`rcaZh9@U!&&w^y0*=&tf~HWV@{_E6+epHinYHNGDz5ZNzYkW*e^B+VH**~XLYMgihDaUpgw(zLKYMk<7ik!Db z(G@q@@(t18EnFYO2wZC(`dK}du_GsAL}J(`lmu+Mneg6BvgF}xrV)FZQg@FNX0?6K z6?n=F=!|VbY#jNNfZe&5Y=2`vaS1$Ssx@*a4#BBHQWqL$i6-@LQ`*?%CohC9+C4#g z2TAQw5BmSC*zN0nt@zASjDd4LeN4b3;3a&d#adB-4qLPYxFY;C3a zsv3scJt!zAjo9!ik>{o-$!9ej<|OD^MU?ffT}dT*Z`tc63lH}P0^Bode>~acKafAv zzKT&KWfRlkH_x#>J|>AYTSY3d3t6SA7Za9xOAAXkC@+~Byy*qfuuc^+Xy(^f^(n>r zkt$PDhr;j5Gb`qL8uonF9jX(oOll8?^k&d{7m*_*QMTuyL>;2>9=?7s}`e+*&+r? z_cCRm_j|1RgQyXEU6$2um_>{&Xi2J!1uH4EY?Newu-Y`biS%{neP&<%ThgQR2a*Ya zb3Xn(x~+Oyh2(3heM)Sd;kzVKM~68jypb0v7(GtFUVMyN`7ozZu(m%%K(OC)Jnuz;oVF*m4+a<*RgY!jh@)W2;( z!@xNcVesrJkeg-&b0r)PJI$oEP%G=^4glAl-GpYA$>7VXXtT-|xbWlOF;{fI3g2ow zEIm?hLA1LuNA(HRL8}jtUE=>w@d&>AcU96SbX`08;8usRsE;|HX!b)= ze+`L3&mPLr7omOd!MCOK(yO!D1MY{28NVWL8uF0#eZ1C`q4ZoOBOe*5Z_1TdbyRl8O!10pr&t}N-t?d(kIs=XM zr51EIh7Oh($oP$YYZrdySE>&p+e4RMD4XKW=HG-g{@fu~INUOT4L&tfWhRwT-Vk0Y zr$N4L22-vXP=q3{iXKbzN4J(!-?@gT4~-1-;<^CakLLg{K0K1jD9b} zwo|?Yv1TOAxGPYvF2{BI-*NljbqBI6F2Z?Tab2ajh>ELDKFTeTD5={)|G!CjWPc_a zxO0X`jyPON|H4MqbtX-09Lfw8V^)7PndSH7xWrSb|0EZSq|uKMFT?ql?~)DGdi*6K zkV9g-{XINmk5oFchgH6DL(xFq(41fcEd>j1Ur~9b?)^-WP*Rptbn+ZawyDLQ1o@=+ zRjRA~L+*eib!oqyq{zXNyyxv}Wb3pr4JZw8sr?QL!p5}lY(M@X!7DK*POfTr*zT}~zMP@M0Yu!SqsIYzY8Nwi1^BOq`)8LMlZ zvbUVa&ToTKyCM@f@$Eyr{r;lwd)90rp~)e+p$I!3?>}me;A+{lakO~M+D|fkf44vWqTByh z*Maw4d7SnA#AP4zG1mSXV*R}9^$}cgz0$~dg&BcoO&v`IyE4u8R?ngQFX>k=qYF*|UwNT5YHl2h zY4_wX8type9(35gYJybWR=y_Qb(RVjo5=X&P40BXYY}PlJxQBR(nie=O^{i|14rEc z(#>vv@it@+Cw=}oA>jSDAvews+ELm#FvI09-O3%g!4;~k@Ej=KHo@=wH_c4xW_k`2 zQf$Y%eq_e8mv-f%-8*Qv-`mG^>F$!Gk4C)A<;-FI+R*Qiy?zx{2mNdn`5C?s?2qqL zopkhgkIpCUQ|TtRf67*wdunuMg;4u2>M50#iv3ZM7`zoc^o$f|o=p|s6BReP1LF1h z2`Sl!yVjZgK)yRJEos(>5R26Vq&`VQKpXJ7p93XF=+SrfK_jJbw^|zyp7}KUx8v*E z7k#Iu43U}X;c|ag{e2Q{;GFk2QQ%7gW6Bui+v`_KEsz55y}j`GjwFsR+>qtFL~g}C z-}=&)Zw3`eK5XsnqdqQvJr#FIXTfc%74UwfYnE?+;mYR_R_l^Uafv4{EIVi9xOmBu z7bgEk(1`4H$*Gi&IBs;b*tB3GP{SovozmpE9y`=zc5rvup0S6p zFh;O|#eda)t4Y~B&JZSVAG2h2{^SWtU8Fq`cjo2c zU|y86&o)`J1T!HUg45KuND|{JxF`!n`t+4J>GaMfiz@PQ_-j$Bn zaOsi_kg_4y7{Q2J8)*#LTkKL%DL_}GK=8~ z6b>&`eOsvpx0L4X;OjfysczcqyVof9GV0jNUN`C)_D0#2?+%O^! zN!sEu;>CJ*r7hXZrDt4H_pK29D7r8#gKOLSpAu*BZ90r3~?o`NrmxP0S^jvq5=^2Wd5}nITW>a5P9$0OloqvUv z{WI?+T8{rMwr3ZH_zK7!2>O68R|fav(G|Fbn7!ds=Q$~lX8%L2Kc@w+5DUtLjN}k! zZ0XpS@$Z}R<;p8qZdyFXb?t<-Mc;soNXAG0$-_B+H=N{Ul+V}A%-)FEu3TG_=3}8} z^h;Mtr44o;HOKj8{EF~Dc@%y}#rfrYO_52{rzYd&TKWd-q`Rnf9UrS}k?4Ar=vqTE zqicZlBsafFNp<|Q9zV)D2jLR^|Jc<^J7siz5UCI0@KaRy zOpPL#D{lCIJZW_;d#TxU*y_srWN|-{QNxCm{Yt2rU#@HKNb|kb#k@&Zr|Ji>fICX{ zLQVJ1&)$i#AD!v!CyTNtotMSFv%YYUG^|I_a;eWU6lSnlAcHEG_iFp49RIUl>du*A zzqCAOq5aa0Ikm9cFKt#MHv6SpayF@V^BX4iOSk8=@=kxx>^#XhT~07H*5@3CF0Y*R zWdz7hD`}80^ESzUamWg!eibuJO46^yTet`2JIl4QO|)d$;+^30Or>|d8LW9+?IeiO zUwvKX|41Z`#Kc3!*)j15p4Tazl9(u@Sy2c+@fdBvuyFZ_r2qdt`p-sxJ&sUIkrdh& z&3iS;quU=;dGwuN(m7(4Q@^pnVbCIw<-8T7c`v8SpX@a^vdQ6V%bt8F#-vUkiwfH} z^_Ah^*lZ~R!VwJc*tOFwotDOtM~0~`i|(*+Ui%7+0S#I3J+`5$BfC?S0GI#Y7_S$k z@e6)>w#a%b^h&Tmq?$G*9A4)Hh*9++F#1;;iBHIv#G3FqLy^zHz>#|hSA}yY;na;@ z4U6F&a5EhJ;eg?3&CodwhN)c=NnJlhh4)1T0>(ZnmHrxek3N6REK_VXk;S^+1=x3O zU5HDVKBAb_(N@$;7caJS*;lHzHf@FaemTd^DaK=p07Gie>uZf(K(4u zgVu}Ngw7NDYQOt+6v|KbE!cK953@L(P{LJL^yo84QYglR#*F+4jpd^`x$&GDV^KU; zw_pGoi~M!&oYxXz^<;Olc*C_ z9ecO`Qx)mp4=Em2lvhRai$XahYL;`ol(+jGGMQo4mq6-Fc@4)ukoxVI_xSw%qVMtL z;fwlr`TrU^5@j#seg#N}E&b!iga1u_Jpcdddwl%j<%#`a`a9_46R=7*(=J8YAOFXl zd)4+>ZNC~vKSzDslR6u#AivJzXUDA_xxE@bn1>O!2t zuM72Av4|)8>c1cby1jlk4=jv7hh$gf0c_uX=_oRuRFhDqePH3G9|b^$J#rDqflDc^ zn}4k=kLO7l)#)8x>{qg{Y7&9?mQ5zQosoS^vgM+MK!TGf_{i_QBuQibR&bxOD{=MSgt}4#RrJ``-=#}`VZr_S$D(A=FKw9I-2SjM&=y{h> zIc1y>yq1K9kSqRK{g&q{)8h!~nN8L!)N)C6ul?uJi$I2)-8$z0x!^qu-_P^lH;>={ zr44y9p}Sfl&Yd`te&^9c$A*QxnW6P^K7t_%?!rGsp)*bug0P-&?_BkIJBOP3jP2 zvrGHy%Hl_QZ3QGNd)ZANsp&9vJ%_wcn@E{e&$1Z(7s6U@hINA()*K0|M8fLc(h~~a z!=JeEq>ND{=>j)#-%M2NNf{t^%sbNPgjKMwTCWm9)X@2@l0Q0~1E}N6<65D*6G@AU z>rU9%W7c@iV5rs@w-VlT1`XQTJNGv1zJEHeE~&VpFhu_%@7X(Tt6Eo#EyR>( z`FC;(cMvU#S^t=hcU`{chf-MH@IA*B9F;t)*>~BzVR?UcRqI_l;Fz`E`(0P%vu^*_ z`2ckzv#WcGq*qU|v6ZpZXe&u-b94J*VW=rb=CGNoqX7Ghh6v$=@Wo}<4}?}1J}Sky z_?bD7f)}%GI52wu4^T@^m+X>T)QsTPU-g7PXB|~@9)QhvH4V)M#ed10u1c7`hW@lQ{7k}8H9Rd6;NM$0REJE1>h*Kx;JHa6R5pASNB+MFlYQNT zIOQF)ultcCRON1~Agi=*%r8@6Gu+zozKv<((Oj0iQGJUNgL1a7ys&P2&K5i-c~{6q zNONF)OmmNas%=wa@gleRPWkI zae6EGGBz;y@@8cSFGF1LK1s*lh|Oq_r+ek;_cGDmIQlUd6wz&3HbICecZ4q;Pvuov^&3K6lj4wk=1XJ?$RR1!zqm)0>b3ds%3ZPR z4!~8&Fq;H^P=v8QU`9l)D(5OAYO1cG?#AZSgLqTX>^gtFSs?6dcS$%acS-27okQkh zRnt@$=u4_!=Cv34`!fA$lbjs&G}jK@VN3Gu3_R&Ur9zeVp~)Ne6q$YoJ=^sQZGVgF zS?^mZD$!71AfK7km+i@HoGaPc)vVJGjb4LE74+=j)7y#3Q^DB}E8SahkU?Eq z@M82;Y1#EyT>DvQ9_W)JvZ^6P-Pl$XyE%0&9YCGmI1d@oDiv)e(v`Wo*$Uql{1)N+ z-!caPA2Ipssyv6gof+-4ai74YJXvBWZhWjXe@v8tDs3E^Ut$(dF;CIw2H$6%w=l43 z3kgL+Of$>m((g%zA8Jq;eiQ4)+~Rd@mF)Z2Q><%8u_UcQ?qaxhozO`T?hW`@MT=@$ z>J_tGX|>@UQt`*ZLCq=^ZzqJ{dy=p}k<#;qJgo}1$F#$;10~V`%j{Mlb?_QJhDBet z8qj*OF`eVR^e4=Vy69cl>(>%wTp6iBry)_}^#tXlGa&7mN@R1d)mn~KN@>}T!>X5; zZJBM7n#nPcqGTq= z5BQcqI5E%=s7_3GXvm$CTYr22G-)O&f z$Nn?2{*n4Xovw?r_rQ07T7+PSp1+I%2&F&3uN0 z2z3sn?utKElRN+X^XR!?C2v(oEyJ)XcHG-P_#l4R@S+Z^jG&&fW=l`)&ER_nL_5ka zJq^WwUQo^QaL;CEKM`uxUv>B;D*ZdJ{b|hnS?5pSj&={kzKihSd6pJ(o`?6ok$2Se zu|64|&yk+bG0Datqur<(2TLEOl4Yt^#&Bst84tMf{^pYdh_cU>4DgusGu4V;{Cq~I z?5~d2mtDv)?@z|PGnk5YkCzw!-g#Lpf9D0U@A3Ss^M>bARQ?e!rfZky(kNuDj5G@8 z%Iv1J3TLof&(@wo)c$Bux0+t} zz7(mWF`cLc#evMOdeU(E2`X2XolZXSjiZmyUd2d@V?=xvLQfCs31R_CPY6U@j zYh{0+5VO_IZ_F96Ke=sy4v$6x-@NPjH(5_U&i(@bzr0RY<%e7~>)P=i9-f`y`uJ~M zUs=y4L{~~tTHQ9O1Ib?YUEVT4<}OIj8V9#t1Zl73@Qe3VwfkWGE#F~_xmrZ3#{-H4 z&XZ}ZCC)&mGjP_d^~J0$->N3B?CvoG3hfWJ7TX_h9oOhIl$1%%yfpK&^1Dh|#wDod z%zdYYbtl**@H7(LfkFo>@eWq!NtE%dJ<9v_{2RkNCzGC6VVy}xzWb#5*P42`9C8QN zO?(~TF7B}{gtw4=x&`-(Q#prYf@c?VL%lpFp)| z!AhkYrwplUfl>qAY_w?P7urdlQD`q#`v=bUWvaupx;#gwUS>Wo;mhij$uTlYJljf+Hp$Y>zSLGg=mt&h7&vkclRnd(n zyl2YA0a?=TlcgiR^o2P{cLgS=xvrf`uJ)HV|Mh9?i=t=ANI-9l^o=Li%?%F)oM(7J zj`F(F^!;LLp6)|ZI?YUJC!1{Sm8yj|7|V6B~~l_A|IhO5*Vz5g^a* z46;ol7%=>Mg7@%!K!5ScK=H}vc4-FGzVieOCltf{4LzZVQ}7vNC*=40j^XDRca57q zUq$%NXuq-HUX5~ba$*_!z`zL4*KbepNvx&O)t3i}c#NvS73lxp*F&!DW^or zPa&pe#Dl+T>Ir=(aq`%E3$s$rd1|Md!?Js2$V9^WoDs6LX85yTQk_D|QK+CB36UI& zj-SiS9nF{1i|+|3SxORZmMJXs!&3=^B_`O1dq-C*7#r-Jz4#IYJGB>+>lCs5=_wVA z4R&!az68Nu-;3!~!T$A>oUy_FwijQ5V87ps=~TfQ(FMn>s0kDK;OEKPKlEZcRj?(e z7Z{vF$pgg0ZpP)r&7durcM+G;x04VJg{S zs{#deRqMX@bJYA838p%h|CZ0~-%^uiz^dm((} znB|yPHE+S9WsZvK>IIb*-nsKKu*gaWM%Uw4V7dofAOG-=1jd7x&vv|yuamsDt zl+x*@yUAO2!=$3=V@f8Pnv}T3-Z_h^Dm*nt<$^_|M$LlC+p4@ZMqzceV_|g# z$MGF=Di&8cW>!_rcPt_q7ZXPnOi|gC>4js*mQ1wtyQ#3Ge0td=cS+IB#=?r4nz@Us zOwP$=B@>G#O_^A3%$+}L?)Lm%%k*s+k}0#7Jr>mujDuVdc0`jiHnr;|3yAQi{6e@A#9 zK1q8Cr?m28s$!Tj`3dKA#HFyPNEhwt6h|sl__h4zGIPeB z%33miaYgl9kAuHH@_fM}N7ekra~Ca`Kd)-O7wV$96*H@=q!5_DDf4fezhKGy|HdCv zU&UwdaQLvrd|l$^&QBa_FDV?CjIk?A=1@i*-bKsAvUnFb7FH~(sY)zM$DlmSYt|`8 zg=7AL`IlEMom->Kld5`-S&J6Tb5uhT8)nu<9sl0(Fw-kGxq9Veg7_y)XNhrCY2&nS zq+V86)zmn=R8Ge{>he6_JW-BHahs?X%~!E#cGV)2lN&v_$b+cNo9k6EIFx%9)i4M#_$qkbxmKIMecZ?|>S2D4`nLuWZ>t!(`#nNQcHLjPvReVwxP7_@5 zL}!2FK{j;U&A-|FbMvpArMP79GA6~}XYO$y|76og|LVCjXD^ty@EV5>SVLcMaLEsH ze)7Nr4>0X)$Vuz`-<^H!xbwc_mBrq#@o!bun6$I*yW=Z1o=5ZVWcK|TC*J$rIjKCa z;NO{Pw&dY|%jwgH=g;_e*NXE~AD(e#Uy=PEh9k3z?oQe}X?3!6nE!*&dFxw^kMAu^ z>c{)XFfAw^l=Rah6-knG|FJVI({H$uc7BHY8bL|`k&BsMiPn;ym1ci{|v1# zlD^9OMTT*%at|3v#4(A)Igj_(P+%p3R>Ch+1I%@$6p|)8_;0jFjYfd~FX zE*M7Di!T-yh5gU2@;Yde9Tl@w6T8e&Q$Lx=7lAD z=6dE|Ldxp^D&{Xs7>JoAs(+-rM+Q;3!n zs`^9f;kLQ+Jwnn}&M<}zyTbfm+m|aAR8&@8PWwX!S5*(I#K4JYdUlkeDhXnt=qbG; z9i)1<7(D%rTL_Q@`;L`4;)O0Bby9xgCP@SCsj955SR}nW^;Y4nm|ivC6LGh~pMG1_ zGW^oART-YDSrxu&FE8Hd5<7$T-7~ksp!HQPIafh(`IE!DY$46O4|erBIIir)aG9m2 z#1?jZ`sl8Wlvj6t5cH47`em2;{V z)!ZV_mw!uC`i@aq0nIagW<^aE@n38ySWzvx%+R9JEB)P?IX+i-74B_{JtopGg_AoDI`m8JY#lrt6X(b~Oy~jb)Kw$(`wv6b6N9K{WzR zS`>4FM^k=D~pFf0JcV)oe^q)gmn;oKiK)8mK~1BByXclpv!-sS-FOlJ%FKBB(FmB}$7g;44}|(mYO$D4D$- z!(OE}UpCmR%@vNB6&{Ca!z(N1!z1>;v3>{z&9R(X@+6(5~(-Ud~JJ$Dvvgt6CGZo+jJ@m8c z@mEh@Ya`({gWJJ&@DM2biBEz#>|xGEt`i&zHiK@k4fKH7Y+bAebHI&YJ-8ig2Ac@S z_FLisI>9#33myVnz;3Vu%%H#F*@}Fy33P&O;54us^n$kE^@JM0Y_J*30k?y>U_0mm zkAf|rjlN3=Fl;00o`C5D0>>(L2e#0I>08d`{kZc z2k2}kKj}E;zCyVJZEQ?x0ki)~zSDuseGPp<8~Yxb!FI46Z02V390$n0Suf~$ll%g+ z-y+??Trgu0^1&Ri9LxvXKo96WOge)(ZzB(E2Q%nH=dx`h7wiVh!M1miFYg`b4c2pV zs0DN$!5`QG+P;oEI1p^=B%eU<2gC<#If~w(=R@=Y%a0LG4tyU`j=+|Gp%2&%vPsR@fB6D>84Pq1?-*`3Uz?ZT)~%pIsD*I z(6$7Au%6vrOxjmfuTwpz{ZW2X=tn zVABtA9}XXw54M2ipsk5}8NqC@0rY@dKrh$^)`N${{YQi|0=Zxg*bO>C+kK=1m<@Wx z{eJv|&0rgtyMge*mIp$ifmh-W%muwaMo+LFTn^@LBwk=U*a3EcCk1~>I(?IT`5EDX zwoQa5@1Pef2OGe8uo>+BIpKlX|AW4{=mX|}Ef3)jYzMtyb2I#4{lkKG3#>_=4HsNiYY@z7~DJe6SfT2iw7Fumh|IyTOg1?bpZyv%!5}4%h+af+s;I zn4OPa;84)^8{~l5U_ICdwt&vx5upaCNo7vz~{w?$X zt3eNYI_tr9a3kn^l5_`c&k&F62oEd=bHHja7pw=J;6|_|OyyO0BBgDqei*baKz&=+h0GYWBk7CB%pm=BhN<)9a=2AjbKFnc%g z1#`edU@q7Vw(LR97{Uef!REb`8!+cN${pwh_ks0b2iOFj1e?L^BK(6xLGSaFKQQ+N z!UfyGdZF*bKiCX*gDqf&llZ-eKQJ3~f;r$c(EDfN4c3D#U=z4c+_{uvU@`K+T+j)! zG-Z^79?%0W2fbhuSPyOio4_`(89W5GfZbplm@yVT!5pvy%m=%{a?th?@c^^IdN2pv z2u1_w^!y_0eW)`MQq z`2q4lH@F>~2Oa{~f+xWT!GUGy1LlM8fz!YNU8Ea$9k>zvHn<&J2_6C)z?0xFz=78r z#!fIF{2Mq8%sPsH@EUL8+z7r7ZU=22;vXCeo&>K42bLSg9bi8A z5I7Co3oZxyAHzR59^4LQ8|E+R_8W|(rCCWAWb{p2pF}=G9>_n}!k*AE8xNUTV>1Vg zx2G>jt2M6s%C+D4X3j<8Q+O~J#^H@UGNsR?er8r(^2Gj`X@%+XD()WsiTfo&Q0_Oz zxYt8Ib_)NZU$b^s0rD(Ap>OA3+JilzbA+IO5?(w1vY_82G-swGxilP{^4ATy4EL{y zyZ(v44B|Z%I>p`eHzwAf+)KU|cf0l@ApV@tYflrN2lv*v@Fa}o&|QmrLajU-V=}WE zlZ!J4G^C*MYFlCEkh)Z7=J4dz{WFIYW;zNp2TaV&8k3n;oL(5F;0KCIXpy%MdE1aD zpEMSE_au+W9I!5BOs1pJR-8GcLFBFOirlTS=O_i^5=w;CJh3?68+@$H%(3e8LSm^pEa_XV4Pr$zs z`nEWJDQ_*%_d{PIGAJ!eD5k~fH}iK~y2xwCeK_S!(#ZM={U~%7^uZPp6uQl37&D+> z5l0^geJS*;IC?Jhh0y!P(cRGJKu?RKd$jy`{^ii6zQw0Ula^2OOQFeh+@jNO3-rzK z$Lrq)UFuhAT)2mzZ-Xwy*ZN6(yP!mYT|jZyWYA$elvfYm7z z5N(^K>Je5(-1v#ST=);e|F{s8ykCapNjjF}Ui-72(Anbd%v8lk=+)5ILVrN$;t!BC zs)wG%v8ZiAa7jJJ+SR2bxAs>xcx-y|TJvdK`cmsbm!t#Mx6YQl(6lI|#29JVoczm& ze-39FlO+R|MD)IoT+AxmEjgL|fb~2!eQIXfuHCmv%vKYyyx>jrtw+vq*7w~!TR)+1 z6#3A9V-Z22w?H=@=?UE*MbG7VAM~}*MXu=IDE6>H^w;f4@}2!vOM+W;yI~`99lz)a ztxV8vMC_t!H^yboNM0GXijx%@6B$I(anqqi6!+nzfQBdELm%;%E%he zarUFUBTwZM?6*Cky|L*tF1GyW@J~^G#z&=3hlGzjxmdwWAJs2X<#AkWzlidfIKSOd z@^X1zzqKdy$FP3?|K&;g)QjxjX}crkr` zAN~n{hvtu_OZ#&Y`cn8Sgqv``ORy8uC-b*By<8Vqu`4+lv=8t#3!k!!PO*zLLUlIj zqOLNn3jHn1s+e|t8vaJHKD6Z&{_c+Urwg=%*Mz^VU(lb#XCMBCu>N$jgcpuaiAqc> zK9U~Y@Xdhl5|eNEOr3riXOJEV=sD2WC!ps;-wb`MmP50ZCVG@ZKMbAeINdKLy`*1S zFx%>tn(aS)Cq4&XGkjUBqy0B|``{Y_-}GMLCcCYGRs7+b0bg`^5PC)?{jmh}9Oz|n zbT|C@&|N|g*Q0PfDBs3GnltKB4yawc@u`Vcgk4M?DNWE9%4c!ivO-pF!qvahE zd5w(}9YqsDh7;qe&lbjp~-cHT@Oktgfc2meHW zAyz*%UX19c(`QC*?H;j%l?TI@I5JdNqwcONxLdT}fyeapAG_bB6j5noz z;GSYoqp0wfdp_<{cd}1c2;#pgR=(`Z*o*t`E%yNKQT?LjxX+P&3F5BiFVwh4npAIi z{zA8lVxPCbe-M7D{?F3-OHx8C?WS`4a`0o&Y6%OOYI_VdAqC|f${47~7{=E3xgujX6 zk95-WerlXHR_6V%QWBueg{}lY?nqT#cv>llv@PabDY0VpX_9@^kD*!SWR3bDQ`Qq5F)ci=u8_gZm} zq&NAx+E!xCvrdjm@bQ^x`LGT=kM;zA>$JahGLCJOacqOk&#q3TMAh|4zE%08%IT~U zZWS-tkF*#6(e_y4>xJH$fZhOoTLOAB^sUgZP?F6*67TI=ezlNP`uK>sIr5Qk4&grF zKu?I}RzA3snRPSeNXkn$^aAML5jsjYB#)2GbC^9vDRSy3;mG$s7Q$a36xxqEWk?It zOJNdsC+_QUcbo2xpjw))$<8)_6@6)e1LvRajyvgXZoUq{TF9?ieKyyI>b#Ggy+?B&ZmC; zwI{Upw02F8M;E39MO^QRb+g|k>ET6Q*=xGpAwKJ5p1x7$=^GeNQLc(qxuSuVng4LX zQd1a`uPyjLjDNas;h*HgKIjLbtMVZ2(bPyqki3z7ZF6qv3H?;;gi6Oqe?783pxcRs zDT|X5MMH-uddU7b3GY{$CLr{3=s%st*_1eXHT0>kGyfh(uZO-gj$hJgBlJ=5zo+xP zQPN458`7yRReHHyq_$oli7q&*{Nq3Px1P|ZsPI(zhraMlZRaEH11UB$Y%X;(ZHOMS z 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豏_sDnx`7+-)>yl76R*Kkf~H1cgu?wdZ1{2bi- z{p8g7EB6VViTkPY*Ms}HxSy*0)Z^|rjr?ZZhnz-!8}65%Mt%qGSD!|{t(bdZP9r}D zcPH)?qe%I`Gg5w?xX;49Alm(oh`R^()woOB6}E2+Om`uD`TH^jzD$8HQ{c-K_%a2) zOo1;`;L8;FG6lX&fiF|w|D_Z-cE66zohD}K_pKUR7sbYQQ|U#Dkfg~YziK{M@5TCE z&2{56Nxx@ke%Ed0!x{R0=pobR`TBjKmapbipbgOUuV`GW-DQ4Cerj<5g0c5(^P#_f|LWJw2fb_+4lhN!8)asEwOFI9ujwH3YVy;s>f2ND zexg$TM(BQ#LoN}djC!PZ)c6~B`qt^apSe!fGc;&Ybys=hTPk5+tpw0rGzlY!ke zir>=nn0`NUv+4eces`F!|JVNAtn)Ws!PUCl+^6vojgM>Gt?^ZjM>HPS*r(c*c&^5a zHD0B0ti~HP&eFJ8<7$ofX?#TE;~IBsd{yHSjmI_inWw|oc(KN-G>+AHqsCbp7i(Ot z@ji`@Xnb7bZjG;MJfiWq#y<0P_!=+Pc$LPn8gJA%OXFgVt2N%I@ez%WYuv5zRgFh9 z9@p4Ml}_fHG+wOnDve_`-l%bw#>E;}YrIe6BN`vqxLf0^8jol^uCdQT9e<4%YrIP1 zSdBMoG`V~KHNP5bniusMS(Yipg3YT?A2aGB^{=v8K2mC=N>#5}D4&Zm`q;E6%T8KI zySsEQ0hifd* zSf+7?x+Io$7L7ABeWAuUb&T3m(Bt{mYrahyw`y$FxL@O8jmI<^T8}J^4voV##)lLC zu0NwTtY*Qm5yOV_pQmc3Z#MT$KX_%(7^XfGHf+%X6Km%1c|LE&Oz8R(z{6(G_YLE# zv^9L{Z#v%};}c6&iZ;_Xx7u_0Tu4=Orq5bbF|W!{^f?tZbBtlL_!^jc=WAAkRy}wA zZAxa%vUw((7HJGyR8?Iep_!`4E84{HOkZRSyS0)(RzTC|czDAP${NFF*3=loyy~xf zf=o+X$amR>&0gRgW<9`DwG^6YHoaoeqKak4FyYtF@u~L3-`Ucr+DfZD8rFlxoH&tI zeX+1TmG$f*}?*?xTU|vXW>Qomp*~@K1BBuES#zx zM1ScsSo-s*eh=_h`V5wTtKVSZCX-FYUwGpE-wjP7kWe~S+m z^^1RRfEw-J8b@vVoc?8eBxT-c0a6N#1H_{4?cqjp&cXqL3)w(F9?FzzaR*r{{l!72`C`3LCnKI z3~~$1{|zA!{RJTq{RRZ|#{u=CDFKQA*LOhm!}^n8K_CGOM;ISD?ZCu9bPrTN7n