Coverage for pydelica/compiler.py: 68%

158 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-12 09:07 +0000

1import glob 

2import logging 

3import os 

4import platform 

5import shutil 

6import subprocess 

7import tempfile 

8import pydantic 

9import pathlib 

10 

11import pydelica.exception as pde 

12from pydelica.options import LibrarySetup 

13 

14 

15class Compiler: 

16 """Performs compilation of Modelica using the OMC compiler""" 

17 

18 def __init__(self) -> None: 

19 """Initialise a compiler object 

20 

21 Parameters 

22 ---------- 

23 open_modelica_library_path : str, optional 

24 location of the OM libraries, else use defaults for system 

25 

26 Raises 

27 ------ 

28 pde.BinaryNotFoundError 

29 if an OMC compiler binary could not be found 

30 """ 

31 self._logger = logging.getLogger("PyDelica.Compiler") 

32 self._environment = os.environ.copy() 

33 self._omc_flags: dict[str, str | None] = {} 

34 self._binary_dirs: list[str] = [] 

35 

36 # If log level is debug, set OMC to be the same 

37 if self._logger.getEffectiveLevel() == logging.DEBUG: 

38 self._omc_flags["-d"] = None 

39 

40 self._omc_binary = None 

41 

42 if "OPENMODELICAHOME" in os.environ: 

43 _omc_cmd = "omc.exe" if platform.system() == "Windows" else "omc" 

44 self._omc_binary = os.path.join( 

45 os.environ["OPENMODELICAHOME"], "bin", _omc_cmd 

46 ) 

47 elif shutil.which("omc"): 

48 self._omc_binary = shutil.which("omc") 

49 

50 if not self._omc_binary: 

51 raise pde.BinaryNotFoundError("Failed to find OMC binary") 

52 

53 if platform.system() == "Windows": 

54 _mod_tool_bin = os.path.join( 

55 os.environ["OPENMODELICAHOME"], 

56 "tools", 

57 "msys", 

58 "mingw64", 

59 "bin", 

60 ) 

61 self._environment["PATH"] = ( 

62 f"{_mod_tool_bin}{os.pathsep}" + self._environment["PATH"] 

63 ) 

64 

65 self._environment["PATH"] = ( 

66 f"{os.path.dirname(self._omc_binary)}" 

67 + os.pathsep 

68 + self._environment["PATH"] 

69 ) 

70 self._logger.debug(f"Using Compiler: {self._omc_binary}") 

71 

72 def clear_cache(self) -> None: 

73 """Remove all build directories""" 

74 for dir in self._binary_dirs: 

75 shutil.rmtree(dir) 

76 

77 def set_profile_level(self, profile_level: str | None = None) -> None: 

78 """ "Sets the OMC profiling level, deactivates it if None""" 

79 if not profile_level: 

80 self._omc_flags.pop("--profiling", None) 

81 else: 

82 self.set_omc_flag("--profiling", profile_level) 

83 

84 def set_omc_flag(self, flag: str, value: str | None = None) -> None: 

85 """Sets a flag for the OMC compiler 

86 

87 Flags are added as: 

88 

89 omc <flag> 

90 

91 or 

92 

93 omc <flag>=<value> 

94 

95 Parameters 

96 ---------- 

97 flag : str 

98 flag to append 

99 value : str, optional 

100 value for the flag if appropriate 

101 """ 

102 if value: 

103 self._logger.debug(f"Setting OMC compiler flag '{flag}={value}'") 

104 else: 

105 self._logger.debug(f"Setting OMC compiler flag '{flag}'") 

106 self._omc_flags[flag] = value 

107 

108 def remove_omc_flag(self, flag: str) -> None: 

109 """Removes a flag from the OMC compiler if it exists""" 

110 if flag not in self._omc_flags: 

111 self._logger.debug(f"Flag '{flag}' is unset, ignoring removal") 

112 return 

113 self._logger.debug(f"Removing flag '{flag}' from OMC compiler") 

114 self._omc_flags.pop(flag, None) 

115 

116 @pydantic.validate_call 

117 def compile( 

118 self, 

119 modelica_source_file: pydantic.FilePath, 

120 model_addr: str | None = None, 

121 c_source_dir: pydantic.DirectoryPath | None = None, 

122 extra_models: list[str] | None = None, 

123 custom_library_spec: list[dict[str, str]] | None = None, 

124 ) -> pathlib.Path: 

125 """Compile Modelica source file 

126 

127 Parameters 

128 ---------- 

129 modelica_source_file : str 

130 Modelica source file to compile 

131 model_addr : str, optional 

132 Model within source file to compile, default is first found 

133 c_source_dir : str, optional 

134 directory containing any additional required C sources 

135 extra_models: list[str], optional 

136 Additional other model dependencies 

137 custom_library_spec: list[dict[str, str]], optional 

138 Use specific library versions 

139 

140 Returns 

141 ------- 

142 pathlib.Path 

143 location of output binary 

144 """ 

145 _temp_build_dir = tempfile.mkdtemp() 

146 

147 # Check if there is a 'Resources/Include' directory in the same 

148 # location as the Modelica script 

149 

150 _candidate_c_inc = modelica_source_file.parent.joinpath( 

151 "Resources", "Include" 

152 ) 

153 

154 if os.path.exists(_candidate_c_inc) and not c_source_dir: 

155 c_source_dir = _candidate_c_inc 

156 

157 with tempfile.TemporaryDirectory() as _temp_source_dir: 

158 if c_source_dir: 

159 self._prepare_c_incls(f"{c_source_dir}", f"{_temp_source_dir}") 

160 modelica_source_file = modelica_source_file.absolute() 

161 

162 # Copy sources to a temporary source location 

163 self._logger.debug( 

164 "Copying sources to temporary directory '%s'", _temp_source_dir 

165 ) 

166 _temp_model_source = os.path.join( 

167 _temp_source_dir, os.path.basename(modelica_source_file) 

168 ) 

169 shutil.copy(modelica_source_file, _temp_model_source) 

170 

171 if not modelica_source_file.exists(): 

172 raise FileNotFoundError( 

173 f"Could not compile Modelica file '{modelica_source_file}'," 

174 " file does not exist" 

175 ) 

176 

177 _args = [self._omc_binary, "-s", _temp_model_source] 

178 

179 if extra_models: 

180 for model in extra_models: 

181 _orig_model = modelica_source_file.parent.joinpath(model) 

182 if not os.path.exists(_orig_model): 

183 raise FileNotFoundError( 

184 f"Could not compile supplementary Modelica file '{model}'," 

185 " file does not exist" 

186 ) 

187 _temp_model_source = os.path.join( 

188 _temp_source_dir, os.path.basename(model) 

189 ) 

190 shutil.copy(_orig_model, _temp_model_source) 

191 _args.append(_temp_model_source) 

192 

193 _args.append("Modelica") 

194 

195 if model_addr: 

196 _args.append(f"+i={model_addr}") 

197 

198 for flag, value in self._omc_flags.items(): 

199 if not value: 

200 _args.append(flag) 

201 else: 

202 _args.append(f"{flag}={value}") 

203 

204 _cmd_str = " ".join(_args) 

205 

206 self._logger.debug(f"Executing Command: {_cmd_str}") 

207 

208 _gen = None 

209 

210 with LibrarySetup() as library: 

211 for lib in custom_library_spec or []: 

212 library.use_library(**lib) 

213 

214 # Only use custom library location if required else use default 

215 _environ = os.environ.copy() 

216 if library.session_library: 

217 _environ["OPENMODELICALIBRARY"] = library.session_library 

218 

219 try: 

220 _gen = subprocess.run( 

221 _args, 

222 shell=False, 

223 stderr=subprocess.PIPE, 

224 stdout=subprocess.PIPE, 

225 text=True, 

226 env=_environ, 

227 cwd=_temp_build_dir, 

228 ) 

229 

230 pde.parse_error_string_compiler(_gen.stdout, _gen.stderr) 

231 except FileNotFoundError as e: 

232 self._logger.error("Failed to run command '%s'", _cmd_str) 

233 self._logger.debug("PATH: %s", self._environment["PATH"]) 

234 if _gen: 

235 self._logger.error("Traceback: %s", _gen.stdout) 

236 raise e from e 

237 except pde.OMExecutionError as e: 

238 self._logger.error("Failed to run command '%s'", _cmd_str) 

239 if _gen: 

240 self._logger.error("Traceback: %s", _gen.stdout) 

241 raise e from e 

242 except pde.OMBuildError as e: 

243 if "lexer failed" in e.args[0]: 

244 self._logger.warning(e.args[0]) 

245 else: 

246 if _gen: 

247 self._logger.error("Traceback: %s", _gen.stdout) 

248 raise e from e 

249 

250 if not _gen: 

251 raise RuntimeError("Failed to execute model generation") 

252 

253 if _gen.returncode != 0: 

254 raise pde.OMBuildError( 

255 f"Model build configuration failed with exit code {_gen.returncode}:\n\t{_gen.stderr}" 

256 ) 

257 

258 self._logger.debug(_gen.stdout) 

259 

260 if _gen and _gen.stderr: 

261 self._logger.error(_gen.stderr) 

262 

263 _make_file = glob.glob(os.path.join(_temp_build_dir, "*.makefile")) 

264 

265 if not _make_file: 

266 self._logger.error( 

267 "Output directory contents [%s]: %s", 

268 _temp_build_dir, 

269 os.listdir(_temp_build_dir), 

270 ) 

271 raise pde.ModelicaFileGenerationError( 

272 f"Failed to find a Makefile in the directory: {_temp_build_dir}, " 

273 "Modelica failed to generated required files." 

274 ) 

275 

276 # Use the OM included MSYS Mingw32Make for Windows 

277 if platform.system() == "Windows": 

278 _make_binaries = glob.glob( 

279 os.path.join( 

280 os.environ["OPENMODELICAHOME"], 

281 "tools", 

282 "msys", 

283 "mingw*", 

284 "bin", 

285 "mingw*-make.exe", 

286 ) 

287 ) 

288 

289 if not _make_binaries: 

290 raise pde.BinaryNotFoundError( 

291 "Failed to find Make binary in Modelica directories" 

292 ) 

293 

294 _make_cmd = _make_binaries[0] 

295 

296 elif not shutil.which("make"): 

297 raise pde.BinaryNotFoundError("Could not find GNU-Make on this system") 

298 else: 

299 _make_cmd = shutil.which("make") 

300 

301 _make_file = _make_file[0] 

302 

303 _build_cmd = [_make_cmd, "-f", _make_file] 

304 

305 if platform.system() == "Windows": 

306 _build_cmd.extend(("-w", "OMC_LDFLAGS_LINK_TYPE=static")) 

307 self._logger.debug(f"Build Command: {' '.join(_build_cmd)}") 

308 

309 _build = subprocess.run( 

310 _build_cmd, 

311 shell=False, 

312 stderr=subprocess.PIPE, 

313 stdout=subprocess.PIPE, 

314 text=True, 

315 env=self._environment, 

316 cwd=_temp_build_dir, 

317 ) 

318 

319 try: 

320 pde.parse_error_string_compiler(_build.stdout, _build.stderr) 

321 except pde.OMBuildError as e: 

322 self._logger.error(_build.stderr) 

323 raise e from e 

324 

325 if _build.stdout: 

326 self._logger.debug(_build.stdout) 

327 

328 if _build.stderr: 

329 self._logger.error(_build.stderr) 

330 

331 if _build.returncode != 0: 

332 raise pde.OMBuildError( 

333 f"Model build failed with exit code {_build.returncode}:\n\t{_build.stderr}" 

334 ) 

335 

336 self._binary_dirs.append(_temp_build_dir) 

337 

338 return pathlib.Path(_temp_build_dir) 

339 

340 def _prepare_c_incls(self, c_source_dir: str, _temp_dir: str) -> None: 

341 self._logger.debug("Checking for C sources in '%s'", c_source_dir) 

342 _c_sources = glob.glob(os.path.join(c_source_dir, "*.c")) 

343 _c_sources += glob.glob(os.path.join(c_source_dir, "*.C")) 

344 _include = os.path.join(_temp_dir, "Resources", "Include") 

345 os.makedirs(_include) 

346 for source in _c_sources: 

347 _file_name = os.path.basename(source) 

348 self._logger.debug("Found '%s'", _file_name) 

349 shutil.copy(source, os.path.join(_include, _file_name))