xflow.framework 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. xflow_framework-0.1.0/LICENSE +24 -0
  2. xflow_framework-0.1.0/MANIFEST.in +5 -0
  3. xflow_framework-0.1.0/PKG-INFO +43 -0
  4. xflow_framework-0.1.0/README.md +3 -0
  5. xflow_framework-0.1.0/pyproject.toml +3 -0
  6. xflow_framework-0.1.0/requirements.txt +6 -0
  7. xflow_framework-0.1.0/setup.cfg +4 -0
  8. xflow_framework-0.1.0/setup.py +67 -0
  9. xflow_framework-0.1.0/xflow/framework/__init__.py +0 -0
  10. xflow_framework-0.1.0/xflow/framework/common.py +14 -0
  11. xflow_framework-0.1.0/xflow/framework/container.py +292 -0
  12. xflow_framework-0.1.0/xflow/framework/env.py +72 -0
  13. xflow_framework-0.1.0/xflow/framework/errors.py +33 -0
  14. xflow_framework-0.1.0/xflow/framework/main.py +112 -0
  15. xflow_framework-0.1.0/xflow/framework/node.py +392 -0
  16. xflow_framework-0.1.0/xflow/framework/pipeline.py +189 -0
  17. xflow_framework-0.1.0/xflow/framework/ssh.py +372 -0
  18. xflow_framework-0.1.0/xflow/framework/statics/initdir/.gitignore +215 -0
  19. xflow_framework-0.1.0/xflow/framework/statics/initdir/README.md +0 -0
  20. xflow_framework-0.1.0/xflow/framework/statics/initdir/env.yml +41 -0
  21. xflow_framework-0.1.0/xflow/framework/statics/initdir/pipelines/__init__.py +0 -0
  22. xflow_framework-0.1.0/xflow/framework/statics/initdir/pipelines/example.py +60 -0
  23. xflow_framework-0.1.0/xflow/framework/statics/initdir/pipelines/template.py +32 -0
  24. xflow_framework-0.1.0/xflow/framework/statics/initdir/requirements.txt +1 -0
  25. xflow_framework-0.1.0/xflow/framework/statics/initdir/scripts/.gitkeep +0 -0
  26. xflow_framework-0.1.0/xflow/framework/statics/initdir/workdir/.gitkeep +0 -0
  27. xflow_framework-0.1.0/xflow/framework/utils.py +59 -0
  28. xflow_framework-0.1.0/xflow/framework/version.py +4 -0
  29. xflow_framework-0.1.0/xflow.framework.egg-info/PKG-INFO +43 -0
  30. xflow_framework-0.1.0/xflow.framework.egg-info/SOURCES.txt +32 -0
  31. xflow_framework-0.1.0/xflow.framework.egg-info/dependency_links.txt +1 -0
  32. xflow_framework-0.1.0/xflow.framework.egg-info/entry_points.txt +2 -0
  33. xflow_framework-0.1.0/xflow.framework.egg-info/requires.txt +8 -0
  34. xflow_framework-0.1.0/xflow.framework.egg-info/top_level.txt +3 -0
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2025, zhaowcheng
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,5 @@
1
+ recursive-include xflow/framework/statics *
2
+ prune tests
3
+ global-exclude *.py[cod]
4
+ include requirements.txt
5
+ include README*
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: xflow.framework
3
+ Version: 0.1.0
4
+ Summary: 可以用 Python 写 Pipeline 的 CICD 框架。
5
+ Home-page: https://github.com/zhaowcheng/xflow.framework
6
+ Author: zhaowcheng
7
+ Author-email: zhaowcheng@163.com
8
+ Project-URL: Homepage, https://github.com/zhaowcheng/xflow.framework
9
+ Project-URL: Issues, https://github.com/zhaowcheng/xflow.framework/issues
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.6
14
+ Classifier: Programming Language :: Python :: 3.7
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Python: >=3.6
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: ruamel.yaml; python_version >= "3.10"
24
+ Requires-Dist: paramiko; python_version >= "3.10"
25
+ Requires-Dist: decorator; python_version >= "3.10"
26
+ Requires-Dist: filelock; python_version >= "3.10"
27
+ Requires-Dist: docker; python_version >= "3.10"
28
+ Requires-Dist: typed-settings[click,option-groups,pydantic]; python_version >= "3.10"
29
+ Dynamic: author
30
+ Dynamic: author-email
31
+ Dynamic: classifier
32
+ Dynamic: description
33
+ Dynamic: description-content-type
34
+ Dynamic: home-page
35
+ Dynamic: license-file
36
+ Dynamic: project-url
37
+ Dynamic: requires-dist
38
+ Dynamic: requires-python
39
+ Dynamic: summary
40
+
41
+ # xflow.framework
42
+
43
+ 可以用 Python 写 Pipeline 的 CICD 框架。
@@ -0,0 +1,3 @@
1
+ # xflow.framework
2
+
3
+ 可以用 Python 写 Pipeline 的 CICD 框架。
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=40.6"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,6 @@
1
+ ruamel.yaml; python_version >= '3.10'
2
+ paramiko; python_version >= '3.10'
3
+ decorator; python_version >= '3.10'
4
+ filelock; python_version >= '3.10'
5
+ docker; python_version >= '3.10'
6
+ typed-settings[pydantic,click,option-groups]; python_version >= '3.10'
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ import os
4
+
5
+ from setuptools import setup, find_namespace_packages
6
+
7
+
8
+ BASEDIR = os.path.abspath(os.path.dirname(__file__))
9
+
10
+
11
+ def find_version():
12
+ verfile = os.path.join(BASEDIR, 'xflow', 'framework', 'version.py')
13
+ with open(verfile, 'r', encoding='utf8') as f:
14
+ for line in f.readlines():
15
+ if line.startswith('__version__'):
16
+ return line.split('=')[1].strip().strip("'").strip('"')
17
+
18
+
19
+ def find_requires():
20
+ reqfile = os.path.join(BASEDIR, 'requirements.txt')
21
+ with open(reqfile, 'r', encoding='utf8') as f:
22
+ return [r.strip() for r in f.readlines()]
23
+
24
+
25
+ def find_long_description():
26
+ with open('README.md', encoding='utf8') as f:
27
+ desc = f.read()
28
+ desc = desc.replace('/blob/master/',
29
+ f'/blob/v{find_version()}/')
30
+ return desc
31
+
32
+
33
+ setup(
34
+ name='xflow.framework',
35
+ version=find_version(),
36
+ description='可以用 Python 写 Pipeline 的 CICD 框架。',
37
+ long_description=find_long_description(),
38
+ long_description_content_type='text/markdown',
39
+ author='zhaowcheng',
40
+ author_email='zhaowcheng@163.com',
41
+ install_requires=find_requires(),
42
+ packages=find_namespace_packages(exclude=['tests', 'tests.*']),
43
+ entry_points={
44
+ 'console_scripts': [
45
+ 'xflow = xflow.framework.main:main'
46
+ ]
47
+ },
48
+ include_package_data=True,
49
+ classifiers=[
50
+ "License :: OSI Approved :: BSD License",
51
+ "Operating System :: OS Independent",
52
+ "Programming Language :: Python :: 3",
53
+ "Programming Language :: Python :: 3.6",
54
+ "Programming Language :: Python :: 3.7",
55
+ "Programming Language :: Python :: 3.8",
56
+ "Programming Language :: Python :: 3.9",
57
+ "Programming Language :: Python :: 3.10",
58
+ "Programming Language :: Python :: 3.11",
59
+ "Programming Language :: Python :: 3.12"
60
+ ],
61
+ url='https://github.com/zhaowcheng/xflow.framework',
62
+ python_requires='>=3.6',
63
+ project_urls={
64
+ 'Homepage': 'https://github.com/zhaowcheng/xflow.framework',
65
+ 'Issues': 'https://github.com/zhaowcheng/xflow.framework/issues'
66
+ }
67
+ )
File without changes
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 公共变量/常量。
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+
10
+ XFLOW_DIR: Path = Path(__file__).parent
11
+
12
+ STATICS_DIR = XFLOW_DIR.joinpath('statics')
13
+
14
+ INIT_DIR = STATICS_DIR.joinpath('initdir')
@@ -0,0 +1,292 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 容器模块。
5
+ """
6
+
7
+ import tarfile
8
+ import io
9
+ import os
10
+
11
+ from typing import Generator, Optional, Union, Dict
12
+ from select import select
13
+ from socket import SocketIO
14
+ from contextlib import contextmanager
15
+ from pathlib import Path, PurePosixPath
16
+ from functools import cached_property
17
+
18
+ from decorator import decorator
19
+ from docker.client import DockerClient
20
+ from docker.models.containers import Container
21
+ from docker.tls import TLSConfig
22
+
23
+ from xflow.framework.errors import CommandError
24
+ from xflow.framework.ssh import CommandResult
25
+
26
+
27
+ @decorator
28
+ def autopen(func, *args, **kwargs):
29
+ """
30
+ 自动连接器。
31
+ """
32
+ conn: ContainerConnection = args[0]
33
+ conn.open()
34
+ return func(*args, **kwargs)
35
+
36
+
37
+ class ContainerConnection(object):
38
+ """
39
+ 容器连接。
40
+ """
41
+ def __init__(
42
+ self,
43
+ ip: str,
44
+ port: int = 2375,
45
+ user: Optional[str] = None,
46
+ name: Optional[str] = None,
47
+ image: Optional[str] = None,
48
+ runargs: Optional[Dict] = None,
49
+ cacert: Optional[str] = None,
50
+ clientcert: Optional[str] = None,
51
+ clientkey: Optional[str] = None,
52
+ envs: Optional[Dict[str, str]] = None
53
+ ):
54
+ """
55
+ :param ip: IP 地址。
56
+ :param port: Docker 服务端口。
57
+ :param user: 用户名。
58
+ :param name: 容器名,和 `image` 为互斥参数,必须且只能指定其中一个。
59
+ :param image: 镜像名,和 `name` 为互斥参数,必须且只能指定其中一个,
60
+ 如果该参数指定,则每次连接用该镜像创建一个新容器。
61
+ :param runargs: 使用镜像启动容器时的参数。
62
+ :param cacert: CA 证书。
63
+ :param clientcert: 客户端证书,当指定 `clientkey` 参数时必须同时指定该参数。
64
+ :param clientkey: 客户端私钥,当指定 `clientcert` 参数时必须同时指定该参数。
65
+ :param envs: 连接的默认环境变量,其中:
66
+ `LANG` 默认值为 `en_US.UTF-8`。
67
+ `LANGUAGE` 默认值为 `en_US.UTF-8`。
68
+ """
69
+ # 必须且只能指定 `name` 和 `image` 参数中的一个。
70
+ if (name == None and image == None) or \
71
+ (name != None and image != None):
72
+ raise ValueError('Must specify only one of `name` and `image`.')
73
+ # `clientcert` 指定的同时必须指定 `clientkey`,反之亦然。
74
+ if (clientcert != None and clientkey == None) or \
75
+ (clientcert == None and clientkey != None):
76
+ raise ValueError('`clientcert` must be specified along with `clientkey`, and vice versa.')
77
+ self._ip = ip
78
+ self._port = port
79
+ self._user = user
80
+ self._envs = envs or {}
81
+ self._name = name
82
+ self._image = image
83
+ self._runargs = runargs or {}
84
+ self._cacert = cacert
85
+ self._clientcert = clientcert
86
+ self._clientkey = clientkey
87
+ self._dockerclient: DockerClient = None
88
+ self._container: Container = None
89
+ self._cwd: str = ''
90
+ for k, v in {'LANG': 'en_US.UTF-8',
91
+ 'LANGUAGE': 'en_US.UTF-8'}.items():
92
+ self._envs.setdefault(k, v)
93
+
94
+ @property
95
+ def _connstr(self) -> str:
96
+ """
97
+ 连接字符串。
98
+ """
99
+ if self._user:
100
+ prefix = f'docker://{self._user}@{self._ip}:{self._port}'
101
+ else:
102
+ prefix = f'docker://{self._ip}:{self._port}'
103
+ if self._name:
104
+ return f'{prefix}:{self._name}'
105
+ else: # 从镜像自动创建容器
106
+ if self._container:
107
+ # 容器已创建
108
+ return f'{prefix}:{self._image}->{self._container.name}'
109
+ else:
110
+ # 容器未创建
111
+ return f'{prefix}:{self._image}->...'
112
+
113
+ @cached_property
114
+ def _uid(self) -> int:
115
+ """
116
+ 用户 id。
117
+ """
118
+ return int(self.exec('id -u'))
119
+
120
+ def open(self) -> None:
121
+ """
122
+ 开启连接。
123
+ """
124
+ if self._container:
125
+ return
126
+ print(f'[{self._connstr}] Connecting...')
127
+ if self._cacert or self._clientcert:
128
+ tls = TLSConfig(
129
+ ca_cert=self._cacert,
130
+ verify=True if self._cacert else False,
131
+ client_cert=(self._clientcert, self._clientkey) if self._clientcert else None
132
+ )
133
+ else:
134
+ tls = False
135
+ self._dockerclient = DockerClient(base_url=f'tcp://{self._ip}:{self._port}', tls=tls)
136
+ if self._name:
137
+ self._container = self._dockerclient.containers.get(self._name)
138
+ else:
139
+ self._container = self._dockerclient.containers.run(self._image, detach=True, **self._runargs)
140
+ print(f'[{self._connstr}] Connected')
141
+
142
+ def close(self) -> None:
143
+ """
144
+ 关闭连接。
145
+ """
146
+ self._dockerclient.close()
147
+
148
+ def remove(self, force: bool = False) -> None:
149
+ """
150
+ 删除本容器。
151
+
152
+ :param force: 是否强制删除正在运行的容器。
153
+ """
154
+ self._container.remove(force=force)
155
+ print(f'[{self._connstr}] Removed container {self._container.name}')
156
+
157
+ @property
158
+ def existed(self) -> bool:
159
+ """
160
+ 是否一个已存在的容器(即不是从镜像临时创建的)。
161
+ """
162
+ return not self._image
163
+
164
+ @autopen()
165
+ def exec(
166
+ self,
167
+ cmd: str,
168
+ envs: Optional[Dict[str, str]] = None
169
+ ) -> CommandResult:
170
+ """
171
+ 执行命令。
172
+
173
+ :param cmd: 被执行的命令。
174
+ :param envs: 环境变量。
175
+ :return: 命令输出。
176
+
177
+ :raises:
178
+ `CommandError` -- 命令返回码不为 0。
179
+
180
+ >>> exec('ls /home') # successful # doctest: +SKIP
181
+ >>> exec('ls /errpath') # CommandError # doctest: +SKIP
182
+ """
183
+ envs = envs or {}
184
+ environment = self._envs.copy()
185
+ environment.update(envs)
186
+ print(f'[{self._connstr}:{self._cwd or "~"}] {cmd}')
187
+ cmdid = self._dockerclient.api.exec_create(
188
+ self._container.id,
189
+ # 使用 bash -c 的方式是为了避免 docker exec api 报 "cd not found" 的问题
190
+ 'bash -c "' + (f'cd {self._cwd} && {cmd}' if self._cwd else cmd) + '"',
191
+ stdout=True,
192
+ stderr=True,
193
+ stdin=True,
194
+ tty=True,
195
+ environment=environment,
196
+ user=self._user
197
+ )['Id']
198
+ sock: SocketIO = self._dockerclient.api.exec_start(cmdid, tty=True, socket=True)
199
+ encoding = environment['LANG'].split('.')[-1]
200
+ output = ''
201
+ while True:
202
+ rlist, _, _ = select([sock], [], [], 0.1)
203
+ if sock in rlist:
204
+ data = sock.read(1024).decode(encoding=encoding, errors='ignore')
205
+ if data == '':
206
+ break
207
+ output += data
208
+ print(data, end='')
209
+ rc = self._dockerclient.api.exec_inspect(cmdid)['ExitCode']
210
+ if rc != 0:
211
+ raise CommandError(f'ExitCode {rc}: `{cmd}`')
212
+ return CommandResult(output, rc=rc, cmd=cmd)
213
+
214
+ @contextmanager
215
+ def dir(self, path: str | PurePosixPath) -> Generator[None, None, None]:
216
+ """
217
+ 切换工作目录。
218
+
219
+ >>> with dir('/my/workdir'): # doctest: +SKIP
220
+ ... d = exec('pwd') # doctest: +SKIP
221
+ ... # doctest: +SKIP
222
+ >>> d # doctest: +SKIP
223
+ '/my/workdir' # doctest: +SKIP
224
+ """
225
+ try:
226
+ self._cwd = str(path)
227
+ yield
228
+ finally:
229
+ self._cwd = ''
230
+
231
+ @autopen()
232
+ def getfile(
233
+ self,
234
+ rfile: Union[str, PurePosixPath],
235
+ ldir: Union[str, Path]
236
+ ) -> None:
237
+ """
238
+ 从容器下载文件 `rfile` 到本地目录 `ldir`。
239
+
240
+ :param rfile: 容器内文件。
241
+ :param ldir: 本地目录。
242
+
243
+ >>> getfile('/tmp/myfile', '/home') # /home/myfile
244
+ >>> getfile('/tmp/myfile', 'D:\\') # D:\\myfile
245
+ """
246
+ rfile = PurePosixPath(rfile)
247
+ ldir = Path(ldir)
248
+ lfile = ldir.joinpath(rfile.name)
249
+ ltarfile = ldir.joinpath(f'{rfile.name}.tar')
250
+ print(f'[{self._connstr}] Get {lfile} <= {rfile}')
251
+ stream, _ = self._container.get_archive(str(rfile))
252
+ with open(ltarfile, 'wb') as f:
253
+ for chunk in stream:
254
+ f.write(chunk)
255
+ with tarfile.open(ltarfile) as tar:
256
+ tar.extractall(ldir)
257
+ os.remove(str(ltarfile))
258
+
259
+ @autopen()
260
+ def putfile(
261
+ self,
262
+ lfile: Union[str, Path],
263
+ rdir: Union[str, PurePosixPath]
264
+ ) -> None:
265
+ """
266
+ 上传本地文件 `lfile` 到容器目录 `rdir`。
267
+
268
+ :param lfile: 本地文件。
269
+ :param rdir: 容器内目录。
270
+
271
+ >>> putfile('/home/myfile', '/tmp') # /tmp/myfile
272
+ >>> putfile('D:\\myfile', '/tmp') # /tmp/myfile
273
+ """
274
+ lfile = Path(lfile)
275
+ rdir = PurePosixPath(rdir)
276
+ rfile = rdir.joinpath(lfile.name)
277
+ print(f'[{self._connstr}] Put {lfile} => {rfile}')
278
+ stream = io.BytesIO()
279
+ with tarfile.open(fileobj=stream, mode='w') as tar:
280
+ tarinfo = tar.gettarinfo(str(lfile), arcname=lfile.name)
281
+ tarinfo.uid = self._uid
282
+ with open(lfile, 'rb') as f:
283
+ tar.addfile(tarinfo, f)
284
+ stream.seek(0)
285
+ self._container.put_archive(str(rdir), stream)
286
+
287
+ @autopen()
288
+ def exists(self, path: Union[str, PurePosixPath]) -> bool:
289
+ """
290
+ 检查远端路径是否存在。
291
+ """
292
+ return self.exec(f'test -e {path} && echo true || echo flase') == 'true'
@@ -0,0 +1,72 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 环境信息管理。
5
+ """
6
+
7
+ from typing import List
8
+ from pathlib import Path
9
+
10
+ from ruamel import yaml
11
+
12
+ from xflow.framework.node import Node, NativeNode, ContainerNode
13
+ from xflow.framework.errors import NoSuchNodeError, NoSuchDockerError
14
+
15
+
16
+ class Env(object):
17
+ """
18
+ 环境信息管理类。
19
+ """
20
+ def __init__(self, envfile: str | Path):
21
+ """
22
+ :param envfile: 环境信息文件。
23
+ """
24
+ with open(envfile, encoding='utf8') as f:
25
+ self.__data = yaml.YAML(typ='safe').load(f)
26
+ self.__nodes: List[Node] = []
27
+ for nodeinfo in self.__data['nodes']:
28
+ if nodeinfo['docker']:
29
+ for dockerinfo in self.__data['dockers']:
30
+ if dockerinfo['name'] == nodeinfo['docker']:
31
+ self.__nodes.append(
32
+ ContainerNode(
33
+ name=nodeinfo['name'],
34
+ ip=dockerinfo['ip'],
35
+ port=dockerinfo['port'],
36
+ bwd=nodeinfo['workdir'],
37
+ user=nodeinfo['user'],
38
+ container=nodeinfo['container'],
39
+ image=nodeinfo['image'],
40
+ runargs=nodeinfo['runargs'],
41
+ cacert=dockerinfo['tls']['cacert'],
42
+ clientcert=dockerinfo['tls']['clientcert'],
43
+ clientkey=dockerinfo['tls']['clientkey'],
44
+ envs=nodeinfo['envs']
45
+ )
46
+ )
47
+ break
48
+ else:
49
+ raise NoSuchDockerError(f'name: {nodeinfo["docker"]}')
50
+ else:
51
+ self.__nodes.append(
52
+ NativeNode(
53
+ name=nodeinfo['name'],
54
+ ip=nodeinfo['ip'],
55
+ sshport=nodeinfo['sshport'],
56
+ user=nodeinfo['user'],
57
+ password=nodeinfo['password'],
58
+ bwd=nodeinfo['workdir'],
59
+ envs=nodeinfo['envs']
60
+ )
61
+ )
62
+
63
+ def get_node(self, name: str) -> Node:
64
+ """
65
+ 获取节点。
66
+
67
+ :param name: 节点名。
68
+ """
69
+ for node in self.__nodes:
70
+ if node.name == name:
71
+ return node
72
+ raise NoSuchNodeError(f'name: {name}')
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 异常类。
5
+ """
6
+
7
+
8
+ class SSHConnectError(Exception):
9
+ """
10
+ SSH 连接错误。
11
+ """
12
+ pass
13
+
14
+
15
+ class CommandError(Exception):
16
+ """
17
+ 命令执行错误。
18
+ """
19
+ pass
20
+
21
+
22
+ class NoSuchNodeError(Exception):
23
+ """
24
+ 无此节点。
25
+ """
26
+ pass
27
+
28
+
29
+ class NoSuchDockerError(Exception):
30
+ """
31
+ 无此 Docker。
32
+ """
33
+ pass