Coverage for tests / test_bpsconfig.py: 21%

267 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:52 +0000

1# This file is part of ctrl_bps. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <https://www.gnu.org/licenses/>. 

27import os 

28import unittest 

29 

30import yaml 

31 

32from lsst.ctrl.bps import BPS_SEARCH_ORDER, BpsConfig 

33from lsst.daf.butler import Config 

34 

35TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

36 

37 

38class TestBpsConfigConstructor(unittest.TestCase): 

39 """Test BpsConfig construction.""" 

40 

41 def setUp(self): 

42 self.filename = os.path.join(TESTDIR, "data/config.yaml") 

43 with open(self.filename) as f: 

44 self.dictionary = yaml.safe_load(f) 

45 

46 def tearDown(self): 

47 pass 

48 

49 def testFromFilename(self): 

50 """Test initialization from a file, but without defaults.""" 

51 config = BpsConfig(self.filename) 

52 self.assertEqual(set(config), {"foo", "bar", "baz"} | set(BPS_SEARCH_ORDER)) 

53 self.assertEqual(set(config["foo"]), {"qux"}) 

54 self.assertEqual(set(config["bar"]), {"qux"}) 

55 self.assertEqual(set(config["baz"]), {"grault", "garply"}) 

56 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

57 

58 def testFromDict(self): 

59 """Test initialization from a dictionary.""" 

60 config = BpsConfig(self.dictionary) 

61 self.assertEqual(set(config), {"foo", "bar", "baz"} | set(BPS_SEARCH_ORDER)) 

62 self.assertEqual(set(config["foo"]), {"qux"}) 

63 self.assertEqual(set(config["bar"]), {"qux"}) 

64 self.assertEqual(set(config["baz"]), {"grault", "garply"}) 

65 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

66 

67 def testFromConfig(self): 

68 """Test initialization from other Config object.""" 

69 config_daf = Config(self.dictionary) 

70 config = BpsConfig(config_daf) 

71 self.assertEqual(set(config), {"foo", "bar", "baz"} | set(BPS_SEARCH_ORDER)) 

72 self.assertEqual(set(config["foo"]), {"qux"}) 

73 self.assertEqual(set(config["bar"]), {"qux"}) 

74 self.assertEqual(set(config["baz"]), {"grault", "garply"}) 

75 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

76 

77 def testFromBpsConfig(self): 

78 """Test initialization from other BpsConfig object.""" 

79 config_ref = BpsConfig(self.dictionary) 

80 config = BpsConfig(config_ref) 

81 self.assertEqual(set(config), set(config_ref)) 

82 self.assertEqual(config.search_order, config_ref.search_order) 

83 

84 def testDefaultsInclusion(self): 

85 """Test including defaults.""" 

86 config = BpsConfig(self.filename, defaults={"quux": 1}) 

87 self.assertEqual(set(config), {"foo", "bar", "baz", "quux"} | set(BPS_SEARCH_ORDER)) 

88 self.assertEqual(config["quux"], 1) 

89 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

90 

91 def testDefaultsOverride(self): 

92 """Test overriding defaults.""" 

93 config = BpsConfig(self.filename, defaults={"foo": {"qux": 2}, "quux": 1}) 

94 self.assertEqual(set(config), {"foo", "bar", "baz", "quux"} | set(BPS_SEARCH_ORDER)) 

95 self.assertEqual(config["quux"], 1) 

96 self.assertEqual(config["foo"], BpsConfig({"qux": 1}, search_order=[])) 

97 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

98 

99 def testWmsFromCmdline(self): 

100 """Test initialization when WMS class specified at the cmdline.""" 

101 with unittest.mock.patch.dict( 

102 os.environ, {"BPS_WMS_SERVICE_CLASS": "wms_test_utils.WmsServiceFromEnv"} 

103 ): 

104 filename = os.path.join(TESTDIR, "data/config_with_wms.yaml") 

105 config = BpsConfig( 

106 filename, 

107 defaults={"wmsServiceClass": "wms_test_utils.WmsServiceFromDefaults"}, 

108 wms_service_class_fqn="wms_test_utils.WmsServiceFromCmdline", 

109 ) 

110 self.assertEqual(set(config), {"corge", "wmsServiceClass"} | set(BPS_SEARCH_ORDER)) 

111 self.assertEqual(config["corge"], "cmdline") 

112 

113 def testWmsFromConfig(self): 

114 """Test initialization when WMS class specified in the config.""" 

115 with unittest.mock.patch.dict( 

116 os.environ, {"BPS_WMS_SERVICE_CLASS": "wms_test_utils.WmsServiceFromEnv"} 

117 ): 

118 filename = os.path.join(TESTDIR, "data/config_with_wms.yaml") 

119 config = BpsConfig( 

120 filename, 

121 defaults={"wmsServiceClass": "wms_test_utils.WmsServiceFromDefaults"}, 

122 wms_service_class_fqn=None, 

123 ) 

124 self.assertEqual(set(config), {"corge", "wmsServiceClass"} | set(BPS_SEARCH_ORDER)) 

125 self.assertEqual(config["corge"], "config") 

126 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

127 

128 def testWmsFromEnv(self): 

129 """Test initialization with WMS class in the env.""" 

130 with unittest.mock.patch.dict( 

131 os.environ, {"BPS_WMS_SERVICE_CLASS": "wms_test_utils.WmsServiceFromEnv"} 

132 ): 

133 config = BpsConfig( 

134 {}, 

135 defaults={"wmsServiceClass": "wms_test_utils.WmsServiceFromDefaults"}, 

136 wms_service_class_fqn=None, 

137 ) 

138 self.assertEqual(set(config), {"corge", "wmsServiceClass"} | set(BPS_SEARCH_ORDER)) 

139 self.assertEqual(config["corge"], "env") 

140 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

141 

142 def testWmsFromDefaults(self): 

143 """Test initialization with a default WMS class.""" 

144 config = BpsConfig({}, defaults={"wmsServiceClass": "wms_test_utils.WmsServiceFromDefaults"}) 

145 self.assertEqual(set(config), {"corge", "wmsServiceClass"} | set(BPS_SEARCH_ORDER)) 

146 self.assertEqual(config["corge"], "defaults") 

147 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

148 

149 def testDefaultsExclusion(self): 

150 config = BpsConfig( 

151 self.filename, defaults=None, wms_service_class_fqn="wms_test_utils.WmsServiceFromCmdline" 

152 ) 

153 self.assertEqual(set(config), {"foo", "bar", "baz"} | set(BPS_SEARCH_ORDER)) 

154 self.assertEqual(set(config["foo"]), {"qux"}) 

155 self.assertEqual(set(config["bar"]), {"qux"}) 

156 self.assertEqual(set(config["baz"]), {"grault", "garply"}) 

157 self.assertEqual(config.search_order, BPS_SEARCH_ORDER) 

158 

159 def testInvalidArg(self): 

160 """Test if exception is raised for an argument of unsupported type.""" 

161 sequence = ["wibble", "wobble", "wubble", "flob"] 

162 with self.assertRaises(ValueError): 

163 BpsConfig(sequence) 

164 

165 

166class TestBpsConfigGet(unittest.TestCase): 

167 """Test retrieval of items from BpsConfig.""" 

168 

169 def setUp(self): 

170 self.config = BpsConfig({"foo": "bar"}) 

171 

172 def tearDown(self): 

173 pass 

174 

175 def testKeyExistsNoDefault(self): 

176 """Test if the value is returned when the key is in the dictionary.""" 

177 self.assertEqual("bar", self.config.get("foo")) 

178 

179 def testKeyExistsDefaultProvided(self): 

180 """Test if the value is returned when the key is in the dictionary.""" 

181 self.assertEqual("bar", self.config.get("foo", "qux")) 

182 

183 def testKeyMissingNoDefault(self): 

184 """Test if the provided default is returned if the key is missing.""" 

185 self.assertEqual("", self.config.get("baz")) 

186 

187 def testKeyMissingDefaultProvided(self): 

188 """Test if the custom default is returned if the key is missing.""" 

189 self.assertEqual("qux", self.config.get("baz", "qux")) 

190 

191 

192class TestBpsConfigSearch(unittest.TestCase): 

193 """Test searching of BpsConfig.""" 

194 

195 def setUp(self): 

196 filename = os.path.join(TESTDIR, "data/config.yaml") 

197 self.config = BpsConfig(filename, search_order=["baz", "bar", "foo"], defaults={}) 

198 os.environ["GARPLY"] = "garply" 

199 

200 def tearDown(self): 

201 del os.environ["GARPLY"] 

202 

203 def testSectionSearchOrder(self): 

204 """Test if sections are searched in the prescribed order.""" 

205 key = "qux" 

206 found, value = self.config.search(key) 

207 self.assertEqual(found, True) 

208 self.assertEqual(value, 2) 

209 

210 def testCurrentValues(self): 

211 """Test if a current value overrides of the one in configuration.""" 

212 found, value = self.config.search("qux", opt={"curvals": {"qux": -3}}) 

213 self.assertEqual(found, True) 

214 self.assertEqual(value, -3) 

215 

216 def testSearchobjValues(self): 

217 """Test if a serachobj value overrides of the one in configuration.""" 

218 options = {"searchobj": {"qux": 4}} 

219 found, value = self.config.search("qux", opt=options) 

220 self.assertEqual(found, True) 

221 self.assertEqual(value, 4) 

222 

223 def testSubsectionSearch(self): 

224 options = {"curvals": {"curr_baz": "garply"}} 

225 found, value = self.config.search("qux", opt=options) 

226 self.assertEqual(found, True) 

227 self.assertEqual(value, 3) 

228 

229 def testDefault(self): 

230 """Test if a default value is properly set.""" 

231 found, value = self.config.search("plugh", opt={"default": 4}) 

232 self.assertEqual(found, True) 

233 self.assertEqual(value, 4) 

234 

235 def testReplaceVars(self): 

236 """Test replaceVars method.""" 

237 test_opt = {"default": 555} 

238 orig_str = "<ENV:GARPLY>/waldo/{qux:03}/{notthere}" 

239 value = self.config.replace_vars(orig_str, opt=test_opt) 

240 self.assertEqual(value, "<ENV:GARPLY>/waldo/002/") 

241 self.assertEqual(test_opt["default"], 555) 

242 

243 def testReplaceVarsSkipNames(self): 

244 test_opt = {"default": 555, "skipNames": ["qgraphFile", "butlerConfig"]} 

245 orig_str = "<ENV:GARPLY>/waldo/{qux:03} {qgraphFile} {butlerConfig}" 

246 value = self.config.replace_vars(orig_str, opt=test_opt) 

247 self.assertEqual(value, "<ENV:GARPLY>/waldo/002 {qgraphFile} {butlerConfig}") 

248 self.assertEqual(test_opt["default"], 555) 

249 

250 def testVariables(self): 

251 """Test combinations of expandEnvVars, replaceEnvVars, 

252 and replaceVars. 

253 """ 

254 test_opt = {"expandEnvVars": False, "replaceEnvVars": False, "replaceVars": False} 

255 found, value = self.config.search("grault", opt=test_opt) 

256 self.assertEqual(found, True) 

257 self.assertEqual(value, "${GARPLY}/waldo/{qux:03}") 

258 

259 test_opt = {"expandEnvVars": False, "replaceEnvVars": False, "replaceVars": True} 

260 found, value = self.config.search("grault", opt=test_opt) 

261 self.assertEqual(found, True) 

262 self.assertEqual(value, "${GARPLY}/waldo/002") 

263 

264 test_opt = {"expandEnvVars": False, "replaceEnvVars": True, "replaceVars": False} 

265 found, value = self.config.search("grault", opt=test_opt) 

266 self.assertEqual(found, True) 

267 self.assertEqual(value, "<ENV:GARPLY>/waldo/{qux:03}") 

268 

269 test_opt = {"expandEnvVars": False, "replaceEnvVars": True, "replaceVars": True} 

270 found, value = self.config.search("grault", opt=test_opt) 

271 self.assertEqual(found, True) 

272 self.assertEqual(value, "<ENV:GARPLY>/waldo/002") 

273 

274 test_opt = {"expandEnvVars": True, "replaceEnvVars": False, "replaceVars": False} 

275 found, value = self.config.search("grault", opt=test_opt) 

276 self.assertEqual(found, True) 

277 self.assertEqual(value, "garply/waldo/{qux:03}") 

278 

279 test_opt = {"expandEnvVars": True, "replaceEnvVars": False, "replaceVars": True} 

280 found, value = self.config.search("grault", opt=test_opt) 

281 self.assertEqual(found, True) 

282 self.assertEqual(value, "garply/waldo/002") 

283 

284 test_opt = {"expandEnvVars": True, "replaceEnvVars": True, "replaceVars": False} 

285 found, value = self.config.search("grault", opt=test_opt) 

286 self.assertEqual(found, True) 

287 self.assertEqual(value, "garply/waldo/{qux:03}") 

288 

289 test_opt = {"expandEnvVars": True, "replaceEnvVars": True, "replaceVars": True} 

290 found, value = self.config.search("grault", opt=test_opt) 

291 self.assertEqual(found, True) 

292 self.assertEqual(value, "garply/waldo/002") 

293 

294 def testRequired(self): 

295 """Test if exception is raised if a required setting is missing.""" 

296 with self.assertRaises(KeyError): 

297 self.config.search("fred", opt={"required": True}) 

298 

299 def testS3Path(self): 

300 """Test that double slashes aren't replaced with single slash.""" 

301 config = BpsConfig(self.config) 

302 s3 = "s3://user1@rubin-place-users/butler-pipeline1-processing.yaml" 

303 config["butlerConfig"] = s3 

304 test_opt = {"expandEnvVars": True, "replaceEnvVars": True, "replaceVars": True} 

305 found, value = config.search("butlerConfig", opt=test_opt) 

306 self.assertEqual(found, True) 

307 self.assertEqual(value, s3) 

308 

309 def testSubDirTemplate(self): 

310 """Test that double slashes are replaced with single slash 

311 in subDirTemplate. 

312 """ 

313 config = BpsConfig(self.config) 

314 

315 # template doesn't end in slash, post-slashes value didn't end in slash 

316 config["subDirTemplate"] = "{label}/{val1}/{val2}/{val3}/{val4}/{val5}" 

317 test_opt = { 

318 "expandEnvVars": True, 

319 "replaceEnvVars": True, 

320 "replaceVars": True, 

321 "curvals": {"label": "label1", "val5": 12345}, 

322 } 

323 found, value = config.search("subDirTemplate", opt=test_opt) 

324 self.assertEqual(found, True) 

325 self.assertEqual(value, "label1/12345") 

326 

327 # template doesn't end in slash, post-slashes value ended in slash 

328 config["subDirTemplate"] = "{label}/{val1}/{val2}/{val3}/{val4}/{val5}" 

329 test_opt = { 

330 "expandEnvVars": True, 

331 "replaceEnvVars": True, 

332 "replaceVars": True, 

333 "curvals": {"label": "label2", "val4": 12345}, 

334 } 

335 found, value = config.search("subDirTemplate", opt=test_opt) 

336 self.assertEqual(found, True) 

337 self.assertEqual(value, "label2/12345") 

338 

339 # template ends in slash, post-slashes value didn't end in slash 

340 config["subDirTemplate"] = "{label}/{val1}/{val2}/{val3}/{val4}/{val5}/" 

341 test_opt = { 

342 "expandEnvVars": True, 

343 "replaceEnvVars": True, 

344 "replaceVars": True, 

345 "curvals": {"label": "label3", "val4": 12345}, 

346 } 

347 found, value = config.search("subDirTemplate", opt=test_opt) 

348 self.assertEqual(found, True) 

349 self.assertEqual(value, "label3/12345/") 

350 

351 # template ends in slash, post-slashes value ended in slash 

352 config["subDirTemplate"] = "{label}/{val1}/{val2}/{val3}/{val4}/{val5}/" 

353 test_opt = { 

354 "expandEnvVars": True, 

355 "replaceEnvVars": True, 

356 "replaceVars": True, 

357 "curvals": {"label": "label4", "val2": 12345}, 

358 } 

359 found, value = config.search("subDirTemplate", opt=test_opt) 

360 self.assertEqual(found, True) 

361 self.assertEqual(value, "label4/12345/") 

362 

363 def testOtherTemplate(self): 

364 """Test that other subDirTemplate-like value doesn't have 

365 double slashes replaced. 

366 """ 

367 config = BpsConfig(self.config) 

368 

369 config["otherTemplate"] = "{label}/{val1}/{val2}/{val3}/{val4}/{val5}" 

370 test_opt = { 

371 "expandEnvVars": True, 

372 "replaceEnvVars": True, 

373 "replaceVars": True, 

374 "curvals": {"label": "label1", "val4": 12345, "val5": ""}, 

375 } 

376 found, value = config.search("otherTemplate", opt=test_opt) 

377 self.assertEqual(found, True) 

378 self.assertEqual(value, "label1////12345/") 

379 

380 

381class TestBpsConfigGenerateConfig(unittest.TestCase): 

382 """Test BpsConfig.generate_config and bpsEval methods.""" 

383 

384 def setUp(self): 

385 # Just to shorten string length in tests 

386 self.test_prefix = "lsst.ctrl.bps.tests.config_test_utils" 

387 

388 filename = os.path.join(TESTDIR, "data/initialize_config.yaml") 

389 self.config = BpsConfig(filename, BPS_SEARCH_ORDER, defaults={}) 

390 

391 def testUnparsableValue(self): 

392 config = BpsConfig( 

393 { 

394 "p1": 3, 

395 "p3": 16, 

396 }, 

397 BPS_SEARCH_ORDER, 

398 ) 

399 # invalid function name 

400 config["bpsGenerateConfig"] = "not a valid name({p1}, param3={p3})" 

401 with self.assertRaisesRegex(ValueError, "Unparsable bpsGenerateConfig value='not a valid"): 

402 config.generate_config() 

403 

404 def testMissingParen(self): 

405 config = BpsConfig( 

406 { 

407 "p1": 3, 

408 "p3": 16, 

409 }, 

410 BPS_SEARCH_ORDER, 

411 ) 

412 # invalid function name 

413 config["bpsGenerateConfig"] = self.test_prefix + ".generate_config_1(1, param3=2" 

414 with self.assertRaisesRegex(ValueError, "Unparsable bpsGenerateConfig value='"): 

415 config.generate_config() 

416 

417 def testBadFunctionName(self): 

418 config = BpsConfig( 

419 { 

420 "p1": 3, 

421 "p3": 16, 

422 }, 

423 BPS_SEARCH_ORDER, 

424 ) 

425 # invalid function name 

426 config["bpsGenerateConfig"] = self.test_prefix + ".notthere({p1}, param3={p3})" 

427 with self.assertRaisesRegex(ImportError, "notthere"): 

428 config.generate_config() 

429 

430 def testBadModuleName(self): 

431 config = BpsConfig( 

432 { 

433 "p1": 3, 

434 "p3": 16, 

435 }, 

436 BPS_SEARCH_ORDER, 

437 ) 

438 # invalid module name 

439 config["bpsGenerateConfig"] = "lsst.ctrl.bps.notthere.generate_config(1, 2)" 

440 with self.assertRaisesRegex(ImportError, "notthere"): 

441 config.generate_config() 

442 

443 def testBadParamName(self): 

444 config = BpsConfig( 

445 {"p1": 3, "p3": 16, "bpsGenerateConfig": self.test_prefix + ".generate_config_1(1, param5=2)"}, 

446 BPS_SEARCH_ORDER, 

447 ) 

448 with self.assertRaisesRegex(TypeError, "unexpected keyword argument 'param5'"): 

449 config.generate_config() 

450 

451 def testExtraParam(self): 

452 config = BpsConfig( 

453 { 

454 "p1": 3, 

455 "p3": 16, 

456 "bpsGenerateConfig": self.test_prefix + ".generate_config_1({p1}, 2, {p3}, 4)", 

457 }, 

458 BPS_SEARCH_ORDER, 

459 ) 

460 with self.assertRaisesRegex(TypeError, "positional arguments"): 

461 config.generate_config() 

462 

463 def testWithSearchOrder(self): 

464 # Check that bpsGenerateConfig is replaced in search sections 

465 # (e.g., pipetask) And when replacing vars in subsections 

466 # config ordering is used. 

467 # Ditto for finalJob (which isn't a search section). 

468 # Checking all in single function to ensure doesn't quit early. 

469 self.config.generate_config() 

470 

471 filename = os.path.join(TESTDIR, "data/initialize_config_truth.yaml") 

472 truth = BpsConfig(filename, BPS_SEARCH_ORDER, defaults={}) 

473 

474 self.assertEqual(self.config, truth) 

475 

476 def testBpsEval(self): 

477 """Test replacing bpsEval when need to import module.""" 

478 test_opt = { 

479 "expandEnvVars": True, 

480 "replaceEnvVars": True, 

481 "replaceVars": True, 

482 "curvals": {"curr_pipetask": "ptask1"}, 

483 } 

484 found, value = self.config.search("genval1", opt=test_opt) 

485 self.assertEqual(found, True) 

486 self.assertEqual(value, "-c val1:0.1 -c val2:3") 

487 

488 def testBpsEvalBuiltin(self): 

489 """Test replacing bpsEval with builtin function.""" 

490 test_opt = { 

491 "expandEnvVars": True, 

492 "replaceEnvVars": True, 

493 "replaceVars": True, 

494 "curvals": {"curr_pipetask": "ptask1"}, 

495 } 

496 found, value = self.config.search("genval2", opt=test_opt) 

497 self.assertEqual(found, True) 

498 self.assertEqual(value, "-c val1:32") 

499 

500 def testBpsEvalInvalid(self): 

501 """Test reporting not replacing bpsEval.""" 

502 test_opt = { 

503 "expandEnvVars": True, 

504 "replaceEnvVars": True, 

505 "replaceVars": True, 

506 "curvals": {"curr_pipetask": "ptask1"}, 

507 } 

508 

509 self.config["badkey1"] = "badval1 bpsEval('sum([1,2]') blah" 

510 with self.assertRaisesRegex(ValueError, "Unparsable bpsEval in 'badval1 bpsEval"): 

511 _ = self.config.search("badkey1", opt=test_opt) 

512 

513 

514if __name__ == "__main__": 

515 unittest.main()