Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / codegen / artifact.py: 72%

194 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 07:18 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Artifact-layout resolution and atomic writes for codegen bundles. 

16 

17The artifact bundle lives in a ``generated/`` directory and holds the 

18generated modeling file, its diff against the original, and copied remote-code 

19dependencies. Two layout anchors exist: the model-anchored default 

20(``hyper_parallel/models/<model_type>/generated``, one bundle per model type) 

21and an explicit directory for isolated model-construction workflows. The bundle is 

22written as one atomic unit: every file is first staged into a temporary 

23*sibling* directory, then the whole directory is swapped into place, so a 

24half-written bundle can never be imported. 

25""" 

26 

27from __future__ import annotations 

28 

29from contextlib import contextmanager 

30import ctypes 

31from dataclasses import dataclass 

32import errno 

33import logging 

34import os 

35from pathlib import Path 

36import shutil 

37import tempfile 

38import time 

39from typing import Iterator 

40 

41# Bundle directory name shared by every layout constructor. 

42_GENERATED_DIR = "generated" 

43 

44_LOGGER = logging.getLogger(__name__) 

45_LOCK_INITIALIZATION_GRACE_S = 1.0 

46 

47 

48@dataclass 

49class ArtifactLayout: 

50 """Describe the paths belonging to one generated bundle.""" 

51 

52 artifact_dir: str 

53 modeling_path: str 

54 diff_path: str 

55 init_path: str 

56 

57 @classmethod 

58 def build_default( 

59 cls, 

60 artifact_dir: str | None = None, 

61 model_name: str | None = None, 

62 ) -> "ArtifactLayout": 

63 """Create the layout for model-construction codegen. 

64 

65 ``artifact_dir`` defaults to ``Path.cwd()/generated``. 

66 """ 

67 resolved_dir = os.path.abspath(artifact_dir or os.path.join(os.getcwd(), _GENERATED_DIR)) 

68 modeling_name = _default_modeling_name(model_name) 

69 return cls( 

70 artifact_dir=resolved_dir, 

71 modeling_path=os.path.join(resolved_dir, modeling_name), 

72 diff_path=os.path.join(resolved_dir, modeling_name + ".diff"), 

73 init_path=os.path.join(resolved_dir, "__init__.py"), 

74 ) 

75 

76 @classmethod 

77 def build_model_anchored( 

78 cls, 

79 model_type: str, 

80 model_name: str | None = None, 

81 ) -> "ArtifactLayout": 

82 """Create the model-anchored layout for one model type. 

83 

84 ``artifact_dir`` is ``hyper_parallel/models/<model_type>/generated`` 

85 — exactly one bundle per model type, colocated with the model's 

86 adapter code. When ``models/<model_type>/`` does not exist yet, a 

87 warning is emitted and the directory is created on demand, so a 

88 model without a family directory still gets its own bundle home 

89 instead of silently falling back to another location. 

90 """ 

91 # ``artifact.py`` lives in ``hyper_parallel/codegen/``; the models 

92 # tree is its sibling — package-relative, never cwd-dependent. 

93 models_root = Path(__file__).resolve().parents[1] / "models" 

94 family_root = models_root / model_type 

95 if not family_root.is_dir(): 

96 _LOGGER.warning( 

97 "codegen: model type %r has no directory under " 

98 "hyper_parallel/models yet; the bundle will be created at " 

99 "%s (add an adapter package there to colocate it with the " 

100 "model's conversion code)", 

101 model_type, 

102 os.path.join(str(family_root), _GENERATED_DIR), 

103 ) 

104 artifact_dir = os.path.join(str(family_root), _GENERATED_DIR) 

105 modeling_name = _default_modeling_name(model_name) 

106 return cls( 

107 artifact_dir=artifact_dir, 

108 modeling_path=os.path.join(artifact_dir, modeling_name), 

109 diff_path=os.path.join(artifact_dir, modeling_name + ".diff"), 

110 init_path=os.path.join(artifact_dir, "__init__.py"), 

111 ) 

112 

113 

114def _default_modeling_name(model_name: str | None) -> str: 

115 base = model_name or "model" 

116 # '' / '-' are not valid Python identifiers in module names. 

117 safe = "".join(c if c.isalnum() or c == "_" else "_" for c in base) 

118 return f"modeling_{safe}_gen_npu.py" 

119 

120 

121def resolve_default_artifact_layout( 

122 artifact_dir: str | None = None, 

123 model_name: str | None = None, 

124) -> ArtifactLayout: 

125 """Resolve the default model-construction artifact layout.""" 

126 return ArtifactLayout.build_default(artifact_dir, model_name) 

127 

128 

129def resolve_model_artifact_layout( 

130 model_type: str, 

131 model_name: str | None = None, 

132) -> "ArtifactLayout": 

133 """Resolve the model-anchored layout for ``model_type``. 

134 

135 The bundle directory is always ``hyper_parallel/models/<model_type>/ 

136 generated`` — a strict one-to-one mapping keyed by the model type, with 

137 no aliasing between model types. When ``models/<model_type>/`` does not 

138 exist yet, ``ArtifactLayout.build_model_anchored`` warns and the directory is 

139 created on demand by :func:`ensure_artifact_dir`. 

140 """ 

141 return ArtifactLayout.build_model_anchored(model_type, model_name=model_name) 

142 

143 

144def ensure_artifact_dir(layout: ArtifactLayout) -> None: 

145 """Create the artifact directory (recursive, no-op if present). 

146 

147 Raises a readable error when the directory cannot be created: the 

148 model-anchored default lives inside the installed ``hyper_parallel`` 

149 package, which is read-only under a non-editable install. 

150 """ 

151 try: 

152 Path(layout.artifact_dir).mkdir(parents=True, exist_ok=True) 

153 except OSError as exc: 

154 raise RuntimeError( 

155 f"codegen: cannot create artifact directory {layout.artifact_dir}: {exc}. " 

156 "The default bundle location lives inside the hyper_parallel package; " 

157 "for a non-editable install either reinstall with 'pip install -e .' " 

158 "or point 'model.codegen_artifact_dir' at a writable directory in the YAML." 

159 ) from exc 

160 

161 

162def write_bundle_atomic(layout: ArtifactLayout, files: dict[str, str]) -> None: 

163 """Write the whole bundle into place as one atomic directory switch. 

164 

165 ``files`` maps filename -> content; keys must be plain file names (not 

166 absolute paths) so nothing bypasses the staging directory. 

167 

168 The bundle is first staged into a temporary *sibling* of ``artifact_dir`` 

169 (never inside it), then the whole directory is swapped in. On Windows 

170 ``os.replace`` cannot replace a non-empty target directory, so the swap is 

171 three steps: move the old bundle aside, rename the staged dir into place, 

172 then delete the backup. A reader on ``artifact_dir`` therefore sees either 

173 the old complete bundle or the new complete bundle — never a mix. 

174 """ 

175 ensure_artifact_dir(layout) 

176 parent = os.path.dirname(layout.artifact_dir) or "." 

177 

178 tmpdir = tempfile.mkdtemp(prefix=".codegen_tmp_", dir=parent) 

179 old_dir = None 

180 try: 

181 _stage_bundle(tmpdir, files) 

182 

183 if os.path.exists(layout.artifact_dir): 

184 old_dir = tempfile.mkdtemp(prefix=".codegen_old_", dir=parent) 

185 os.rmdir(old_dir) # reserve a name; must be absent for os.replace 

186 os.replace(layout.artifact_dir, old_dir) 

187 os.replace(tmpdir, layout.artifact_dir) 

188 if old_dir is not None: 

189 shutil.rmtree(old_dir, ignore_errors=True) 

190 old_dir = None 

191 finally: 

192 # If the staged dir never made it into place, put the old bundle back. 

193 if old_dir is not None and os.path.isdir(old_dir): 

194 if not os.path.exists(layout.artifact_dir): 

195 os.replace(old_dir, layout.artifact_dir) 

196 else: 

197 shutil.rmtree(old_dir, ignore_errors=True) 

198 if os.path.isdir(tmpdir): 

199 shutil.rmtree(tmpdir, ignore_errors=True) 

200 

201 

202def _stage_bundle(tmpdir: str, files: dict[str, str]) -> None: 

203 """Write the complete newly emitted file set into ``tmpdir``.""" 

204 normalized = dict(files) 

205 for name in normalized: 

206 _validate_bundle_filename(name) 

207 

208 init_present = "__init__.py" in normalized 

209 if not init_present: 

210 normalized["__init__.py"] = "" 

211 

212 for name, content in normalized.items(): 

213 # newline="" disables the platform's default CRLF translation so the 

214 # staged bytes equal the emitted text bytes exactly. On Windows the 

215 # default (newline=None) would turn every LF into CRLF and make bundle 

216 # bytes platform-dependent. 

217 with open( 

218 os.path.join(tmpdir, name), "w", encoding="utf-8", newline="" 

219 ) as handle: 

220 handle.write(content) 

221 

222def _validate_bundle_filename(name: str) -> None: 

223 """Reject absolute, nested, and parent-relative bundle paths.""" 

224 if ( 

225 not name 

226 or os.path.isabs(name) 

227 or os.path.basename(name) != name 

228 or "/" in name 

229 or "\\" in name 

230 or name in {".", ".."} 

231 ): 

232 raise ValueError(f"bundle file keys must be plain names, got {name!r}") 

233 

234 

235def clean_temp_artifacts(layout: ArtifactLayout) -> None: 

236 """Remove stale ``.codegen_tmp_*`` / ``.codegen_old_*`` dirs. 

237 

238 Staging now happens in the artifact dir's *parent*, so these leftovers are 

239 scanned there rather than inside the bundle. 

240 """ 

241 parent = os.path.dirname(layout.artifact_dir) or "." 

242 if not os.path.isdir(parent): 

243 return 

244 for name in os.listdir(parent): 

245 if name.startswith(".codegen_tmp_") or name.startswith(".codegen_old_"): 

246 shutil.rmtree(os.path.join(parent, name), ignore_errors=True) 

247 

248 

249@contextmanager 

250def artifact_generation_lock( 

251 layout: ArtifactLayout, 

252 *, 

253 timeout_s: float = 600.0, 

254) -> Iterator[None]: 

255 """Serialize bundle publication and recover locks left by dead owners.""" 

256 parent = os.path.dirname(layout.artifact_dir) or "." 

257 bundle_name = os.path.basename(layout.artifact_dir.rstrip(os.sep)) or "generated" 

258 lock_path = os.path.join(parent, f".{bundle_name}.codegen.lock") 

259 deadline = time.monotonic() + timeout_s 

260 descriptor = None 

261 owner_identity = None 

262 while descriptor is None: 

263 try: 

264 descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) 

265 try: 

266 os.write(descriptor, str(os.getpid()).encode("ascii")) 

267 except BaseException: 

268 os.close(descriptor) 

269 descriptor = None 

270 try: 

271 os.unlink(lock_path) 

272 except FileNotFoundError: 

273 pass 

274 raise 

275 owner_identity = _file_identity(lock_path) 

276 except FileExistsError: 

277 if _remove_stale_lock(lock_path): 

278 continue 

279 if time.monotonic() >= deadline: 

280 raise TimeoutError( 

281 f"codegen: timed out waiting for artifact lock {lock_path}" 

282 ) from None 

283 time.sleep(0.1) 

284 try: 

285 yield 

286 finally: 

287 os.close(descriptor) 

288 if owner_identity is not None and _file_identity(lock_path) == owner_identity: 

289 try: 

290 os.unlink(lock_path) 

291 except FileNotFoundError: 

292 pass 

293 

294 

295def _remove_stale_lock(lock_path: str) -> bool: 

296 """Remove ``lock_path`` when its recorded owner process is dead.""" 

297 try: 

298 identity = _file_identity(lock_path) 

299 stat_result = os.stat(lock_path) 

300 with open(lock_path, "r", encoding="ascii") as handle: 

301 value = handle.read().strip() 

302 except (FileNotFoundError, OSError, UnicodeError): 

303 return False 

304 

305 try: 

306 pid = int(value) 

307 except ValueError: 

308 # There is a tiny interval between O_EXCL creation and writing the PID. 

309 # Only reclaim malformed locks after that initialization window. 

310 age_s = max(0.0, time.time() - stat_result.st_mtime) 

311 stale = age_s >= _LOCK_INITIALIZATION_GRACE_S 

312 else: 

313 stale = not _pid_is_alive(pid) 

314 if not stale or _file_identity(lock_path) != identity: 

315 return False 

316 try: 

317 os.unlink(lock_path) 

318 except FileNotFoundError: 

319 return True 

320 except OSError: 

321 return False 

322 _LOGGER.warning("codegen: removed stale artifact lock %s", lock_path) 

323 return True 

324 

325 

326def _pid_is_alive(pid: int) -> bool: 

327 """Return whether ``pid`` still identifies a live process.""" 

328 if pid <= 0: 

329 return False 

330 if os.name == "nt": 

331 return _windows_pid_is_alive(pid) 

332 try: 

333 os.kill(pid, 0) 

334 except ProcessLookupError: 

335 return False 

336 except PermissionError: 

337 return True 

338 except OSError as exc: 

339 if exc.errno == errno.ESRCH: 

340 return False 

341 if exc.errno == errno.EPERM: 

342 return True 

343 return True 

344 return True 

345 

346 

347def _windows_pid_is_alive(pid: int) -> bool: 

348 """Query a Windows process without using ``os.kill(pid, 0)``. 

349 

350 Windows does not implement the POSIX signal-zero probe; using it may 

351 terminate the target process. ``OpenProcess`` plus ``GetExitCodeProcess`` 

352 is read-only and reports ``STILL_ACTIVE`` for a live owner. 

353 """ 

354 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) 

355 open_process = kernel32.OpenProcess 

356 open_process.argtypes = (ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong) 

357 open_process.restype = ctypes.c_void_p 

358 get_exit_code = kernel32.GetExitCodeProcess 

359 get_exit_code.argtypes = (ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)) 

360 get_exit_code.restype = ctypes.c_int 

361 close_handle = kernel32.CloseHandle 

362 close_handle.argtypes = (ctypes.c_void_p,) 

363 close_handle.restype = ctypes.c_int 

364 process = open_process(0x1000, False, pid) 

365 if not process: 

366 # Access denied proves that the process exists. Other errors are 

367 # treated conservatively except ERROR_INVALID_PARAMETER (no such PID). 

368 return ctypes.get_last_error() != 87 

369 try: 

370 exit_code = ctypes.c_ulong() 

371 if not get_exit_code(process, ctypes.byref(exit_code)): 

372 return True 

373 return exit_code.value == 259 # STILL_ACTIVE 

374 finally: 

375 close_handle(process) 

376 

377 

378def _file_identity(path: str) -> tuple[int, int, int] | None: 

379 """Return enough stat identity to avoid unlinking a replaced lock file.""" 

380 try: 

381 result = os.stat(path) 

382 except FileNotFoundError: 

383 return None 

384 return result.st_ino, result.st_mtime_ns, result.st_size 

385 

386 

387__all__ = [ 

388 "ArtifactLayout", 

389 "artifact_generation_lock", 

390 "clean_temp_artifacts", 

391 "ensure_artifact_dir", 

392 "resolve_default_artifact_layout", 

393 "resolve_model_artifact_layout", 

394 "write_bundle_atomic", 

395]