xflow.framework 0.1.0__py3-none-any.whl

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.
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'
xflow/framework/env.py ADDED
@@ -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
@@ -0,0 +1,112 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 入口脚本。
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import shutil
10
+
11
+ import click
12
+
13
+ from typing import Type
14
+ from importlib import import_module
15
+ from pathlib import Path
16
+
17
+ from typed_settings import click_options
18
+
19
+ from xflow.framework.version import __version__
20
+ from xflow.framework.common import INIT_DIR
21
+ from xflow.framework.pipeline import Pipeline, TResult
22
+ from xflow.framework.env import Env
23
+
24
+
25
+ class RunGroup(click.Group):
26
+ """
27
+ run 命令组。
28
+ """
29
+ def import_pipeline(self, projdir: str, pplname: str) -> Type[Pipeline]:
30
+ """
31
+ 导入 Pipeline。
32
+
33
+ :param projdir: 项目目录。
34
+ :param pplname: pipeline 名称。
35
+ """
36
+ projdir = str(Path(projdir).resolve())
37
+ if projdir not in sys.path:
38
+ sys.path.insert(0, projdir)
39
+ os.chdir(projdir)
40
+ pplmod = import_module(f'pipelines.{pplname}')
41
+ pplcls: Type[Pipeline] = getattr(pplmod, pplname)
42
+ return pplcls
43
+
44
+ def list_commands(self, ctx: click.Context):
45
+ """
46
+ 把 pipelines 目录下的 `.py` 文件名作为子命令(不递归)。
47
+ """
48
+ cmds = []
49
+ ppldir = os.path.join(ctx.obj['projdir'], 'pipelines')
50
+ for f in os.listdir(ppldir):
51
+ if f.endswith('.py') and f not in ('__init__.py', 'template.py'):
52
+ cmds.append(f.replace('.py', ''))
53
+ return cmds
54
+
55
+ def get_command(self, ctx: click.Context, cmd_name: str):
56
+ """
57
+ 返回执行 pipeline 的函数。
58
+ """
59
+ pplcls = self.import_pipeline(ctx.obj['projdir'], cmd_name)
60
+ @click.command(cmd_name)
61
+ @click_options(pplcls.Options, 'xflow')
62
+ @click.pass_context
63
+ def command(ctx: click.Context, options: Pipeline.Options):
64
+ env = Env(Path(ctx.obj['projdir']).joinpath('env.yml'))
65
+ pplinst = pplcls(ctx.obj['projdir'],
66
+ env,
67
+ ctx.obj['nodename'],
68
+ options)
69
+ result: TResult = pplinst.run()
70
+ if result == 'FAILED':
71
+ exit(1)
72
+ return command
73
+
74
+
75
+ @click.group()
76
+ @click.version_option(__version__)
77
+ @click.option('--projdir', '-p', required=True, envvar='XFLOW_PROJDIR', show_envvar=True)
78
+ @click.pass_context
79
+ def main(ctx: click.Context, projdir: str):
80
+ """
81
+ xflow
82
+ """
83
+ ctx.ensure_object(dict)
84
+ ctx.obj['projdir'] = projdir
85
+
86
+
87
+ @main.command()
88
+ @click.pass_context
89
+ def init(ctx: click.Context):
90
+ """
91
+ Initialize the project directory.
92
+ """
93
+ projdir = ctx.obj['projdir']
94
+ if os.path.exists(projdir) and os.listdir(projdir):
95
+ print(f'Error: {projdir} exists and is not empty.')
96
+ exit(1)
97
+ shutil.copytree(INIT_DIR, projdir, dirs_exist_ok=True)
98
+ print(f'Initialized {projdir}')
99
+
100
+
101
+ @main.group(cls=RunGroup)
102
+ @click.option('--nodename', '-n', required=True)
103
+ @click.pass_context
104
+ def run(ctx: click.Context, nodename: str):
105
+ """
106
+ Run a pipeline.
107
+ """
108
+ ctx.obj['nodename'] = nodename
109
+
110
+
111
+ if __name__ == '__main__':
112
+ main()