Coverage for bim2sim/project.py: 75%

292 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 17:09 +0000

1"""Project handling""" 

2import logging 

3import os 

4import sys 

5import subprocess 

6import shutil 

7import threading 

8from distutils.dir_util import copy_tree 

9from enum import Enum 

10from pathlib import Path 

11from typing import Dict, Type, Union 

12 

13import configparser 

14 

15from bim2sim.kernel.decision import ListDecision, DecisionBunch, save, load 

16from bim2sim.kernel import log 

17from bim2sim.tasks.base import Playground 

18from bim2sim.plugins import Plugin, load_plugin 

19from bim2sim.utilities.common_functions import all_subclasses 

20from bim2sim.sim_settings import BaseSimSettings 

21from bim2sim.utilities.types import LOD 

22 

23 

24def open_config(path): 

25 """Open config for user and wait for closing before continue.""" 

26 if sys.platform.startswith('darwin'): # For MAC OS X 

27 open_file = subprocess.Popen(['open', path]) 

28 elif os.name == 'nt': # For Windows 

29 open_file = subprocess.Popen(["notepad.exe", path]) 

30 # os.system("start " + conf_path) 

31 # todo for any reason wait() seems not to work on linux 

32 # elif os.name == 'posix': # For Linux, Mac, etc. 

33 # open_file = subprocess.Popen(['xdg-open', path]) 

34 else: 

35 raise NotImplementedError('Only mac os and windows are ' 

36 'supported currently.') 

37 open_file.wait() 

38 

39 

40def add_config_section( 

41 config: configparser.ConfigParser, 

42 sim_settings: BaseSimSettings, 

43 name: str) -> configparser.ConfigParser: 

44 """Add a section to config with all attributes and default values.""" 

45 if name not in config._sections: 

46 config.add_section(name) 

47 attributes = [attr for attr in list(sim_settings.__dict__.keys()) 

48 if not callable(getattr(sim_settings, attr)) and not 

49 attr.startswith('__')] 

50 for attr in attributes: 

51 default_value = getattr(sim_settings, attr).default 

52 if isinstance(default_value, Enum): 

53 default_value = str(default_value) 

54 if not attr in config[name]: 

55 config[name][attr] = str(default_value) 

56 return config 

57 

58 

59def config_base_setup(path, backend=None): 

60 """Initial setup for config file""" 

61 config = configparser.ConfigParser(allow_no_value=True) 

62 config.read(path) 

63 

64 if not config.sections(): 

65 # add all default attributes from base workflow 

66 config = add_config_section( 

67 config, BaseSimSettings, "Generic Simulation Settings") 

68 # add all default attributes from sub workflows 

69 all_settings = all_subclasses(BaseSimSettings) 

70 for flow in all_settings: 

71 config = add_config_section(config, flow, flow.__name__) 

72 

73 # add general settings 

74 config.add_section("Backend") 

75 config["Backend"]["use"] = backend 

76 config.add_section("Frontend") 

77 config["Frontend"]["use"] = 'ConsoleFrontEnd' 

78 config.add_section("Modelica") 

79 config["Modelica"]["Version"] = "4.0" 

80 

81 with open(path, "w") as file: 

82 config.write(file) 

83 

84 

85class FolderStructure: 

86 """Project related file and folder handling.""" 

87 

88 CONFIG = "config.toml" 

89 DECISIONS = "decisions.json" 

90 FINDER = "finder" 

91 IFC_BASE = "ifc" 

92 LOG = "log" 

93 EXPORT = "export" 

94 

95 _src_path = Path(__file__).parent # base path to bim2sim assets 

96 

97 def __init__(self, path=None): 

98 self._root_path = None 

99 self.root = path or os.getcwd() 

100 

101 @property 

102 def root(self): 

103 """absolute root path""" 

104 return self._root_path 

105 

106 @root.setter 

107 def root(self, value: str): 

108 self._root_path = Path(value).absolute().resolve() 

109 

110 @property 

111 def assets(self): 

112 return self._src_path / 'assets' 

113 

114 @property 

115 def enrichment(self): 

116 return self._src_path / 'enrichment_data' 

117 

118 @property 

119 def config(self): 

120 """absolute path to config""" 

121 return self._root_path / self.CONFIG 

122 

123 @property 

124 def decisions(self): 

125 """absolute path to decisions""" 

126 return self._root_path / self.DECISIONS 

127 

128 @property 

129 def finder(self): 

130 """absolute path to finder""" 

131 return self._root_path / self.FINDER 

132 

133 @property 

134 def log(self): 

135 """absolute path to log folder""" 

136 return self._root_path / self.LOG 

137 

138 @property 

139 def ifc_base(self): 

140 """absolute path to ifc folder""" 

141 return self._root_path / self.IFC_BASE 

142 

143 @property 

144 def export(self): 

145 """absolute path to export folder""" 

146 return self._root_path / self.EXPORT 

147 

148 @property 

149 def b2sroot(self): 

150 """absolute path of bim2sim root folder""" 

151 return self._src_path.parent 

152 

153 @property 

154 def sub_dirs(self): 

155 """list of paths to sub folders""" 

156 return [self.log, self.ifc_base, self.export, self.finder] 

157 

158 def copy_assets(self, path): 

159 """copy assets to project folder""" 

160 copy_tree(str(self.assets), str(path)) 

161 

162 def is_project_folder(self, path=None): 

163 """Check if root path (or given path) is a project folder""" 

164 root = path or self.root 

165 if not root: 

166 return False 

167 root = Path(root) 

168 if not root.is_dir(): 

169 return False 

170 if (root / self.CONFIG).is_file(): 

171 return True 

172 return False 

173 

174 def complete_project_folder(self): 

175 """Adds missing sub folders to given path""" 

176 for subdir in self.sub_dirs: 

177 os.makedirs(subdir, exist_ok=True) 

178 

179 def create_project_folder(self): 

180 """Creates a project folder on given path""" 

181 os.makedirs(self.root, exist_ok=True) 

182 self.complete_project_folder() 

183 

184 # create empty config file 

185 with open(self.config, "w"): 

186 pass 

187 

188 self.copy_assets(self.root) 

189 

190 @classmethod 

191 def create(cls, rootpath: str, ifc_paths: Dict = None, 

192 plugin: Union[str, Type[Plugin]] = None, open_conf: bool = False): 

193 """Create ProjectFolder and set it up. 

194 

195 Create instance, set source path, create project folder 

196 copy ifc, base config setup and open config if needed. 

197 

198 Args: 

199 rootpath: path of root folder 

200 ifc_paths: dict with key: bim2sim domain and value: path 

201 to corresponding ifc which gets copied into project folder 

202 plugin: the target Plugin 

203 open_conf: flag to open the config file in default application 

204 """ 

205 general_logger = log.initial_logging_setup() 

206 # set rootpath 

207 self = cls(rootpath) 

208 if isinstance(plugin, str): 

209 plugin_name = plugin 

210 elif issubclass(plugin, Plugin): 

211 plugin_name = plugin.name 

212 else: 

213 raise ValueError(f"{plugin} is not a subclass of Plugin or a str.") 

214 

215 if self.is_project_folder(): 

216 general_logger.info( 

217 "Given path is already a project folder ('%s')" % self.root) 

218 else: 

219 self.create_project_folder() 

220 config_base_setup(self.config, plugin_name) 

221 

222 if ifc_paths: 

223 if not isinstance(ifc_paths, Dict): 

224 raise ValueError( 

225 "Please provide a Dictionary with key: Domain, value: Path " 

226 "to IFC ") 

227 # copy all ifc files to domain specific project folders 

228 for domain, file_path in ifc_paths.items(): 

229 if not file_path.exists(): 

230 if "test" in file_path.parts \ 

231 and "resources" in file_path.parts: 

232 raise ValueError( 

233 f"Provided path to ifc is: {file_path}, but this " 

234 f"file does not exist. You are trying to run a " 

235 f"test on your local machine," 

236 f" but it seems like you have not downloaded the" 

237 f" needed test resources. " 

238 f"Run 'git submodule update --init --recursive' " 

239 f"to make sure to include the test resources." 

240 ) 

241 else: 

242 raise ValueError( 

243 f"Provided path to ifc is: {file_path}, but this file " 

244 f"does not exist.") 

245 Path.mkdir(self.ifc_base / domain.name, exist_ok=True) 

246 shutil.copy2( 

247 file_path, self.ifc_base / domain.name / file_path.name) 

248 

249 if open_conf: 

250 # open config for user interaction 

251 open_config(self.config) 

252 general_logger.info("Project folder created.") 

253 return self 

254 

255 def delete(self, confirm=True): 

256 """Delete project folder and all files in it. 

257 

258 Raises: 

259 AssertionError: if not existing on file system 

260 """ 

261 # TODO: decision system 

262 if confirm: 

263 ans = input("Delete project folder and all included files? [y/n]") 

264 if not ans == 'y': 

265 return 

266 

267 if os.path.exists(self.root): 

268 shutil.rmtree(self.root, ignore_errors=True) 

269 print("Project folder deleted.") 

270 else: 

271 raise AssertionError( 

272 "Can't delete project folder (reason: does not exist)") 

273 

274 def __str__(self): 

275 return str(self.root) 

276 

277 def __repr__(self): 

278 return "<FolderStructure (root: %s)>" % self.root 

279 

280 

281class Project: 

282 """Project resource handling. 

283 

284 Args: 

285 path: path to load project from 

286 plugin: Plugin to use. This overwrites plugin from config. 

287 

288 Raises: 

289 AssertionError: on invalid path. E.g. if not existing 

290 """ 

291 _active_project = None # lock to prevent multiple interfering projects 

292 

293 def __init__( 

294 self, 

295 path: str = None, 

296 plugin: Type[Plugin] = None, 

297 ): 

298 """Load existing project""" 

299 self.paths = FolderStructure(path) 

300 self.thread_name = threading.current_thread().name 

301 self.log_handlers, self._log_thread_filters = log.project_logging_setup(self) 

302 self.logger = logging.getLogger('bim2sim') 

303 # try to get name of project from ifc name 

304 try: 

305 self.name = list( 

306 filter(Path.is_file, self.paths.ifc_base.glob('**/*')))[0].stem 

307 except: 

308 self.logger.warning( 

309 'Could not set correct project name, using "Project"!') 

310 self.name = "Project" 

311 

312 if not self.paths.is_project_folder(): 

313 raise AssertionError("Project path is no valid project directory. " 

314 "Use Project.create() to create a new Project") 

315 self._made_decisions = DecisionBunch() 

316 self.loaded_decisions = load(self.paths.decisions) 

317 

318 self.plugin_cls = self._get_plugin(plugin) 

319 self.playground = Playground(self) 

320 # link sim_settings to project to make set of settings easier 

321 self.sim_settings = self.playground.sim_settings 

322 

323 def _get_plugin(self, plugin): 

324 if plugin and isinstance(plugin, str): 

325 return load_plugin(plugin) 

326 elif plugin and issubclass(plugin, Plugin): 

327 return plugin 

328 else: 

329 plugin_name = self.config['Backend']['use'] 

330 assert plugin_name, "Either an explicit passed plugin or" \ 

331 " equivalent entry in config is required." 

332 return load_plugin(plugin_name) 

333 

334 @classmethod 

335 def create(cls, project_folder, ifc_paths: Dict = None, plugin: Union[ 

336 str, Type[Plugin]] = None, open_conf: bool = False): 

337 """Create new project 

338 

339 Args: 

340 project_folder: directory of project 

341 ifc_paths: dict with key: IFCDomain and value: path 

342 to corresponding ifc which gets copied into project folder 

343 plugin: Plugin to use with this project. If passed as string, 

344 make sure it is importable (see plugins.load_plugin) 

345 open_conf: flag to open the config file in default application 

346 updated from config 

347 """ 

348 # create folder first and use given plugin 

349 if plugin and (isinstance(plugin, str) or issubclass(plugin, Plugin)): 

350 FolderStructure.create( 

351 project_folder, ifc_paths, plugin, open_conf) 

352 project = cls(project_folder, plugin=plugin) 

353 else: 

354 # recreate plugin out of config, since no plugin was given 

355 FolderStructure.create( 

356 project_folder, ifc_paths, open_conf=open_conf) 

357 project = cls(project_folder) 

358 

359 return project 

360 

361 @staticmethod 

362 def is_project_folder(path: str) -> bool: 

363 return FolderStructure(path).is_project_folder() 

364 

365 @classmethod 

366 def _release(cls, project): 

367 if cls._active_project is project: 

368 cls._active_project = None 

369 elif cls._active_project is None: 

370 raise AssertionError("Cant release Project. No active project.") 

371 else: 

372 raise AssertionError("Cant release from other project.") 

373 

374 def is_active(self) -> bool: 

375 """Return True if current project is active, False otherwise.""" 

376 return Project._active_project is self 

377 

378 def _update_logging_thread_filters(self): 

379 """Update thread filters to current thread.""" 

380 thread_name = threading.current_thread().name 

381 for thread_filter in self._log_thread_filters: 

382 thread_filter.thread_name = thread_name 

383 

384 @property 

385 def config(self): 

386 """returns configparser instance. Basic config is done if file is not 

387 present""" 

388 config = configparser.ConfigParser(allow_no_value=True) 

389 if not config.read(self.paths.config): 

390 config_base_setup(self.paths.root) 

391 config.read(self.paths.config) 

392 return config 

393 

394 def rewrite_config(self): 

395 # TODO this might need changes due to handling of enums 

396 config = self.config 

397 settings_manager = self.sim_settings.manager 

398 for setting in settings_manager: 

399 s = settings_manager.get(setting) 

400 if isinstance(s.value, LOD): 

401 val = s.value.value 

402 else: 

403 val = s.value 

404 config[type(self.sim_settings).__name__][s.name] = str(val) 

405 

406 with open(self.paths.config, "w") as file: 

407 config.write(file) 

408 

409 def run(self, interactive=False, cleanup=True): 

410 """Run project. 

411 

412 Args: 

413 interactive: if True the Task execution order is determined by 

414 Decisions else its derived by plugin 

415 cleanup: execute cleanup logic. Not doing this is only relevant for 

416 debugging 

417 

418 Raises: 

419 AssertionError: if project setup is broken or on invalid Decisions 

420 """ 

421 if not self.paths.is_project_folder(): 

422 raise AssertionError("Project ist not set correctly!") 

423 

424 self.sim_settings.check_mandatory() 

425 success = False 

426 if interactive: 

427 run = self._run_interactive 

428 else: 

429 run = self._run_default 

430 try: 

431 # First update log filters in case Project was created from 

432 # different thread. 

433 # Then update log filters for each iteration, which might get 

434 # called by a different thread. 

435 # Deeper down multithreading is currently not supported for logging 

436 # and will result in a mess of log messages. 

437 self._update_logging_thread_filters() 

438 for decision_bunch in run(): 

439 yield decision_bunch 

440 self._update_logging_thread_filters() 

441 if not decision_bunch.valid(): 

442 raise AssertionError("Cant continue with invalid decisions") 

443 for decision in decision_bunch: 

444 decision.freeze() 

445 self._made_decisions.extend(decision_bunch) 

446 self._made_decisions.validate_global_keys() 

447 success = True 

448 except Exception as ex: 

449 self.logger.exception(f"Something went wrong!: {ex}") 

450 finally: 

451 if cleanup: 

452 self.finalize(success=success) 

453 return 0 if success else -1 

454 

455 def _run_default(self, plugin=None): 

456 """Execution of plugins default tasks""" 

457 # run plugin default 

458 plugin_cls = plugin or self.plugin_cls 

459 _plugin = plugin_cls() 

460 for task_cls in _plugin.default_tasks: 

461 yield from self.playground.run_task(task_cls(self.playground)) 

462 

463 def _run_interactive(self): 

464 """Interactive execution of available ITasks""" 

465 while True: 

466 tasks_classes = {task.__name__: task for task in 

467 self.playground.available_tasks()} 

468 choices = [(name, task.__doc__) for name, task in 

469 tasks_classes.items()] 

470 task_decision = ListDecision("What shall we do?", 

471 choices=choices, 

472 global_key = "_task_%s_decision" % (self.__class__.__name__)) # add global key: or task.__name__ 

473 yield DecisionBunch([task_decision]) 

474 task_name = task_decision.value 

475 task_class = tasks_classes[task_name] 

476 yield from self.playground.run_task(task_class(self.playground)) 

477 if task_class.final: 

478 break 

479 

480 def finalize(self, success=False): 

481 """cleanup method""" 

482 

483 # clean up run relics 

484 # backup decisions 

485 if not success: 

486 pth = self.paths.root / 'decisions_backup.json' 

487 save(self._made_decisions, pth) 

488 self.logger.warning("Decisions are saved in '%s'. Rename file to " 

489 "'decisions.json' to reuse them.", pth) 

490 self.logger.error(f'Project "{self.name}" ' 

491 f'finished, but not successful') 

492 

493 else: 

494 save(self._made_decisions, self.paths.decisions) 

495 self.logger.info(f'Project Exports can be found under ' 

496 f'{self.paths.export}') 

497 self.logger.info(f'Project "{self.name}" finished successful') 

498 

499 # reset sim_settings: 

500 self.playground.sim_settings.load_default_settings() 

501 # clean logger 

502 log.teardown_loggers() 

503 

504 def delete(self): 

505 """Delete the project.""" 

506 self.finalize(True) 

507 self.paths.delete(False) 

508 self.logger.info("Project deleted") 

509 

510 def reset(self): 

511 """Reset the current project.""" 

512 self.logger.info("Project reset") 

513 self.playground.state.clear() 

514 self.playground.history.clear() 

515 self._made_decisions.clear() 

516 

517 def __repr__(self): 

518 return "<Project(%s)>" % self.paths.root