Coverage for pydelica/compiler.py: 68%

1 import glob

2 import logging

3 import os

4 import platform

5 import shutil

6 import subprocess

7 import tempfile

8 import pydantic

9 import pathlib

10

11 import pydelica.exception as pde

12 from pydelica.options import LibrarySetup

13

14

15 class 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))