Coverage for pydelica/compiler.py: 68%

157 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-19 07:38 +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 if "OPENMODELICAHOME" in os.environ: 

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

42 self._omc_binary = os.path.join( 

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

44 ) 

45 elif shutil.which("omc"): 

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

47 

48 if not self._omc_binary: 

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

50 

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

52 _mod_tool_bin = os.path.join( 

53 os.environ["OPENMODELICAHOME"], 

54 "tools", 

55 "msys", 

56 "mingw64", 

57 "bin", 

58 ) 

59 self._environment["PATH"] = ( 

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

61 ) 

62 

63 self._environment["PATH"] = ( 

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

65 + os.pathsep 

66 + self._environment["PATH"] 

67 ) 

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

69 

70 def clear_cache(self) -> None: 

71 """Remove all build directories""" 

72 for dir in self._binary_dirs: 

73 shutil.rmtree(dir) 

74 

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

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

77 if not profile_level: 

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

79 else: 

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

81 

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

83 """Sets a flag for the OMC compiler 

84 

85 Flags are added as: 

86 

87 omc <flag> 

88 

89 or 

90 

91 omc <flag>=<value> 

92 

93 Parameters 

94 ---------- 

95 flag : str 

96 flag to append 

97 value : str, optional 

98 value for the flag if appropriate 

99 """ 

100 if value: 

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

102 else: 

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

104 self._omc_flags[flag] = value 

105 

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

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

108 if flag not in self._omc_flags: 

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

110 return 

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

112 self._omc_flags.pop(flag, None) 

113 

114 @pydantic.validate_call 

115 def compile( 

116 self, 

117 modelica_source_file: pydantic.FilePath, 

118 model_addr: str | None = None, 

119 c_source_dir: pydantic.DirectoryPath | None = None, 

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

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

122 ) -> pathlib.Path: 

123 """Compile Modelica source file 

124 

125 Parameters 

126 ---------- 

127 modelica_source_file : str 

128 Modelica source file to compile 

129 model_addr : str, optional 

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

131 c_source_dir : str, optional 

132 directory containing any additional required C sources 

133 extra_models: list[str], optional 

134 Additional other model dependencies 

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

136 Use specific library versions 

137 

138 Returns 

139 ------- 

140 pathlib.Path 

141 location of output binary 

142 """ 

143 _temp_build_dir = tempfile.mkdtemp() 

144 

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

146 # location as the Modelica script 

147 

148 _candidate_c_inc = modelica_source_file.parent.joinpath( 

149 "Resources", "Include" 

150 ) 

151 

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

153 c_source_dir = _candidate_c_inc 

154 

155 with tempfile.TemporaryDirectory() as _temp_source_dir: 

156 if c_source_dir: 

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

158 modelica_source_file = modelica_source_file.absolute() 

159 

160 # Copy sources to a temporary source location 

161 self._logger.debug( 

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

163 ) 

164 _temp_model_source = os.path.join( 

165 _temp_source_dir, os.path.basename(modelica_source_file) 

166 ) 

167 shutil.copy(modelica_source_file, _temp_model_source) 

168 

169 if not modelica_source_file.exists(): 

170 raise FileNotFoundError( 

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

172 " file does not exist" 

173 ) 

174 

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

176 

177 if extra_models: 

178 for model in extra_models: 

179 _orig_model = modelica_source_file.parent.joinpath(model) 

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

181 raise FileNotFoundError( 

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

183 " file does not exist" 

184 ) 

185 _temp_model_source = os.path.join( 

186 _temp_source_dir, os.path.basename(model) 

187 ) 

188 shutil.copy(_orig_model, _temp_model_source) 

189 _args.append(_temp_model_source) 

190 

191 _args.append("Modelica") 

192 

193 if model_addr: 

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

195 

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

197 if not value: 

198 _args.append(flag) 

199 else: 

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

201 

202 _cmd_str = " ".join(_args) 

203 

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

205 

206 _gen = None 

207 

208 with LibrarySetup() as library: 

209 for lib in custom_library_spec or []: 

210 library.use_library(**lib) 

211 

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

213 _environ = os.environ.copy() 

214 if library.session_library: 

215 _environ["OPENMODELICALIBRARY"] = library.session_library 

216 

217 try: 

218 _gen = subprocess.run( 

219 _args, 

220 shell=False, 

221 stderr=subprocess.PIPE, 

222 stdout=subprocess.PIPE, 

223 text=True, 

224 env=_environ, 

225 cwd=_temp_build_dir, 

226 ) 

227 

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

229 except FileNotFoundError as e: 

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

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

232 if _gen: 

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

234 raise e from e 

235 except pde.OMExecutionError as e: 

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

237 if _gen: 

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

239 raise e from e 

240 except pde.OMBuildError as e: 

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

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

243 else: 

244 if _gen: 

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

246 raise e from e 

247 

248 if not _gen: 

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

250 

251 if _gen.returncode != 0: 

252 raise pde.OMBuildError( 

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

254 ) 

255 

256 self._logger.debug(_gen.stdout) 

257 

258 if _gen and _gen.stderr: 

259 self._logger.error(_gen.stderr) 

260 

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

262 

263 if not _make_file: 

264 self._logger.error( 

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

266 _temp_build_dir, 

267 os.listdir(_temp_build_dir), 

268 ) 

269 raise pde.ModelicaFileGenerationError( 

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

271 "Modelica failed to generated required files." 

272 ) 

273 

274 # Use the OM included MSYS Mingw32Make for Windows 

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

276 _make_binaries = glob.glob( 

277 os.path.join( 

278 os.environ["OPENMODELICAHOME"], 

279 "tools", 

280 "msys", 

281 "mingw*", 

282 "bin", 

283 "mingw*-make.exe", 

284 ) 

285 ) 

286 

287 if not _make_binaries: 

288 raise pde.BinaryNotFoundError( 

289 "Failed to find Make binary in Modelica directories" 

290 ) 

291 

292 _make_cmd = _make_binaries[0] 

293 

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

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

296 else: 

297 _make_cmd = shutil.which("make") 

298 

299 _make_file = _make_file[0] 

300 

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

302 

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

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

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

306 

307 _build = subprocess.run( 

308 _build_cmd, 

309 shell=False, 

310 stderr=subprocess.PIPE, 

311 stdout=subprocess.PIPE, 

312 text=True, 

313 env=self._environment, 

314 cwd=_temp_build_dir, 

315 ) 

316 

317 try: 

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

319 except pde.OMBuildError as e: 

320 self._logger.error(_build.stderr) 

321 raise e from e 

322 

323 if _build.stdout: 

324 self._logger.debug(_build.stdout) 

325 

326 if _build.stderr: 

327 self._logger.error(_build.stderr) 

328 

329 if _build.returncode != 0: 

330 raise pde.OMBuildError( 

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

332 ) 

333 

334 self._binary_dirs.append(_temp_build_dir) 

335 

336 return pathlib.Path(_temp_build_dir) 

337 

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

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

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

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

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

343 os.makedirs(_include) 

344 for source in _c_sources: 

345 _file_name = os.path.basename(source) 

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

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