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.
@@ -0,0 +1,392 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 节点。
5
+ """
6
+ import os
7
+ import shutil
8
+ import inspect
9
+ import tempfile
10
+
11
+ from pathlib import PurePosixPath, Path
12
+
13
+ from typing import Generator, Dict, Optional, Union, Literal
14
+ from contextlib import contextmanager
15
+
16
+ from xflow.framework.ssh import SSHConnection
17
+ from xflow.framework.container import ContainerConnection
18
+ from xflow.framework.ssh import CommandResult
19
+ from xflow.framework.pipeline import Pipeline
20
+
21
+
22
+ class Node(object):
23
+ """
24
+ 节点基类。
25
+ """
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ user: str,
30
+ bwd: str,
31
+ conn: Union[SSHConnection, ContainerConnection],
32
+ envs: Optional[Dict[str, str]] = None
33
+ ):
34
+ """
35
+ :param name: 名称。
36
+ :param user: 用户名。
37
+ :param bwd: 基础工作目录。
38
+ :param envs: 环境变量。
39
+ """
40
+ self.__name = name
41
+ self.__user = user
42
+ self.__envs = envs
43
+ self.__bwd: PurePosixPath = PurePosixPath(bwd)
44
+ self.__cwd: PurePosixPath = PurePosixPath('')
45
+ self.__conn = conn
46
+ self.__nixenv = {}
47
+
48
+ @property
49
+ def name(self) -> str:
50
+ """
51
+ 节点名。
52
+ """
53
+ return self.__name
54
+
55
+ @property
56
+ def bwd(self) -> PurePosixPath:
57
+ """
58
+ 基础工作目录。
59
+ """
60
+ return self.__bwd
61
+
62
+ @property
63
+ def cwd(self) -> PurePosixPath:
64
+ """
65
+ 当前工作目录(与 pipeline 相关)。
66
+ """
67
+ # 如果已指定绝对路径,则直接返回,否则自动拼接。
68
+ if self.__cwd.is_absolute():
69
+ return self.__cwd
70
+ p = self.pipeline
71
+ return self.bwd.joinpath(p.name, f'{p.buildid}', self.__cwd)
72
+
73
+ @property
74
+ def scriptdir(self) -> PurePosixPath:
75
+ """
76
+ 脚本存放目录。
77
+ """
78
+ return self.cwd.joinpath('scripts')
79
+
80
+ @property
81
+ def pipeline(self) -> 'Pipeline':
82
+ """
83
+ 当前 pipeline。
84
+ """
85
+ for frame_info in inspect.stack():
86
+ frame = frame_info.frame
87
+ for obj in frame.f_locals.values():
88
+ if hasattr(obj, '__class__') and issubclass(obj.__class__, Pipeline):
89
+ return obj
90
+
91
+ @property
92
+ def is_native(self) -> bool:
93
+ """
94
+ 是否原生节点。
95
+ """
96
+ return self.__class__.__name__ == 'NativeNode'
97
+
98
+ @property
99
+ def is_container(self) -> bool:
100
+ """
101
+ 是否容器节点。
102
+ """
103
+ return self.__class__.__name__ == 'ContainerNode'
104
+
105
+ @property
106
+ def existed(self) -> bool:
107
+ """
108
+ 是否一个已存在的容器(即不是从镜像临时创建的)。
109
+ """
110
+ return self.__conn.existed
111
+
112
+ def remove(self, force: bool = False) -> None:
113
+ """
114
+ 删除本容器。
115
+
116
+ :param force: 是否强制删除正在运行的容器。
117
+ """
118
+ self.__conn.remove(force=force)
119
+
120
+ def mkcwd(self) -> None:
121
+ """
122
+ 创建当前工作目录。
123
+ """
124
+ self.__conn.exec(f'mkdir -p {self.cwd}')
125
+
126
+ def rmcwd(self) -> None:
127
+ """
128
+ 删除当前工作目录。
129
+ """
130
+ self.__conn.exec(f'rm -rf {self.cwd}')
131
+
132
+ @contextmanager
133
+ def dir(self, path: str | PurePosixPath) -> Generator[None, None, None]:
134
+ """
135
+ 切换工作目录。
136
+
137
+ >>> with dir('/my/workdir'): # doctest: +SKIP
138
+ ... d = exec('pwd') # doctest: +SKIP
139
+ ... # doctest: +SKIP
140
+ >>> d # doctest: +SKIP
141
+ '/my/workdir' # doctest: +SKIP
142
+ """
143
+ try:
144
+ self.__cwd = PurePosixPath(path)
145
+ yield
146
+ finally:
147
+ self.__cwd = PurePosixPath('')
148
+
149
+ @contextmanager
150
+ def nixenv(
151
+ self,
152
+ flake: str | PurePosixPath,
153
+ system: Optional[Literal['x86_64-linux',
154
+ 'aarch64-linux',
155
+ 'loongarch64-linux',
156
+ 'mips64el-linux']] = None,
157
+ name: str = 'default',
158
+ options: Optional[str] = None
159
+ ) -> Generator[None, None, None]:
160
+ """
161
+ 使用 nix develop 进入一个 nix shell 环境。
162
+
163
+ :param flake: flake(目录)路径。
164
+ :param system: 系统平台,为空则为当前系统平台。
165
+ :param name: 环境名称。
166
+ :param options: nix develop 命令选项。
167
+ """
168
+ try:
169
+ self.__nixenv.update(
170
+ {
171
+ 'flake': flake,
172
+ 'system': system,
173
+ 'name': name,
174
+ 'options': options
175
+ }
176
+ )
177
+ yield
178
+ finally:
179
+ self.__nixenv.clear()
180
+
181
+ def exec(
182
+ self,
183
+ cmd: str,
184
+ envs: Optional[Dict[str, str]] = None
185
+ ) -> CommandResult:
186
+ """
187
+ 执行命令。
188
+
189
+ :param cmd: 被执行的命令。
190
+ :param envs: 环境变量。
191
+ :return: 命令输出。
192
+
193
+ :raises:
194
+ `CommandError` -- 命令返回码不为 0。
195
+
196
+ >>> exec('ls /home') # successful # doctest: +SKIP
197
+ >>> exec('ls /errpath') # CommandError # doctest: +SKIP
198
+ """
199
+ if self.__nixenv:
200
+ flake = self.__nixenv['flake']
201
+ system = self.__nixenv['system']
202
+ name = self.__nixenv['name']
203
+ options = self.__nixenv['options']
204
+ if system:
205
+ attr = f'devShells.{system}.{name}'
206
+ else:
207
+ attr = name
208
+ options = (options or '') + f' --log-format raw -c {cmd}'
209
+ cmd = f'nix develop {flake}#{attr} {options.strip()}'
210
+ with self.__conn.dir(self.cwd):
211
+ return self.__conn.exec(cmd, envs=envs)
212
+
213
+ def exec_script(
214
+ self,
215
+ script: str | Path,
216
+ argstr: str = '',
217
+ envs: Optional[Dict[str, str]] = None
218
+ ) -> CommandResult:
219
+ """
220
+ 上传并执行脚本。
221
+
222
+ :param script: 本地脚本路径(相对项目目录)。
223
+ :param argstr: 脚本参数。
224
+ :param envs: 环境变量。
225
+ :return: 脚本输出。
226
+
227
+ :raises:
228
+ `CommandError` -- 返回码不为 0。
229
+
230
+ >>> exec_script('scripts/my_script.sh') # doctest: +SKIP
231
+ """
232
+ lscript = Path(script).absolute()
233
+ rscript = self.scriptdir.joinpath(lscript.name)
234
+ rdir = rscript.parent
235
+ if not self.exists(rscript):
236
+ if not self.exists(rdir):
237
+ self.__conn.exec(f'mkdir -p {rdir}')
238
+ self.putfile(lscript, rdir)
239
+ self.__conn.exec(f'chmod +x {rscript}')
240
+ return self.exec(f'{rscript} {argstr}', envs=envs)
241
+
242
+ def getfile(
243
+ self,
244
+ rfile: Union[str, PurePosixPath],
245
+ ldir: Union[str, Path]
246
+ ) -> None:
247
+ """
248
+ 从远端下载文件 `rfile` 到本地目录 `ldir`。
249
+
250
+ :param rfile: 远端文件。
251
+ :param ldir: 本地目录。
252
+
253
+ >>> getfile('/tmp/myfile', '/home') # /home/myfile
254
+ >>> getfile('/tmp/myfile', 'D:\\') # D:\\myfile
255
+ """
256
+ self.__conn.getfile(rfile, ldir)
257
+
258
+ def putfile(
259
+ self,
260
+ lfile: Union[str, Path],
261
+ rdir: Union[str, PurePosixPath]
262
+ ) -> None:
263
+ """
264
+ 上传本地文件 `lfile` 到远端目录 `rdir`。
265
+
266
+ :param lfile: 本地文件。
267
+ :param rdir: 远端目录。
268
+
269
+ >>> putfile('/home/myfile', '/tmp') # /tmp/myfile
270
+ >>> putfile('D:\\myfile', '/tmp') # /tmp/myfile
271
+ """
272
+ return self.__conn.putfile(lfile, rdir)
273
+
274
+ def exists(self, path: Union[str, PurePosixPath]) -> bool:
275
+ """
276
+ 检查远端路径是否存在。
277
+ """
278
+ return self.__conn.exists(path)
279
+
280
+ def write(self, text: str, rfile: Union[str, PurePosixPath]) -> None:
281
+ """
282
+ 写入一个文本文件到远端路径 `path`。
283
+
284
+ :param text: 要写入的文本。
285
+ :param rfile: 远端文件路径。
286
+ """
287
+ rfile = PurePosixPath(rfile)
288
+ filename = rfile.name
289
+ rdir = rfile.parent
290
+ tmpdir = Path(tempfile.mkdtemp())
291
+ lfile = tmpdir.joinpath(filename)
292
+ with open(lfile, 'w') as f:
293
+ f.write(text)
294
+ self.putfile(lfile, rdir)
295
+ shutil.rmtree(tmpdir)
296
+
297
+ def git(
298
+ self,
299
+ repourl: str,
300
+ revision: Optional[str] = None,
301
+ directory: Optional[str | PurePosixPath] = None,
302
+ options: Optional[str] = None
303
+ ) -> None:
304
+ """
305
+ git clone 一个仓库。
306
+
307
+ :param repourl: 仓库地址。
308
+ :param revision: 分支、tag 或 commit。
309
+ :param directory: 克隆的目标目录。
310
+ :param options: git clone 命令选项。
311
+ """
312
+ humanish = repourl.split('/')[-1].replace('.git', '')
313
+ directory = directory or humanish
314
+ self.exec(f'git clone {options or ""} {repourl} {directory}')
315
+ if revision:
316
+ with self.dir(directory):
317
+ self.exec(f'git checkout {revision}')
318
+
319
+
320
+ class NativeNode(Node):
321
+ """
322
+ 原生节点(非容器)。
323
+ """
324
+ def __init__(
325
+ self,
326
+ name: str,
327
+ ip: str,
328
+ sshport: int,
329
+ user: str,
330
+ password: str,
331
+ bwd: str,
332
+ envs: Optional[Dict[str, str]] = None
333
+ ):
334
+ """
335
+ :param name: 名称。
336
+ :param ip: IP 地址。
337
+ :param sshport: SSH 端口。
338
+ :param user: 用户名。
339
+ :param password: 用户密码。
340
+ :param bwd: 基础工作目录。
341
+ :param envs: 环境变量。
342
+ """
343
+ conn = SSHConnection(ip, user, password, port=sshport, envs=envs)
344
+ super().__init__(name, user, bwd, conn, envs=envs)
345
+
346
+
347
+ class ContainerNode(Node):
348
+ """
349
+ 容器节点。
350
+ """
351
+ def __init__(
352
+ self,
353
+ name: str,
354
+ ip: str,
355
+ port: int,
356
+ bwd: str,
357
+ user: Optional[str] = None,
358
+ container: Optional[str] = None,
359
+ image: Optional[str] = None,
360
+ runargs: Optional[Dict] = None,
361
+ cacert: Optional[str] = None,
362
+ clientcert: Optional[str] = None,
363
+ clientkey: Optional[str] = None,
364
+ envs: Optional[Dict[str, str]] = None
365
+ ):
366
+ """
367
+ :param name: 名称。
368
+ :param ip: IP 地址。
369
+ :param port: Docker 服务端口。
370
+ :param user: 用户名。
371
+ :param bwd: 基础工作目录。
372
+ :param container: 容器名,和 `image` 为互斥参数,必须且只能指定其中一个。
373
+ :param image: 镜像名,和 `container` 为互斥参数,必须且只能指定其中一个,
374
+ 如果该参数指定,则每次连接用该镜像创建一个新容器。
375
+ :param runargs: 使用镜像启动容器时的参数。
376
+ :param cacert: CA 证书。
377
+ :param clientcert: 客户端证书,当指定 `clientkey` 参数时必须同时指定该参数。
378
+ :param clientkey: 客户端私钥,当指定 `clientcert` 参数时必须同时指定该参数。
379
+ :param envs: 环境变量。
380
+ """
381
+ conn = ContainerConnection(ip,
382
+ port=port,
383
+ user=user,
384
+ name=container,
385
+ image=image,
386
+ runargs=runargs,
387
+ cacert=cacert,
388
+ clientcert=clientcert,
389
+ clientkey=clientkey,
390
+ envs=envs)
391
+ super().__init__(name, user, bwd, conn, envs=envs)
392
+
@@ -0,0 +1,189 @@
1
+ # Copyright (c) 2025-2026, zhaowcheng <zhaowcheng@163.com>
2
+
3
+ """
4
+ 流水线模块。
5
+ """
6
+
7
+ import re
8
+ import traceback
9
+
10
+ import click
11
+
12
+ from typing import List, Literal, Any, Optional, Iterable, TYPE_CHECKING, Union, get_args, get_origin
13
+ from pathlib import Path
14
+
15
+ from filelock import FileLock
16
+ from pydantic import BaseModel
17
+ from pydantic.fields import FieldInfo
18
+
19
+ if TYPE_CHECKING:
20
+ from xflow.framework.env import Env
21
+
22
+ TResult = Literal['FAILED', 'SUCCESSFUL']
23
+
24
+
25
+ class Pipeline(object):
26
+ """
27
+ 流水线。
28
+ """
29
+ class Option(FieldInfo):
30
+ """
31
+ 流水线参数。
32
+ """
33
+ def __init__(
34
+ self,
35
+ desc: Optional[str] = None,
36
+ default: Any = None,
37
+ choices: Optional[Iterable] = None
38
+ ):
39
+ """
40
+ :param desc: 描述。
41
+ :param default: 默认值。
42
+ :param choices: 枚举值。
43
+ """
44
+ kwargs = {}
45
+ if desc is not None:
46
+ kwargs['description'] = desc
47
+ if default is not None:
48
+ kwargs['default'] = default
49
+ if choices is not None:
50
+ kwargs.update(
51
+ {
52
+ 'json_schema_extra': {
53
+ 'typed-settings': {
54
+ 'click': {
55
+ 'type': click.Choice(choices)
56
+ }
57
+ }
58
+ }
59
+ }
60
+ )
61
+ super().__init__(**kwargs)
62
+
63
+
64
+ class Options(BaseModel):
65
+ """
66
+ 流水线参数表。
67
+ """
68
+ def __init__(self, **data):
69
+ data = self.__set_default_values(**data)
70
+ super().__init__(**data)
71
+
72
+ def __set_default_values(self, **data) -> dict:
73
+ """
74
+ 为 Optional 类型但未设置 default 的参数补充默认值 None。
75
+ """
76
+ for field_name, field_info in self.__class__.model_fields.items():
77
+ field_type = field_info.annotation
78
+ origin = get_origin(field_type)
79
+ args = get_args(field_type)
80
+ if origin is Union and type(None) in args and field_name not in data:
81
+ data[field_name] = None
82
+ return data
83
+
84
+ def __init__(
85
+ self,
86
+ projdir: str | Path,
87
+ env: 'Env',
88
+ nodename: str,
89
+ options: Options
90
+ ):
91
+ """
92
+ :param projdir: 项目目录。
93
+ :param env: 环境信息。
94
+ :param nodename: 执行节点名称。
95
+ :param options: 流水线参数。
96
+ """
97
+ self.projdir = Path(projdir)
98
+ self.bwd = self.projdir.joinpath('workdir') # base workdir
99
+ self.env = env
100
+ self.node = self.env.get_node(nodename)
101
+ self.options = options
102
+ self.result: TResult = None
103
+ self.buildid: int = None
104
+
105
+ @property
106
+ def name(self) -> str:
107
+ """
108
+ 名称。
109
+ """
110
+ return self.__class__.__name__
111
+
112
+ @property
113
+ def cwd(self) -> Path:
114
+ """
115
+ 当前工作目录。
116
+ """
117
+ return self.bwd.joinpath(self.name, f'{self.buildid}')
118
+
119
+ def setup(self) -> None:
120
+ """
121
+ 前置步骤,如果失败将不会执行任何 stage,直接执行 teardown。
122
+ """
123
+ # 获取 buildid
124
+ print(f'Getting BuildID: ', end='')
125
+ parent = self.bwd.joinpath(self.name)
126
+ parent.mkdir(parents=True, exist_ok=True)
127
+ idfile = parent.joinpath('buildid.txt')
128
+ idfile.touch()
129
+ with FileLock(f'{idfile}.lock'):
130
+ with open(idfile, 'r+') as f:
131
+ newid = int(f.read() or 0) + 1
132
+ with open(idfile, 'w') as f:
133
+ f.write(f'{newid}')
134
+ self.buildid = newid
135
+ print(self.buildid)
136
+
137
+ # 打印参数
138
+ print(f'options: {self.options}')
139
+
140
+ # 创建远端工作目录
141
+ self.node.mkcwd()
142
+
143
+ # 创建本地工作目录
144
+ self.cwd.mkdir(parents=True, exist_ok=True)
145
+
146
+ def stage1(self) -> None:
147
+ """
148
+ 阶段 1,一个 pipeline 至少包含一个 stage1,增加则按照
149
+ stage2, stage3, ... 这样依次增加,执行时也将按此顺序。
150
+ """
151
+ raise NotImplementedError
152
+
153
+ @property
154
+ def stages(self) -> List[str]:
155
+ """
156
+ 所有阶段名称。
157
+ """
158
+ return sorted([n for n in dir(self.__class__) if re.match(r'stage\d+', n)])
159
+
160
+ def run(self) -> TResult:
161
+ """
162
+ 执行 pipeline。
163
+ """
164
+ title = lambda t: print(t.center(80, '='))
165
+ try:
166
+ title('setup')
167
+ self.setup()
168
+ for stage in self.stages:
169
+ title(stage)
170
+ getattr(self, stage)()
171
+ self.result = 'SUCCESSFUL'
172
+ except:
173
+ self.result = 'FAILED'
174
+ traceback.print_exc()
175
+ finally:
176
+ title('teardown')
177
+ self.teardown()
178
+ return self.result
179
+
180
+ def teardown(self) -> None:
181
+ """
182
+ 后置步骤,不管成功与否,都会执行该步骤。
183
+ """
184
+ if self.result == 'SUCCESSFUL':
185
+ if self.node.is_container and not self.node.existed:
186
+ self.node.remove(force=True)
187
+ else:
188
+ self.node.rmcwd()
189
+