Repository: SwanHubX/SwanLab (language: python)

Issue:
[REQUEST] 进程结束时打印SwanLab信息
## 🤩 Features description [Please make everyone to understand it]

当云端版的进程结束时，打印形如最开始的信息：

![image](https://github.com/SwanHubX/SwanLab/assets/58305964/3ebdbb82-981e-4af4-9b31-d98c1d3f2390)



Discussion / hints:

Fix patch:
diff --git a/swanlab/data/callback_cloud.py b/swanlab/data/callback_cloud.py
new file mode 100644
index 00000000..3f44377e
--- /dev/null
+++ b/swanlab/data/callback_cloud.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+r"""
+@DATE: 2024/5/5 20:22
+@File: callback_cloud.py
+@IDE: pycharm
+@Description:
+    云端回调
+"""
+from .run.callback import NewKeyInfo
+from swanlab.cloud import UploadType
+from typing import Optional, Dict
+from swanlab.error import ApiError
+from swanlab.api.upload.model import ColumnModel
+from urllib.parse import quote
+from swanlab.api import LoginInfo, create_http, terminal_login
+from swanlab.api.upload import upload_logs
+from swanlab.log import swanlog
+from swanlab.utils.font import FONT
+from swanlab.api import get_http
+from swanlab.utils.key import get_key
+from swanlab.utils.judgment import in_jupyter, show_button_html
+from swanlab.package import get_host_web, get_host_api
+from swanlab.error import KeyFileError
+from swanlab.env import get_swanlab_folder
+from .callback_local import LocalRunCallback, get_run, SwanLabRunState
+from swanlab.cloud import LogSnifferTask, ThreadPool
+from swanlab.db import Experiment
+from swanlab.utils import create_time
+import sys
+import os
+
+
+class CloudRunCallback(LocalRunCallback):
+    login_info: LoginInfo = None
+    """
+    用户登录信息
+    """
+
+    def __init__(self):
+        super(CloudRunCallback, self).__init__()
+        self.pool = ThreadPool()
+        self.exiting = False
+        """
+        标记是否正在退出云端环境
+        """
+
+    def before_init_project(self, project: str, workspace: str, *args, **kwargs) -> int:
+        if self.login_info is None:
+            swanlog.debug("Login info is None, get login info.")
+            self.login_info = self.get_login_info()
+        http = create_http(self.login_info)
+        return http.mount_project(project, workspace).history_exp_count
+
+    def _clean_handler(self):
+        run = get_run()
+        if run is None:
+            return swanlog.debug("SwanLab Runtime has been cleaned manually.")
+        if self.exiting:
+            return swanlog.debug("SwanLab is exiting, please wait.")
+        self._train_finish_print()
+        # 如果正在运行
+        run.finish() if run.is_running else swanlog.debug("Duplicate finish, ignore it.")
+
+    def _except_handler(self, tp, val, tb):
+        if self.exiting:
+            # FIXME not a good way to fix '\n' problem
+            print("")
+            swanlog.error("Aborted uploading by user")
+            sys.exit(1)
+        self._error_print(tp)
+        # 结束运行
+        get_run().finish(SwanLabRunState.CRASHED, error=self._traceback_error(tb))
+        if tp != KeyboardInterrupt:
+            raise tp(val)
+
+    def _view_web_print(self):
+        self._watch_tip_print()
+        http = get_http()
+        project_url = get_host_web() + f"/@{http.groupname}/{http.projname}"
+        experiment_url = project_url + f"/runs/{http.exp_id}"
+        swanlog.info("🏠 View project at " + FONT.blue(FONT.underline(project_url)))
+        swanlog.info("🚀 View run at " + FONT.blue(FONT.underline(experiment_url)))
+        return experiment_url
+
+    def on_train_begin(self):
+        # 注册实验信息
+        try:
+            get_http().mount_exp(
+                exp_name=self.settings.exp_name,
+                colors=self.settings.exp_colors,
+                description=self.settings.description,
+            )
+        except ApiError as e:
+            if e.resp.status_code == 409:
+                FONT.brush("", 50)
+                swanlog.error("The experiment name already exists, please change the experiment name")
+                Experiment.purely_delete(run_id=self.settings.run_id)
+                sys.exit(409)
+
+        # 资源嗅探器
+        sniffer = LogSnifferTask(self.settings.files_dir)
+        self.pool.create_thread(sniffer.task, name="sniffer", callback=sniffer.callback)
+
+        # 向swanlog注册输出流回调
+        def _write_call_call(message):
+            self.pool.queue.put((UploadType.LOG, [message]))
+
+        swanlog.set_write_callback(_write_call_call)
+
+        # 注册系统回调
+        self._register_sys_callback()
+        # 打印信息
+        self._train_begin_print()
+        swanlog.info("👋 Hi " + FONT.bold(FONT.default(self.login_info.username)) + ", welcome to swanlab!")
+        swanlog.info("Syncing run " + FONT.yellow(self.settings.exp_name) + " to the cloud")
+        experiment_url = self._view_web_print()
+
+        # 在Jupyter Notebook环境下，显示按钮
+        if in_jupyter():
+            show_button_html(experiment_url)
+
+    def on_train_end(self, error: str = None):
+        # 打印信息
+        self._view_web_print()
+        run = get_run()
+        # 如果正在退出或者run对象为None或者不在云端环境下
+        if self.exiting or run is None:
+            return swanlog.debug("SwanLab is exiting or run is None, ignore it.")
+        state = run.state
+        # 标志正在退出（需要在下面的逻辑之前标志）
+        self.exiting = True
+        sys.excepthook = self._except_handler
+
+        def _():
+            # 关闭线程池，等待上传线程完成
+            self.pool.finish()
+            # 上传错误日志
+            if error is not None:
+                msg = [{"message": error, "create_time": create_time(), "epoch": swanlog.epoch + 1}]
+                upload_logs(msg, level="ERROR")
+
+        FONT.loading("Waiting for uploading complete", _)
+        get_http().update_state(state == SwanLabRunState.SUCCESS)
+        # 取消注册系统回调
+        self._unregister_sys_callback()
+        self.exiting = False
+
+    def on_metric_create(self, key: str, key_info: NewKeyInfo, static_dir: str):
+        """
+        指标创建回调函数,新增指标信息时调用
+        :param key: 指标key名称
+        :param key_info: 指标信息
+        :param static_dir: 媒体文件目录
+        """
+        if key_info is None:
+            return
+        new_data, data_type, step, epoch = key_info
+        new_data['key'] = key
+        new_data['index'] = step
+        new_data['epoch'] = epoch
+        if data_type == "default":
+            return self.pool.queue.put((UploadType.SCALAR_METRIC, [new_data]))
+        key = quote(key, safe="")
+        data = (new_data, key, data_type, static_dir)
+        self.pool.queue.put((UploadType.MEDIA_METRIC, [data]))
+
+    def on_column_create(self, key, data_type: str, error: Optional[Dict] = None):
+        self.pool.queue.put((UploadType.COLUMN, [ColumnModel(key, data_type.upper(), error)]))
+
+    @classmethod
+    def get_login_info(cls):
+        """
+        发起登录，获取登录信息，执行此方法会覆盖原有的login_info
+        """
+        key = None
+        try:
+            key = get_key(os.path.join(get_swanlab_folder(), ".netrc"), get_host_api())[2]
+        except KeyFileError:
+            fd = sys.stdin.fileno()
+            # 不是标准终端，且非jupyter环境，无法控制其回显
+            if not os.isatty(fd) and not in_jupyter():
+                raise KeyFileError("The key file is not found, call `swanlab.login()` or use `swanlab login` ")
+        return terminal_login(key)
diff --git a/swanlab/data/callback_local.py b/swanlab/data/callback_local.py
new file mode 100644
index 00000000..550bc6b0
--- /dev/null
+++ b/swanlab/data/callback_local.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+r"""
+@DATE: 2024/5/5 20:23
+@File: callback_local.py
+@IDE: pycharm
+@Description:
+    基本回调函数注册表，此时不考虑云端情况
+"""
+from swanlab.log import swanlog
+from swanlab.utils.font import FONT
+from swanlab.data.run.main import get_run, SwanLabRunState
+from swanlab.data.run.callback import SwanLabRunCallback
+import traceback
+
+
+class LocalRunCallback(SwanLabRunCallback):
+
+    def __init__(self):
+        super(LocalRunCallback, self).__init__()
+
+    @staticmethod
+    def _traceback_error(tb):
+        """
+        获取traceback信息
+        """
+        trace_list = traceback.format_tb(tb)
+        html = ""
+        for line in trace_list:
+            html += line + "\n"
+        return html
+
+    @staticmethod
+    def _error_print(tp):
+        """
+        错误打印
+        """
+        # 如果是KeyboardInterrupt异常
+        if tp == KeyboardInterrupt:
+            swanlog.error("KeyboardInterrupt by user")
+        else:
+            swanlog.error("Error happened while training")
+
+    def before_init_project(self, *args, **kwargs):
+        pass
+
+    def _except_handler(self, tp, val, tb):
+        """
+        异常处理
+        """
+        self._error_print(tp)
+        # 结束运行
+        get_run().finish(SwanLabRunState.CRASHED, error=self._traceback_error(tb))
+        if tp != KeyboardInterrupt:
+            raise tp(val)
+
+    def _clean_handler(self):
+        run = get_run()
+        if run is None:
+            return swanlog.debug("SwanLab Runtime has been cleaned manually.")
+        self._train_finish_print()
+        # 如果正在运行
+        run.finish() if run.is_running else swanlog.debug("Duplicate finish, ignore it.")
+
+    def on_train_begin(self, *args, **kwargs):
+        """
+        训练开始，注册系统回调
+        """
+        # 注入系统回调
+        self._register_sys_callback()
+        # 打印信息
+        self._train_begin_print()
+        swanlog.info("Experiment_name: " + FONT.yellow(self.settings.exp_name))
+        self._watch_tip_print()
+
+    def on_train_end(self, error: str = None):
+        """
+        训练结束，取消系统回调
+        此函数被`run.finish`调用
+        """
+        # 打印信息
+        self._watch_tip_print()
+        # 取消注册系统回调
+        self._unregister_sys_callback()
+
+    def on_metric_create(self, *args, **kwargs):
+        pass
+
+    def on_column_create(self, *args, **kwargs):
+        pass
diff --git a/swanlab/data/modules/_utils.py b/swanlab/data/modules/_utils.py
new file mode 100644
index 00000000..0653db0e
--- /dev/null
+++ b/swanlab/data/modules/_utils.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+r"""
+@DATE: 2024/5/5 22:47
+@File: _utils.py
+@IDE: pycharm
+@Description:
+    工具函数
+"""
+import io
+import hashlib
+
+
+def get_file_hash_path(file_path: str) -> str:
+    """计算并返回给定文件的SHA-256哈希值。"""
+
+    hash_sha256 = hashlib.sha256()
+    with open(file_path, "rb") as f:  # 以二进制读取模式打开文件
+        while chunk := f.read(8192):  # 读取文件的小块进行处理
+            hash_sha256.update(chunk)
+    return hash_sha256.hexdigest()
+
+
+def get_file_hash_numpy_array(array) -> str:
+    """计算并返回给定NumPy数组的SHA-256哈希值。"""
+
+    hash_sha256 = hashlib.sha256()
+    # 将NumPy数组转换为字节串，然后更新哈希值
+    hash_sha256.update(array.tobytes())
+    return hash_sha256.hexdigest()
+
+
+def get_file_hash_pil(image) -> str:
+    """计算并返回给定PIL.Image对象的SHA-256哈希值。"""
+
+    hash_sha256 = hashlib.sha256()
+    # 将图像转换为字节数据
+    with io.BytesIO() as buffer:
+        image.save(buffer, format="PNG")  # 可以选择其他格式，如'JPEG'
+        hash_sha256.update(buffer.getvalue())
+    return hash_sha256.hexdigest()
diff --git a/swanlab/data/modules/audio.py b/swanlab/data/modules/audio.py
index b449e3aa..8d3ed3e8 100644
--- a/swanlab/data/modules/audio.py
+++ b/swanlab/data/modules/audio.py
@@ -7,11 +7,8 @@
     音频数据解析
 """
 from .base import BaseType
-from ..utils.file import get_file_hash_numpy_array, get_file_hash_path
-import os
+from ._utils import get_file_hash_numpy_array, get_file_hash_path
 from typing import Union, List
-
-### 以下为音频数据解析的依赖库
 import soundfile as sf
 import numpy as np
 import json
diff --git a/swanlab/data/modules/image.py b/swanlab/data/modules/image.py
index ed7c1632..050303dd 100644
--- a/swanlab/data/modules/image.py
+++ b/swanlab/data/modules/image.py
@@ -2,7 +2,7 @@
 from PIL import Image as PILImage
 from .base import BaseType
 from .utils_modules import BoundingBoxes, ImageMask
-from ..utils.file import get_file_hash_pil
+from ._utils import get_file_hash_pil
 from typing import Union, List, Dict, Any
 from io import BytesIO
 import os
diff --git a/swanlab/data/modules/object_3d.py b/swanlab/data/modules/object_3d.py
index e9651937..19656cab 100644
--- a/swanlab/data/modules/object_3d.py
+++ b/swanlab/data/modules/object_3d.py
@@ -10,7 +10,7 @@
 from .base import BaseType
 import numpy as np
 from typing import Union, ClassVar, Set, List, Optional
-from ..utils.file import get_file_hash_numpy_array, get_file_hash_path
+from ._utils import get_file_hash_numpy_array, get_file_hash_path
 import os
 import json
 import shutil
diff --git a/swanlab/data/modules/video.py b/swanlab/data/modules/video.py
index d348989b..28b4084b 100644
--- a/swanlab/data/modules/video.py
+++ b/swanlab/data/modules/video.py
@@ -8,7 +8,7 @@
      视频数据解析
 """
 from .base import BaseType
-from ..utils.file import get_file_hash_numpy_array, get_file_hash_path
+from ._utils import get_file_hash_numpy_array, get_file_hash_path
 import os
 from typing import Union, List
 import logging
diff --git a/swanlab/data/run/__init__.py b/swanlab/data/run/__init__.py
index 0ebffdd9..41790378 100644
--- a/swanlab/data/run/__init__.py
+++ b/swanlab/data/run/__init__.py
@@ -7,7 +7,7 @@
 @Description:
     在此处导出SwanLabRun类，一次实验运行应该只有一个SwanLabRun实例
 """
-from .main import SwanLabRun, get_run, except_handler, clean_handler, SwanLabRunState
+from .main import SwanLabRun, get_run, SwanLabRunState
 
 
 def register(*args, **kwargs) -> SwanLabRun:
diff --git a/swanlab/data/run/callback.py b/swanlab/data/run/callback.py
new file mode 100644
index 00000000..0e097c9e
--- /dev/null
+++ b/swanlab/data/run/callback.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+r"""
+@DATE: 2024/5/5 20:23
+@File: callback.py
+@IDE: pycharm
+@Description:
+    回调函数注册抽象类
+"""
+from typing import Union, Tuple, Optional
+from swanlab.data.settings import SwanDataSettings
+from swanlab.data.modules import DataType
+from abc import ABC, abstractmethod
+from swanlab.log import swanlog
+from swanlab.utils.font import FONT
+from swanlab.env import is_windows
+from swanlab.package import get_package_version
+import atexit
+import sys
+import os
+
+NewKeyInfo = Union[None, Tuple[dict, Union[float, DataType], int, int]]
+"""
+新的key对象、数据类型、步数、行数
+为None代表没有添加新的key
+"""
+
+
+class SwanLabRunCallback(ABC):
+    """
+    SwanLabRunCallback抽象类
+    """
+
+    def __init__(self):
+        self.settings: Optional[SwanDataSettings] = None
+
+    def inject(self, settings: SwanDataSettings):
+        """
+        为SwanLabRunCallback注入settings等一些依赖，因为实例化可能在SwanLabRun之前发生
+        :param settings: SwanDataSettings, 数据配置
+        :return:
+        """
+        self.settings = settings
+
+    @staticmethod
+    def formate_abs_path(path: str) -> str:
+        """这主要针对windows环境，输入的绝对路径可能不包含盘符，这里进行补充
+        主要是用于打印效果
+        如果不是windows环境，直接返回path，相当于没有调用这个函数
+
+        Parameters
+        ----------
+        path : str
+            待转换的路径
+
+        Returns
+        -------
+        str
+            增加了盘符的路径
+        """
+        if not is_windows():
+            return path
+        if not os.path.isabs(path):
+            return path
+        need_add = len(path) < 3 or path[1] != ":"
+        # 处理反斜杠, 保证路径的正确性
+        path = path.replace("/", "\\")
+        if need_add:
+            return os.path.join(os.getcwd()[:2], path)
+        return path
+
+    def _train_begin_print(self):
+        """
+        训练开始时的打印信息
+        """
+        swanlog.debug("SwanLab Runtime has initialized")
+        swanlog.debug("SwanLab will take over all the print information of the terminal from now on")
+        swanlog.info("Tracking run with swanlab version " + get_package_version())
+        local_path = FONT.magenta(FONT.bold(self.formate_abs_path(self.settings.run_dir)))
+        swanlog.info("Run data will be saved locally in " + local_path)
+
+    def _watch_tip_print(self):
+        """
+        watch命令提示打印
+        """
+        swanlog.info(
+            "🌟 Run `"
+            + FONT.bold("swanlab watch -l {}".format(self.formate_abs_path(self.settings.swanlog_dir)))
+            + "` to view SwanLab Experiment Dashboard locally"
+        )
+
+    def _train_finish_print(self):
+        """
+        打印结束信息
+        """
+        swanlog.info("Experiment {} has completed".format(FONT.yellow(self.settings.exp_name)))
+
+    def _register_sys_callback(self):
+        """
+        注册系统回调
+        """
+        sys.excepthook = self._except_handler
+        atexit.register(self._clean_handler)
+
+    def _unregister_sys_callback(self):
+        """
+        注销系统回调
+        """
+        sys.excepthook = sys.__excepthook__
+        atexit.unregister(self._clean_handler)
+
+    def _clean_handler(self):
+        """
+        正常退出清理函数，此函数调用`run.finish`
+        """
+        pass
+
+    def _except_handler(self, tp, val, tb):
+        """
+        异常退出清理函数
+        """
+        pass
+
+    @abstractmethod
+    def before_init_project(self, *args, **kwargs):
+        """
+        在执行业务逻辑之前调用
+        """
+        pass
+
+    @abstractmethod
+    def on_train_begin(self, *args, **kwargs):
+        """
+        训练开始时的回调函数
+        """
+        pass
+
+    @abstractmethod
+    def on_train_end(self, *args, **kwargs):
+        """
+        训练结束时的回调函数
+        """
+        pass
+
+    @abstractmethod
+    def on_metric_create(self, *args, **kwargs):
+        """
+        指标创建回调函数,新增指标信息时调用
+        """
+        pass
+
+    @abstractmethod
+    def on_column_create(self, *args, **kwargs):
+        """
+        列创建回调函数,新增列信息时调用
+        """
+        pass
+
+
+class EmptyCallback(SwanLabRunCallback):
+    """
+    空回调函数，用于适应不需要回调函数的情况
+    """
+
+    def before_init_project(self, *args, **kwargs):
+        pass
+
+    def on_train_begin(self, *args, **kwargs):
+        pass
+
+    def on_train_end(self, *args, **kwargs):
+        pass
+
+    def on_metric_create(self, *args, **kwargs):
+        pass
+
+    def on_column_create(self, *args, **kwargs):
+        pass
diff --git a/swanlab/data/run/exp.py b/swanlab/data/run/exp.py
index 8fbdd054..e6aa4b1e 100644
--- a/swanlab/data/run/exp.py
+++ b/swanlab/data/run/exp.py
@@ -2,7 +2,7 @@
 from swanlab.data.settings import SwanDataSettings
 from swanlab.data.modules import BaseType, DataType
 from swanlab.log import swanlog
-from typing import Dict, Tuple, Union, Callable, Optional
+from typing import Dict, Tuple, Union, Optional
 from swanlab.utils import create_time
 from swanlab.utils.file import check_tag_format
 from urllib.parse import quote
@@ -20,12 +20,7 @@
     ChartTypeError,
     add_multi_chart
 )
-
-NewKeyInfo = Union[None, Tuple[dict, Union[float, DataType], int, int]]
-"""
-新的key对象、数据类型、步数、行数
-为None代表没有添加新的key
-"""
+from .callback import SwanLabRunCallback, NewKeyInfo
 
 
 class SwanLabExp:
@@ -34,7 +29,7 @@ class SwanLabExp:
     save keys when running experiments
     """
 
-    def __init__(self, settings: SwanDataSettings, expid: int, exp: Experiment) -> None:
+    def __init__(self, settings: SwanDataSettings, expid: int, exp: Experiment, callbacks: SwanLabRunCallback) -> None:
         """初始化实验
 
         Parameters
@@ -55,36 +50,8 @@ def __init__(self, settings: SwanDataSettings, expid: int, exp: Experiment) -> N
         """此实验对应的id，实际上就是db.id"""
         self.db: Experiment = exp
         """此实验对应的数据库实例"""
-        self.__column_callback = None
-        """
-        新的列回调函数，当有新的key添加的时候，会调用这个函数
-        只能设置一次
-        """
-        self.__metric_callback = None
-        """
-        新的metric回调函数，当有新的metric添加的时候，会调用这个函数
-        只能设置一次
-        """
-
-    @property
-    def metric_callback(self):
-        return self.__metric_callback
-
-    @metric_callback.setter
-    def metric_callback(self, value: Callable):
-        if self.__metric_callback is not None:
-            raise ValueError("Metric callback function has been set.")
-        self.__metric_callback = value
-
-    @property
-    def column_callback(self):
-        return self.__column_callback
-
-    @column_callback.setter
-    def column_callback(self, value: Callable):
-        if self.__column_callback is not None:
-            raise ValueError("Key callback function has been set.")
-        self.__column_callback = value
+        self.__callbacks = callbacks
+        """回调函数"""
 
     def add(self, key: str, data: DataType, step: int = None):
         """记录一条新的tag数据
@@ -130,26 +97,14 @@ def add(self, key: str, data: DataType, step: int = None):
                 data.tag = tag_obj.tag
             result = tag_obj.create_chart(tag, data)
             # 创建新列，生成回调
-            if self.column_callback is not None:
-                self.column_callback(*result)
+            self.__callbacks.on_column_create(*result)
         # 检查tag创建时图表是否创建成功，如果失败则也没有写入数据的必要了，直接退出
         if not tag_obj.is_chart_valid:
             return swanlog.warning(f"Chart {tag} has been marked as error, ignored.")
         # 添加tag信息
         key_info = tag_obj.add(data, step)
-        # 添加成功且设置了metric_callback，则调用metric_callback
-        if key_info is not None and self.metric_callback is not None:
-            new_data, data_type, step, epoch = key_info
-            new_data['key'] = tag_obj.tag
-            new_data['index'] = step
-            new_data['epoch'] = epoch
-            if data_type == "default":
-                return self.metric_callback(True, new_data)
-            # 解析数据，第一个元素为指标信息，第二个元素为指标名称key，第三个元素为指标类型，第四个元素为文件列表
-            # 将指标信息中每个文件的路径改为{key}/{filename}，方便后续上传
-            # 文件列表中的内容为绝对路径
-            key = quote(tag_obj.tag, safe="")
-            return self.metric_callback(False, (new_data, key, data_type, self.settings.static_dir))
+        # 调用回调函数
+        self.__callbacks.on_metric_create(tag_obj.tag, key_info, self.settings.static_dir)
 
 
 class SwanLabTag:
diff --git a/swanlab/data/run/main.py b/swanlab/data/run/main.py
index 623219a3..84cf3d43 100644
--- a/swanlab/data/run/main.py
+++ b/swanlab/data/run/main.py
@@ -17,21 +17,14 @@
 from swanlab.db import Experiment, ExistedError, NotExistedError
 from swanlab.data.modules import BaseType
 from swanlab.data.config import SwanLabConfig
-from swanlab.cloud import ThreadPool
-from swanlab.utils import FONT, create_time
-from swanlab.api import get_http
-from swanlab.api.upload import upload_logs
-import traceback
-import os
-import sys
 import time
 import random
-import atexit
 import ujson
 from enum import Enum
 from .exp import SwanLabExp
 from datetime import datetime
 from typing import Tuple, Callable, Optional, Dict
+from .callback import SwanLabRunCallback, EmptyCallback
 
 
 class SwanLabRunState(Enum):
@@ -58,8 +51,7 @@ def __init__(
         log_level: str = None,
         suffix: str = None,
         exp_num: int = None,
-        pool: ThreadPool = None,
-        callbacks: Dict[str, Callable] = None
+        callbacks: SwanLabRunCallback = None
     ):
         """
         Initializing the SwanLabRun class involves configuring the settings and initiating other logging processes.
@@ -84,8 +76,6 @@ def __init__(
             如果不提供此参数(为None)，不会添加后缀
         exp_num : int, optional
             历史实验总数，用于云端颜色与本地颜色的对应
-        pool : ThreadPool
-            线程池对象，用于云端同步，传入此对象，run.cloud将被设置为True
         callbacks : Dict[str, Callable]
             回调函数字典，key为回调函数名，value为回调函数
         """
@@ -99,6 +89,10 @@ def __init__(
         self.__run_id = "run-{}-{}".format(timestamp, _id)
         # 初始化配置
         self.__settings = SwanDataSettings(run_id=self.__run_id)
+        # 初始化回调
+        self.__callbacks: SwanLabRunCallback = callbacks if callbacks is not None else EmptyCallback()
+        # 注入依赖
+        self.__callbacks.inject(self.__settings)
         # ---------------------------------- 初始化日志记录器 ----------------------------------
         # output、console_dir等内容不依赖于实验名称的设置
         swanlog.install(self.__settings.console_dir, self.__check_log_level(log_level))
@@ -111,9 +105,6 @@ def __init__(
         self.__exp: SwanLabExp = self.__register_exp(experiment_name, description, suffix, num=exp_num)
         # 实验状态标记，如果status不为0，则无法再次调用log方法
         self.__state = SwanLabRunState.RUNNING
-        self.__pool = pool
-        # 设置回调函数
-        callbacks is not None and [setattr(self.__exp, key, call(self.pool)) for key, call in callbacks.items()]
 
         # 动态定义一个方法，用于修改实验状态
         def _(state: SwanLabRunState):
@@ -123,13 +114,12 @@ def _(state: SwanLabRunState):
         _change_run_state = _
         run = self
 
-    @property
-    def cloud(self) -> bool:
-        return self.__pool is not None
+        # ---------------------------------- 初始化完成 ----------------------------------
+        self.__callbacks.on_train_begin()
 
     @property
-    def pool(self) -> ThreadPool:
-        return self.__pool
+    def callbacks(self) -> SwanLabRunCallback:
+        return self.__callbacks
 
     @property
     def state(self) -> SwanLabRunState:
@@ -185,35 +175,12 @@ def finish(state: SwanLabRunState = SwanLabRunState.SUCCESS, error=None):
                 print(error, file=fError)
         else:
             error = None
-        # 触发云端退出
-        if run.cloud and not exiting_in_cloud:
-            _before_exit_in_cloud(state, error=error)
-        # 重置控制台记录器
+        # 退出回调
+        run.callbacks.on_train_end(error)
         swanlog.uninstall()
         _run, run = run, None
-        # 删除回调
-        atexit.unregister(clean_handler)
-        sys.excepthook = sys.__excepthook__
         return _run
 
-    def set_metric_callback(self, callback: Callable):
-        """
-        设置实验指标回调函数，当新指标出现时，会调用此函数，只能设置一次，否则出现ValueError
-        :param callback: 回调函数
-
-        :raises: ValueError - 如果已经设置过回调函数，再次设置会抛出异常
-        """
-        self.__exp.metric_callback = callback
-
-    def set_column_callback(self, callback: Callable):
-        """
-        设置实验列回调函数，当新列出现时，会调用此函数，只能设置一次，否则出现ValueError
-        :param callback: 回调函数
-
-        :raises: ValueError - 如果已经设置过回调函数，再次设置会抛出异常
-        """
-        self.__exp.column_callback = callback
-
     @property
     def settings(self) -> SwanDataSettings:
         """
@@ -390,7 +357,7 @@ def __register_exp(
         self.settings.description = description
         # 执行一些记录操作
         self.__record_exp_config()  # 记录实验配置
-        return SwanLabExp(self.__settings, exp.id, exp=exp)
+        return SwanLabExp(self.__settings, exp.id, exp=exp, callbacks=self.__callbacks)
 
     @staticmethod
     def __check_log_level(log_level: str) -> str:
@@ -432,10 +399,6 @@ def __record_exp_config(self):
 
 run: Optional["SwanLabRun"] = None
 """Global runtime instance. After the user calls finish(), run will be set to None."""
-exiting_in_cloud = False
-"""
-Indicates whether the program is exiting in the cloud environment.
-"""
 _change_run_state: Optional["Callable"] = None
 """
 修改实验状态的函数，用于在实验状态改变时调用
@@ -465,79 +428,3 @@ def get_run() -> Optional["SwanLabRun"]:
     """
     global run
     return run
-
-
-def _before_exit_in_cloud(state: SwanLabRunState, error: str = None):
-    """
-    在云端环境下，退出之前的处理，需要依次执行线程池中的回调
-
-    Parameters
-    ----------
-    state : SwanLabRunState
-        实验状态
-    """
-    global exiting_in_cloud
-    # 如果正在退出或者run对象为None或者不在云端环境下
-    if exiting_in_cloud or run is None or not run.cloud:
-        return
-    # 标志已经退出（需要在下面的逻辑之前标志）
-    exiting_in_cloud = True
-    sys.excepthook = except_handler
-
-    def _():
-        # 关闭线程池，等待上传线程完成
-        run.pool.finish()
-        # 上传错误日志
-        if error is not None:
-            msg = [{"message": error, "create_time": create_time(), "epoch": swanlog.epoch + 1}]
-            upload_logs(msg, level="ERROR")
-
-    FONT.loading("Waiting for uploading complete", _)
-    get_http().update_state(state == SwanLabRunState.SUCCESS)
-    exiting_in_cloud = False
-    return
-
-
-def clean_handler():
-    """
-    程序执行完毕后，清理资源，用于用户没有调用finish的情况
-    """
-    if run is None:
-        return swanlog.debug("SwanLab Runtime has been cleaned manually.")
-    # 如果正在运行，且当前没有正在退出云端环境
-    if run.is_running and not exiting_in_cloud:
-        swanlog.info("Experiment {} has completed".format(FONT.yellow(run.settings.exp_name)))
-        run.finish(SwanLabRunState.SUCCESS)
-    else:
-        swanlog.debug("Duplicate finish, ignore it.")
-
-
-def except_handler(tp, val, tb):
-    """
-    定义异常处理函数，用于程序异常退出时的处理
-    此函数触发在clean_handler之前
-    """
-    if run is None:
-        return swanlog.debug("SwanLab Runtime has been cleaned manually.")
-    if exiting_in_cloud:
-        # FIXME not a good way to fix '\n' problem
-        print("")
-        swanlog.error("Aborted uploading by user")
-        sys.exit(1)
-    # 如果是KeyboardInterrupt异常
-    if tp == KeyboardInterrupt:
-        swanlog.error("KeyboardInterrupt by user")
-    else:
-        swanlog.error("Error happened while training")
-    # 追踪信息
-    trace_list = traceback.format_tb(tb)
-    html = repr(tp) + "\n"
-    html += repr(val) + "\n"
-    for line in trace_list:
-        html += line + "\n"
-    if os.path.exists(run.settings.error_path):
-        swanlog.warning("Error log file already exists, append error log to it")
-    # 标记实验失败
-    run.finish(SwanLabRunState.CRASHED, error=html)
-    if tp != KeyboardInterrupt:
-        raise tp(val)
diff --git a/swanlab/data/sdk.py b/swanlab/data/sdk.py
index d7b8060a..5a4acba9 100644
--- a/swanlab/data/sdk.py
+++ b/swanlab/data/sdk.py
@@ -7,9 +7,7 @@
 @Description:
     在此处封装swanlab在日志记录模式下的各种接口
 """
-import atexit
 import os
-import sys
 from typing import Optional, Union, Dict
 from .modules import DataType
 from .run import (
@@ -17,23 +15,16 @@
     SwanLabRun,
     register,
     get_run,
-    except_handler,
-    clean_handler,
 )
+from .callback_cloud import CloudRunCallback
+from .callback_local import LocalRunCallback
 from .config import SwanLabConfig
-from .utils.file import check_dir_and_create, formate_abs_path
-from ..db import Project, connect, Experiment
-from ..env import init_env, ROOT, get_swanlab_folder
+from ..db import Project, connect
+from ..env import init_env, ROOT
 from ..log import swanlog
-from ..utils import FONT, check_load_json_yaml, check_proj_name_format
-from ..utils.key import get_key
-from ..utils.judgment import in_jupyter, show_button_html
-from swanlab.api import create_http, get_http, code_login, LoginInfo, terminal_login
-from swanlab.api.upload.model import ColumnModel
-from swanlab.package import version_limit, get_package_version, get_host_api, get_host_web
-from swanlab.error import KeyFileError, ApiError
-from swanlab.cloud import LogSnifferTask, ThreadPool
-from swanlab.cloud import UploadType
+from ..utils import check_load_json_yaml, check_proj_name_format
+from swanlab.api import code_login
+from swanlab.package import version_limit
 
 _config: Optional["SwanLabConfig"] = SwanLabConfig(None)
 """
@@ -44,12 +35,6 @@
 When the run object is initialized, it will operate on the SwanLabConfig object to write the configuration.
 """
 
-login_info = None
-"""
-Record user login information in the cloud environment.
-`swanlab.login` will assign or update this variable.
-"""
-
 
 def _check_proj_name(name: str) -> str:
     """检查项目名称是否合法，如果不合法则抛出ValueError异常
@@ -76,39 +61,6 @@ def _check_proj_name(name: str) -> str:
     return _name
 
 
-def _create_metric_callback(pool: ThreadPool):
-    """
-    创建指标回调函数
-    """
-
-    def _metric_callback(is_scalar: dict, data):
-        """
-        上传指标回调函数
-        :param is_scalar: 是否为标量指标
-        :param data: 上传的指标
-        """
-        pool.queue.put((UploadType.SCALAR_METRIC if is_scalar else UploadType.MEDIA_METRIC, [data]))
-
-    return _metric_callback
-
-
-def _create_column_callback(pool: ThreadPool):
-    """
-    创建列回调函数
-    """
-
-    def _column_callback(key, data_type: str, error: Optional[Dict] = None):
-        """
-        上传列信息回调函数
-        :param key: 列名
-        :param data_type: 列数据类型
-        :param error: 错误信息
-        """
-        pool.queue.put((UploadType.COLUMN, [ColumnModel(key, data_type.upper(), error)]))
-
-    return _column_callback
-
-
 def _is_inited():
     """检查是否已经初始化"""
     return get_run() is not None
@@ -127,8 +79,7 @@ def login(api_key: str = None):
     """
     if _is_inited():
         raise RuntimeError("You must call swanlab.login() before using init()")
-    global login_info
-    login_info = code_login(api_key) if api_key else _login_in_init()
+    CloudRunCallback.login_info = code_login(api_key) if api_key else CloudRunCallback.get_login_info()
 
 
 def init(
@@ -219,6 +170,8 @@ def init(
         cloud = _load_data(load_data, "cloud", cloud)
         project = _load_data(load_data, "project", project)
         workspace = _load_data(load_data, "workspace", workspace)
+    if not cloud and workspace is not None:
+        swanlog.warning("The `workspace` field is invalid in local mode")
     # 默认实验名称为当前目录名
     project = _check_proj_name(project if project else os.path.basename(os.getcwd()))
     # 初始化logdir参数，接下来logdir被设置为绝对路径且当前程序有写权限
@@ -229,29 +182,16 @@ def init(
     version_limit(logdir, mode="init")
     # 初始化环境变量
     init_env()
-    # 历史实验总数
-    exp_num = None
-    # ---------------------------------- 用户登录、格式、权限校验 ----------------------------------
-    global login_info
-    http, pool = None, None
-    if login_info is None and cloud:
-        # 用户登录
-        login_info = _login_in_init()
-    if cloud:
-        http = create_http(login_info)
-        exp_num = http.mount_project(project, workspace).history_exp_count
-        # 初始化、挂载线程池
-        pool = ThreadPool()
+    # 定义回调函数
+    callbacks = CloudRunCallback() if cloud else LocalRunCallback()
+    # ---------------------------------- 初始化项目 ----------------------------------
+    # 获取云端历史总数
+    exp_num = callbacks.before_init_project(project, workspace)
     # 连接本地数据库，要求路径必须存在，但是如果数据库文件不存在，会自动创建
     connect(autocreate=True)
     # 初始化项目数据库
     Project.init(project)
     # ---------------------------------- 实例化实验 ----------------------------------
-    # 如果是云端环境，设置回调函数
-    callbacks = (
-        None if not cloud else {"metric_callback": _create_metric_callback, "column_callback": _create_column_callback}
-    )
-
     # 注册实验
     run = register(
         experiment_name=experiment_name,
@@ -260,61 +200,8 @@ def init(
         log_level=kwargs.get("log_level", "info"),
         suffix=suffix,
         exp_num=exp_num,
-        pool=pool,
         callbacks=callbacks,
     )
-    # ---------------------------------- 注册实验，开启线程 ----------------------------------
-    if cloud:
-        # 注册实验信息
-        try:
-            get_http().mount_exp(
-                exp_name=run.settings.exp_name,
-                colors=run.settings.exp_colors,
-                description=run.settings.description,
-            )
-        except ApiError as e:
-            if e.resp.status_code == 409:
-                FONT.brush("", 50)
-                swanlog.error("The experiment name already exists, please change the experiment name")
-                Experiment.purely_delete(run_id=run.exp.db.run_id)
-                sys.exit(409)
-        sniffer = LogSnifferTask(run.settings.files_dir)
-        run.pool.create_thread(sniffer.task, name="sniffer", callback=sniffer.callback)
-
-        def _write_call_call(message):
-            pool.queue.put((UploadType.LOG, [message]))
-
-        swanlog.set_write_callback(_write_call_call)
-    # ---------------------------------- 异常处理、程序清理 ----------------------------------
-    sys.excepthook = except_handler
-    # 注册清理函数
-    atexit.register(clean_handler)
-    # ---------------------------------- 终端输出 ----------------------------------
-    if not cloud and workspace is not None:
-        swanlog.warning("The `workspace` field is invalid in local mode")
-    swanlog.debug("SwanLab Runtime has initialized")
-    swanlog.debug("SwanLab will take over all the print information of the terminal from now on")
-    swanlog.info("Tracking run with swanlab version " + get_package_version())
-    swanlog.info("Run data will be saved locally in " + FONT.magenta(FONT.bold(formate_abs_path(run.settings.run_dir))))
-    not cloud and swanlog.info("Experiment_name: " + FONT.yellow(run.settings.exp_name))
-    # 云端版本有一些额外的信息展示
-    cloud and swanlog.info("👋 Hi " + FONT.bold(FONT.default(login_info.username)) + ", welcome to swanlab!")
-    cloud and swanlog.info("Syncing run " + FONT.yellow(run.settings.exp_name) + " to the cloud")
-    swanlog.info(
-        "🌟 Run `"
-        + FONT.bold("swanlab watch -l {}".format(formate_abs_path(run.settings.swanlog_dir)))
-        + "` to view SwanLab Experiment Dashboard locally"
-    )
-    if cloud:
-        project_url = get_host_web() + f"/@{http.groupname}/{http.projname}"
-        experiment_url = project_url + f"/runs/{http.exp_id}"
-        swanlog.info("🏠 View project at " + FONT.blue(FONT.underline(project_url)))
-        swanlog.info("🚀 View run at " + FONT.blue(FONT.underline(experiment_url)))
-
-        # 在Jupyter Notebook环境下，显示按钮
-        if in_jupyter():
-            show_button_html(experiment_url)
-
     return run
 
 
@@ -356,21 +243,6 @@ def finish(state: SwanLabRunState = SwanLabRunState.SUCCESS, error=None):
     run.finish(state, error)
 
 
-def _login_in_init() -> LoginInfo:
-    """在init函数中登录"""
-    # 1. 如果没有登录，提示登录
-    # 2. 如果登录了，发起请求，如果请求失败，重新登录，返回步骤1
-    key = None
-    try:
-        key = get_key(os.path.join(get_swanlab_folder(), ".netrc"), get_host_api())[2]
-    except KeyFileError:
-        fd = sys.stdin.fileno()
-        # 不是标准终端，且非jupyter环境，无法控制其回显
-        if not os.isatty(fd) and not in_jupyter():
-            raise KeyFileError("The key file is not found, call `swanlab.login()` or use `swanlab login` ")
-    return terminal_login(key)
-
-
 def _init_logdir(logdir: str) -> str:
     """
     处理通过init传入的logdir存在的一些情况
@@ -378,7 +250,17 @@ def _init_logdir(logdir: str) -> str:
     # 如果传入了logdir，则将logdir设置为环境变量，代表日志文件存放的路径
     if logdir is not None:
         try:
-            logdir = check_dir_and_create(logdir)
+            if not isinstance(logdir, str):
+                raise ValueError("path must be a string")
+            if not os.path.isabs(logdir):
+                logdir = os.path.abspath(logdir)
+            # 如果创建失败，也是抛出IOError
+            try:
+                os.makedirs(logdir, exist_ok=True)
+            except Exception as e:
+                raise IOError(f"create path: {logdir} failed, error: {e}")
+            if not os.access(logdir, os.W_OK):
+                raise IOError(f"no write permission for path: {logdir}")
         except ValueError:
             raise ValueError("logdir must be a str.")
         except IOError:
@@ -386,7 +268,8 @@ def _init_logdir(logdir: str) -> str:
         os.environ[ROOT] = logdir
     # 如果没有传入logdir，则使用默认的logdir, 即当前工作目录下的swanlog文件夹，但是需要保证目录存在
     else:
-        logdir = os.path.abspath("swanlog")
+        logdir = os.environ.get(ROOT) or os.path.join(os.getcwd(), "swanlog")
+        logdir = os.path.abspath(logdir)
         try:
             os.makedirs(logdir, exist_ok=True)
             if not os.access(logdir, os.W_OK):
diff --git a/swanlab/data/utils/file.py b/swanlab/data/utils/file.py
deleted file mode 100644
index 1245a3d0..00000000
--- a/swanlab/data/utils/file.py
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-r"""
-@DATE: 2024-01-12 01:47:34
-@File: swanlab\data\utils\file.py
-@IDE: vscode
-@Description:
-    文件/格式检查和操作
-"""
-import os
-import platform
-import io
-
-
-def formate_abs_path(path: str) -> str:
-    """这主要针对windows环境，输入的绝对路径可能不包含盘符，这里进行补充
-    主要是用于打印效果
-    如果不是windows环境，直接返回path，相当于没有调用这个函数
-
-    Parameters
-    ----------
-    path : str
-        待转换的路径
-
-    Returns
-    -------
-    str
-        增加了盘符的路径
-    """
-    if platform.system() != "Windows":
-        return path
-    if not os.path.isabs(path):
-        return path
-    need_add = len(path) < 3 or path[1] != ":"
-    # 处理反斜杠, 保证路径的正确性
-    path = path.replace("/", "\\")
-    if need_add:
-        return os.path.join(os.getcwd()[:2], path)
-    return path
-
-
-def check_dir_and_create(path: str) -> str:
-    """检查路径是否是一个绝对路径文件夹
-    检查逻辑是：
-    1. 检查path输入是否是一个字符串，如果不是，抛出ValueError
-    2. 检查path是否是一个绝对路径，如果不是，转换为绝对路径
-    3. 创建文件夹，如果文件夹已经存在，则不做任何操作
-    4. 判断目标文件夹的权限，如果没有写入权限，则抛出IOError
-
-    Parameters
-    ----------
-    path : str
-        待检查的路径
-
-    Returns
-    -------
-    str
-        将path转换为绝对路径后返回
-
-    Raises
-    ------
-    ValueError
-        path不是一个字符串
-    IOError
-        path没有写权限
-    """
-    if not isinstance(path, str):
-        raise ValueError("path must be a string")
-    if not os.path.isabs(path):
-        path = os.path.abspath(path)
-    # 如果创建失败，也是抛出IOError
-    try:
-        os.makedirs(path, exist_ok=True)
-    except Exception as e:
-        raise IOError(f"create path: {path} failed, error: {e}")
-    if not os.access(path, os.W_OK):
-        raise IOError(f"no write permission for path: {path}")
-    return path
-
-
-def get_file_hash_path(file_path: str) -> str:
-    """计算并返回给定文件的SHA-256哈希值。"""
-    import hashlib
-
-    hash_sha256 = hashlib.sha256()
-    with open(file_path, "rb") as f:  # 以二进制读取模式打开文件
-        while chunk := f.read(8192):  # 读取文件的小块进行处理
-            hash_sha256.update(chunk)
-    return hash_sha256.hexdigest()
-
-
-def get_file_hash_numpy_array(array) -> str:
-    """计算并返回给定NumPy数组的SHA-256哈希值。"""
-    import hashlib
-
-    hash_sha256 = hashlib.sha256()
-    # 将NumPy数组转换为字节串，然后更新哈希值
-    hash_sha256.update(array.tobytes())
-    return hash_sha256.hexdigest()
-
-
-def get_file_hash_pil(image) -> str:
-    """计算并返回给定PIL.Image对象的SHA-256哈希值。"""
-    import hashlib
-
-    hash_sha256 = hashlib.sha256()
-    # 将图像转换为字节数据
-    with io.BytesIO() as buffer:
-        image.save(buffer, format="PNG")  # 可以选择其他格式，如'JPEG'
-        hash_sha256.update(buffer.getvalue())
-    return hash_sha256.hexdigest()
-
-
-def get_text_sha256_hash(text):
-    """计算并返回给定文本的SHA-256哈希值。"""
-    import hashlib
-
-    # 使用hashlib库创建一个sha256哈希对象
-    hash_object = hashlib.sha256()
-    # 对输入的文本进行编码，因为hashlib需要的是字节对象
-    hash_object.update(text.encode())
-    # 获取十六进制格式的哈希值
-    hex_dig = hash_object.hexdigest()
-    return hex_dig
diff --git a/swanlab/data/utils/model.py b/swanlab/data/utils/model.py
deleted file mode 100644
index cf8462e5..00000000
--- a/swanlab/data/utils/model.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import torch
-import torchvision
-
-
-def format_size(size):
-    # Define thresholds for units
-    K, M, B = 1e3, 1e6, 1e9
-    if size == 0:
-        return "0"
-    elif size < M:
-        return f"{size / K:.1f}K"
-    elif size < B:
-        return f"{size / M:.1f}M"
-    else:
-        return f"{size / B:.1f}B"
-
-
-def get_pytorch_model_info(model: torch.nn.Module) -> (str, list):
-    """
-    输入一个PyTorch Model对象，返回模型的总参数量（格式化为易读格式）以及每一层的名称、尺寸、精度、参数量、是否可训练。
-
-    :param model: PyTorch Model
-    :return: (总参数量dict[包括总参数量、可训练的总参数量、不可被训练的总参数量], 参数列表[包括每层的名称、尺寸和数据类型])
-    """
-
-    params_list = []
-    total_params = 0
-    total_params_non_trainable = 0
-
-    for name, param in model.named_parameters():
-        params_cout = param.numel()
-        trainable = param.requires_grad
-        params_list.append(
-            [
-                {
-                    "tensor": name,
-                    "shape": str(list(param.size())),
-                    "prediction": str(param.dtype).split(".")[-1],
-                    "params_count": str(params_cout),
-                    "trainable": str(trainable),
-                }
-            ]
-        )
-        total_params += params_cout
-        if not trainable:
-            total_params_non_trainable += params_cout
-
-    total_params_trainable = total_params - total_params_non_trainable
-
-    total_params_info = {
-        "total_params": format_size(total_params),
-        "total_params_trainable": format_size(total_params_trainable),
-        "total_params_non_trainable": format_size(total_params_non_trainable),
-    }
-
-    return total_params_info, params_list
-
-
-if __name__ == "__main__":
-    model = torchvision.models.resnet50(weights=None)
-    in_features = model.fc.in_features
-    model.fc = torch.nn.Linear(in_features, 2)
-
-    formatted_params, params_list = get_pytorch_model_info(model)
-
-    print(formatted_params)
-    for item in params_list:
-        print(item)
diff --git a/tutils/__init__.py b/tutils/__init__.py
index 0c36e2a3..50f527ea 100644
--- a/tutils/__init__.py
+++ b/tutils/__init__.py
@@ -31,3 +31,13 @@ def init_db():
     clear()
     connect(autocreate=True)
     Project.init(name="pytest-swanlab", description="测试swanlab")
+
+
+def open_dev_mode() -> str:
+    """
+    开启开发模式，此时会返回开发环境的api-key并且创建测试目录
+    :return: api-key
+    """
+    # 创建测试目录
+    os.makedirs(SWANLAB_LOG_DIR, exist_ok=True)
+    return CONFIG['api-key']


Test patch:
diff --git a/test/create_experiment.py b/test/create_experiment.py
index e004bd2c..3aa780f9 100644
--- a/test/create_experiment.py
+++ b/test/create_experiment.py
@@ -6,13 +6,19 @@
 @IDE: vscode
 @Description:
     创建一个文件，作为测试用例
+    为了不污染正式环境，在测试时需要切到测试/开发环境的配置文件
+    测试后的swanlog文件存放于test/temp/swanlog目录下
     WARNING 请勿随意修改此文件，以免影响测试效果
 """
+from tutils import open_dev_mode
 import swanlab
 import time
 import random
 import numpy as np
 
+# 手动登录，所以要求config内写入的是测试环境的配置
+swanlab.login(api_key=open_dev_mode())
+
 epochs = 50
 lr = 0.01
 offset = random.random() / 5
@@ -22,7 +28,7 @@
     log_level="debug",
     config="test/config/config.json",
     load="test/config/load.yaml",
-    # cloud=True,
+    cloud=True,
 )
 swanlab.config.epoches = epochs
 swanlab.config.learning_rate = lr