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