Coverage for python/lsst/daf/butler/_config.py: 94%

501 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-16 09:11 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 <http://www.gnu.org/licenses/>. 

27 

28"""Configuration control.""" 

29 

30from __future__ import annotations 

31 

32__all__ = ("Config", "ConfigSubset") 

33 

34import copy 

35import io 

36import json 

37import logging 

38import os 

39import pprint 

40import sys 

41from collections import defaultdict 

42from collections.abc import Iterable, Iterator, Mapping, MutableMapping, Sequence 

43from pathlib import Path 

44from typing import IO, TYPE_CHECKING, Any, ClassVar 

45 

46import yaml 

47from yaml.representer import Representer 

48 

49from lsst.resources import ResourcePath, ResourcePathExpression 

50from lsst.utils import doImportType 

51 

52yaml.add_representer(defaultdict, Representer.represent_dict) 

53 

54 

55# Config module logger 

56log = logging.getLogger(__name__) 

57 

58# PATH-like environment variable to use for defaults. 

59CONFIG_PATH = "DAF_BUTLER_CONFIG_PATH" 

60 

61if TYPE_CHECKING: 

62 yamlLoader = yaml.SafeLoader 

63else: 

64 try: 

65 yamlLoader = yaml.CSafeLoader 

66 except AttributeError: 

67 # Not all installations have the C library 

68 # (but assume for mypy's sake that they're the same) 

69 yamlLoader = yaml.SafeLoader 

70 

71 

72def _doUpdate(d: Mapping[str, Any], u: Mapping[str, Any]) -> Mapping[str, Any]: 

73 if not isinstance(u, Mapping) or not isinstance(d, MutableMapping): 

74 raise RuntimeError(f"Only call update with Mapping, not {type(d)}") 

75 return _mergeInto(d, u) 

76 

77 

78def _mergeInto(d: Any, u: Mapping[str, Any]) -> Any: 

79 """Merge ``u`` into ``d`` recursively. 

80 

81 Parameters 

82 ---------- 

83 d : `~collections.abc.MutableMapping` 

84 Mapping to update in place. 

85 u : `~collections.abc.Mapping` 

86 Mapping supplying the new values. 

87 

88 Returns 

89 ------- 

90 d : `~collections.abc.MutableMapping` 

91 The updated mapping. 

92 

93 Notes 

94 ----- 

95 Configuration values are almost always plain dictionaries read from YAML, 

96 so an exact `dict` check is tried before the abstract base class check. 

97 The abstract check is several times more expensive and this runs once per 

98 node of every subtree that is merged. 

99 """ 

100 for k, v in u.items(): 

101 if type(v) is dict or isinstance(v, Mapping): 

102 lhs = d.get(k) 

103 if type(lhs) is not dict and not isinstance(lhs, MutableMapping): 

104 lhs = {} 

105 d[k] = _mergeInto(lhs, v) 

106 else: 

107 d[k] = v 

108 return d 

109 

110 

111# Types that are common in configurations and are never mappings. Checking 

112# membership here is much cheaper than an abstract base class check, and these 

113# account for the overwhelming majority of configuration values. 

114_NON_MAPPING_TYPES = frozenset({str, int, float, bool, type(None), list, tuple}) 

115 

116 

117def _copyMapping(u: Mapping[str, Any]) -> dict[str, Any]: 

118 """Return a copy of a mapping, recursing into nested mappings. 

119 

120 Parameters 

121 ---------- 

122 u : `~collections.abc.Mapping` 

123 Mapping to copy. 

124 

125 Returns 

126 ------- 

127 copied : `dict` 

128 A new `dict`. Nested mappings, including `Config` instances, are 

129 copied into plain dictionaries. All other values, including lists, are 

130 stored by reference. 

131 

132 Notes 

133 ----- 

134 This is equivalent to merging into an empty mapping but skips the 

135 look-up and type check of the target that a merge needs, and avoids an 

136 abstract base class check for the value types that dominate real 

137 configurations. 

138 """ 

139 copied: dict[str, Any] = {} 

140 for k, v in u.items(): 

141 t = type(v) 

142 if t is dict: 

143 copied[k] = _copyMapping(v) 

144 elif t in _NON_MAPPING_TYPES: 

145 copied[k] = v 

146 elif isinstance(v, Mapping): 146 ↛ 149line 146 didn't jump to line 149 because the condition on line 146 was always true

147 copied[k] = _copyMapping(v) 

148 else: 

149 copied[k] = v 

150 return copied 

151 

152 

153def _checkNextItem(k: str | int, d: Any, create: bool, must_be_dict: bool) -> tuple[Any, bool]: 

154 """See if k is in d and if it is return the new child.""" 

155 nextVal = None 

156 isThere = False 

157 if d is None: 

158 # We have gone past the end of the hierarchy 

159 pass 

160 elif not must_be_dict and isinstance(d, Sequence): 

161 # Check for Sequence first because for lists 

162 # __contains__ checks whether value is found in list 

163 # not whether the index exists in list. When we traverse 

164 # the hierarchy we are interested in the index. 

165 try: 

166 nextVal = d[int(k)] 

167 isThere = True 

168 except IndexError: 

169 pass 

170 except ValueError: 

171 isThere = k in d 

172 elif k in d: 

173 nextVal = d[k] 

174 isThere = True 

175 elif create: 

176 d[k] = {} 

177 nextVal = d[k] 

178 isThere = True 

179 

180 return nextVal, isThere 

181 

182 

183class Loader(yamlLoader): 

184 """YAML Loader that supports file include directives. 

185 

186 Uses ``!include`` directive in a YAML file to point to another 

187 YAML file to be included. The path in the include directive is relative 

188 to the file containing that directive. 

189 

190 storageClasses: !include storageClasses.yaml 

191 

192 Examples 

193 -------- 

194 >>> with open("document.yaml", "r") as f: 

195 data = yaml.load(f, Loader=Loader) 

196 

197 Notes 

198 ----- 

199 See https://davidchall.github.io/yaml-includes.html 

200 

201 Parameters 

202 ---------- 

203 stream : `str` or `io.IO` 

204 The stream to parse. 

205 """ 

206 

207 def __init__(self, stream: str | IO): # types-PyYAML annotates 'stream' with a private type 

208 super().__init__(stream) 

209 # if this is a string and not a stream we may well lack a name 

210 if hasattr(stream, "name"): 

211 self._root = ResourcePath(stream.name, forceDirectory=False) 

212 else: 

213 # No choice but to assume a local filesystem 

214 self._root = ResourcePath("no-file.yaml", forceDirectory=False) 

215 self.add_constructor("!include", Loader.include) 

216 

217 def include(self, node: yaml.Node) -> list[Any] | dict[str, Any]: 

218 result: list[Any] | dict[str, Any] 

219 if isinstance(node, yaml.ScalarNode): 

220 return self.extractFile(self.construct_scalar(node)) # type: ignore[arg-type] 

221 

222 elif isinstance(node, yaml.SequenceNode): 

223 result = [] 

224 for filename in self.construct_sequence(node): 

225 result.append(self.extractFile(filename)) 

226 return result 

227 

228 elif isinstance(node, yaml.MappingNode): 228 ↛ 237line 228 didn't jump to line 237 because the condition on line 228 was always true

229 result = {} 

230 for k, v in self.construct_mapping(node).items(): 

231 if not isinstance(k, str): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 raise TypeError(f"Expected only strings in YAML mapping; got {k!r} of type {type(k)}.") 

233 result[k] = self.extractFile(v) 

234 return result 

235 

236 else: 

237 print("Error:: unrecognised node type in !include statement", file=sys.stderr) 

238 raise yaml.constructor.ConstructorError 

239 

240 def extractFile(self, filename: str) -> Any: 

241 # It is possible for the !include to point to an explicit URI 

242 # instead of a relative URI, therefore we first see if it is 

243 # scheme-less or not. If it has a scheme we use it directly 

244 # if it is scheme-less we use it relative to the file root. 

245 requesteduri = ResourcePath(filename, forceAbsolute=False, forceDirectory=False) 

246 

247 if requesteduri.scheme: 

248 fileuri = requesteduri 

249 else: 

250 fileuri = self._root.updatedFile(filename) 

251 

252 log.debug("Opening YAML file via !include: %s", fileuri) 

253 

254 # Read all the data from the resource 

255 data = fileuri.read() 

256 

257 # Store the bytes into a BytesIO so we can attach a .name 

258 stream = io.BytesIO(data) 

259 stream.name = fileuri.geturl() 

260 return yaml.load(stream, Loader) 

261 

262 

263# Type of the key used for accessing items in configuration object. It can be 

264# a single string as described below or a sequence of srtings and integer 

265# indices. Indices are used to access items in sequences stored in config. 

266_ConfigKey = str | Sequence[str | int] 

267 

268 

269class Config(MutableMapping): 

270 r"""Implements a datatype that is used by `Butler` for configuration. 

271 

272 It is essentially a `dict` with key/value pairs, including nested dicts 

273 (as values). In fact, it can be initialized with a `dict`. 

274 This is explained next: 

275 

276 Config extends the `dict` api so that hierarchical values may be accessed 

277 with delimited notation or as a tuple. If a string is given the delimiter 

278 is picked up from the first character in that string. For example, 

279 ``foo.getValue(".a.b.c")``, ``foo["a"]["b"]["c"]``, ``foo["a", "b", "c"]``, 

280 ``foo[".a.b.c"]``, and ``foo["/a/b/c"]`` all achieve the same outcome. 

281 If the first character is alphanumeric, no delimiter will be used. 

282 ``foo["a.b.c"]`` will be a single key ``a.b.c`` as will ``foo[":a.b.c"]``. 

283 Unicode characters can be used as the delimiter for distinctiveness if 

284 required. 

285 

286 If a key in the hierarchy starts with a non-alphanumeric character care 

287 should be used to ensure that either the tuple interface is used or 

288 a distinct delimiter is always given in string form. 

289 

290 Finally, the delimiter can be escaped if it is part of a key and also 

291 has to be used as a delimiter. For example, ``foo[r".a.b\.c"]`` results in 

292 a two element hierarchy of ``a`` and ``b.c``. For hard-coded strings it is 

293 always better to use a different delimiter in these cases. 

294 

295 Note that adding a multi-level key implicitly creates any nesting levels 

296 that do not exist, but removing multi-level keys does not automatically 

297 remove empty nesting levels. As a result: 

298 

299 >>> c = Config() 

300 >>> c[".a.b"] = 1 

301 >>> del c[".a.b"] 

302 >>> c["a"] 

303 Config({'a': {}}) 

304 

305 Storage formats supported: 

306 

307 - yaml: read and write is supported. 

308 - json: read and write is supported but no ``!include`` directive. 

309 

310 Parameters 

311 ---------- 

312 other : `lsst.resources.ResourcePath` or `Config` or `dict` 

313 Other source of configuration, can be: 

314 

315 - (`lsst.resources.ResourcePathExpression`) 

316 Treated as a URI to a config file. Must end with ".yaml". 

317 - (`Config`) Copies the other Config's values into this one. 

318 - (`dict`) Copies the values from the dict into this Config. 

319 

320 If `None` is provided an empty `Config` will be created. 

321 """ 

322 

323 _D: str = "→" 

324 """Default internal delimiter to use for components in the hierarchy when 

325 constructing keys for external use (see `Config.names()`).""" 

326 

327 includeKey: ClassVar[str] = "includeConfigs" 

328 """Key used to indicate that another config should be included at this 

329 part of the hierarchy.""" 

330 

331 resourcesPackage: str = "lsst.daf.butler" 

332 """Package to search for default configuration data. The resources 

333 themselves will be within a ``configs`` resource hierarchy.""" 

334 

335 def __init__(self, other: ResourcePathExpression | Config | Mapping[str, Any] | None = None): 

336 self._data: dict[str, Any] = {} 

337 self.configFile: ResourcePath | None = None 

338 

339 if other is None: 

340 return 

341 

342 if isinstance(other, Config): 

343 # Copying rather than deep copying, because a config entry may 

344 # hold an object that cannot be deep copied. Nested mappings are 

345 # copied; everything else is stored by reference, which is the 

346 # same behaviour a merge into an empty config would give. 

347 self._data = _copyMapping(other._data) 

348 self.configFile = other.configFile 

349 elif isinstance(other, dict | Mapping): 

350 # In most cases we have a dict, and it's more efficient 

351 # to check for a dict instance before checking the generic mapping. 

352 self._data = _copyMapping(other) 

353 elif isinstance(other, str | ResourcePath | Path): 

354 # if other is a string, assume it is a file path/URI 

355 self.__initFromUri(other) 

356 self._processExplicitIncludes() 

357 else: 

358 # if the config specified by other could not be recognized raise 

359 # a runtime error. 

360 raise RuntimeError(f"A Config could not be loaded from other: {other}") 

361 

362 def ppprint(self) -> str: 

363 """Return config as formatted readable string. 

364 

365 Examples 

366 -------- 

367 use: ``pdb> print(myConfigObject.ppprint())`` 

368 

369 Returns 

370 ------- 

371 s : `str` 

372 A prettyprint formatted string representing the config. 

373 """ 

374 return pprint.pformat(self._data, indent=2, width=1) 

375 

376 def __repr__(self) -> str: 

377 return f"{type(self).__name__}({self._data!r})" 

378 

379 def __str__(self) -> str: 

380 return self.ppprint() 

381 

382 def __len__(self) -> int: 

383 return len(self._data) 

384 

385 def __iter__(self) -> Iterator[str]: 

386 return iter(self._data) 

387 

388 def copy(self) -> Config: 

389 return type(self)(self) 

390 

391 @classmethod 

392 def fromString(cls, string: str, format: str = "yaml") -> Config: 

393 """Create a new Config instance from a serialized string. 

394 

395 Parameters 

396 ---------- 

397 string : `str` 

398 String containing content in specified format. 

399 format : `str`, optional 

400 Format of the supplied string. Can be ``json`` or ``yaml``. 

401 

402 Returns 

403 ------- 

404 c : `Config` 

405 Newly-constructed Config. 

406 """ 

407 if format == "yaml": 

408 new_config = cls().__initFromYaml(string) 

409 elif format == "json": 

410 new_config = cls().__initFromJson(string) 

411 else: 

412 raise ValueError(f"Unexpected format of string: {format}") 

413 new_config._processExplicitIncludes() 

414 return new_config 

415 

416 @classmethod 

417 def fromYaml(cls, string: str) -> Config: 

418 """Create a new Config instance from a YAML string. 

419 

420 Parameters 

421 ---------- 

422 string : `str` 

423 String containing content in YAML format. 

424 

425 Returns 

426 ------- 

427 c : `Config` 

428 Newly-constructed Config. 

429 """ 

430 return cls.fromString(string, format="yaml") 

431 

432 def __initFromUri(self, path: ResourcePathExpression) -> None: 

433 """Load a file from a path or an URI. 

434 

435 Parameters 

436 ---------- 

437 path : `lsst.resources.ResourcePathExpression` 

438 Path or a URI to a persisted config file. 

439 """ 

440 uri = ResourcePath(path, forceDirectory=False) 

441 ext = uri.getExtension() 

442 if ext == ".yaml": 

443 log.debug("Opening YAML config file: %s", uri.geturl()) 

444 content = uri.read() 

445 # Use a stream so we can name it 

446 stream = io.BytesIO(content) 

447 stream.name = uri.geturl() 

448 self.__initFromYaml(stream) 

449 elif ext == ".json": 

450 log.debug("Opening JSON config file: %s", uri.geturl()) 

451 content = uri.read() 

452 self.__initFromJson(content) 

453 else: 

454 # This URI does not have a valid extension. It might be because 

455 # we ended up with a directory and not a file. Before we complain 

456 # about an extension, do an existence check. No need to do 

457 # the (possibly expensive) existence check in the default code 

458 # path above because we will find out soon enough that the file 

459 # is not there. 

460 if not uri.exists(): 

461 raise FileNotFoundError(f"Config location {uri} does not exist.") 

462 raise RuntimeError(f"The Config URI does not have a supported extension: {uri}") 

463 self.configFile = uri 

464 

465 def __initFromYaml(self, stream: IO | str | bytes) -> Config: 

466 """Load a YAML config from any readable stream that contains one. 

467 

468 Parameters 

469 ---------- 

470 stream : `IO` or `str` 

471 Stream to pass to the YAML loader. Accepts anything that 

472 `yaml.load` accepts. This can include a string as well as an 

473 IO stream. 

474 

475 Raises 

476 ------ 

477 yaml.YAMLError 

478 If there is an error loading the file. 

479 """ 

480 content = yaml.load(stream, Loader=Loader) 

481 if content is None: 

482 content = {} 

483 self._data = content 

484 return self 

485 

486 def __initFromJson(self, stream: IO | str | bytes) -> Config: 

487 """Load a JSON config from any readable stream that contains one. 

488 

489 Parameters 

490 ---------- 

491 stream : `IO` or `str` 

492 Stream to pass to the JSON loader. This can include a string as 

493 well as an IO stream. 

494 

495 Raises 

496 ------ 

497 TypeError: 

498 Raised if there is an error loading the content. 

499 """ 

500 if isinstance(stream, bytes | str): 500 ↛ 503line 500 didn't jump to line 503 because the condition on line 500 was always true

501 content = json.loads(stream) 

502 else: 

503 content = json.load(stream) 

504 if content is None: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 content = {} 

506 self._data = content 

507 return self 

508 

509 def _processExplicitIncludes(self) -> None: 

510 """Scan through the configuration searching for the special includes. 

511 

512 Looks for ``includeConfigs`` directive and processes the includes. 

513 """ 

514 # Search paths for config files 

515 searchPaths = [ResourcePath(os.path.curdir, forceDirectory=True)] 

516 if self.configFile is not None: 

517 if isinstance(self.configFile, ResourcePath): 517 ↛ 520line 517 didn't jump to line 520 because the condition on line 517 was always true

518 configDir = self.configFile.dirname() 

519 else: 

520 raise RuntimeError(f"Unexpected type for config file: {self.configFile}") 

521 searchPaths.append(configDir) 

522 

523 # Ensure we know what delimiter to use 

524 names = self.nameTuples() 

525 for path in names: 

526 if path[-1] == self.includeKey: 

527 log.debug("Processing file include directive at %s", self._D + self._D.join(path)) 

528 basePath = path[:-1] 

529 

530 # Extract the includes and then delete them from the config 

531 includes = self[path] 

532 del self[path] 

533 

534 # Be consistent and convert to a list 

535 if not isinstance(includes, list): 

536 includes = [includes] 

537 

538 # Read each file assuming it is a reference to a file 

539 # The file can be relative to config file or cwd 

540 # ConfigSubset search paths are not used 

541 subConfigs = [] 

542 for fileName in includes: 

543 # Expand any shell variables -- this could be URI 

544 fileName = ResourcePath( 

545 os.path.expandvars(fileName), forceAbsolute=False, forceDirectory=False 

546 ) 

547 found = None 

548 if fileName.isabs(): 

549 found = fileName 

550 else: 

551 for dir in searchPaths: 551 ↛ 557line 551 didn't jump to line 557 because the loop on line 551 didn't complete

552 specific = dir.join(fileName.path) 

553 # Remote resource check might be expensive 

554 if specific.exists(): 

555 found = specific 

556 break 

557 if not found: 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 raise RuntimeError(f"Unable to find referenced include file: {fileName}") 

559 

560 # Read the referenced Config as a Config 

561 subConfigs.append(type(self)(found)) 

562 

563 # Now we need to merge these sub configs with the current 

564 # information that was present in this node in the config 

565 # tree with precedence given to the explicit values 

566 newConfig = subConfigs.pop(0) 

567 for sc in subConfigs: 

568 newConfig.update(sc) 

569 

570 # Explicit values take precedence 

571 if not basePath: 

572 # This is an include at the root config 

573 newConfig.update(self) 

574 # Replace the current config 

575 self._data = newConfig._data 

576 else: 

577 newConfig.update(self[basePath]) 

578 # And reattach to the base config 

579 self[basePath] = newConfig 

580 

581 @staticmethod 

582 def _splitIntoKeys(key: _ConfigKey) -> list[str | int]: 

583 r"""Split the argument for get/set/in into a hierarchical list. 

584 

585 Parameters 

586 ---------- 

587 key : `str` or iterable 

588 Argument given to get/set/in. If an iterable is provided it will 

589 be converted to a list. If the first character of the string 

590 is not an alphanumeric character then it will be used as the 

591 delimiter for the purposes of splitting the remainder of the 

592 string. If the delimiter is also in one of the keys then it 

593 can be escaped using ``\``. There is no default delimiter. 

594 

595 Returns 

596 ------- 

597 keys : `list` 

598 Hierarchical keys as a `list`. 

599 """ 

600 if isinstance(key, str): 

601 if not key[0].isalnum(): 

602 d = key[0] 

603 key = key[1:] 

604 else: 

605 return [ 

606 key, 

607 ] 

608 escaped = f"\\{d}" 

609 temp = None 

610 if escaped in key: 

611 # Complain at the attempt to escape the escape 

612 doubled = rf"\{escaped}" 

613 if doubled in key: 

614 raise ValueError( 

615 f"Escaping an escaped delimiter ({doubled} in {key}) is not yet supported." 

616 ) 

617 # Replace with a character that won't be in the string 

618 temp = "\r" 

619 if temp in key or d == temp: 

620 raise ValueError( 

621 f"Can not use character {temp!r} in hierarchical key or as" 

622 " delimiter if escaping the delimiter" 

623 ) 

624 key = key.replace(escaped, temp) 

625 hierarchy = key.split(d) 

626 if temp: 

627 hierarchy = [h.replace(temp, d) for h in hierarchy] 

628 # Copy the list to keep mypy quiet. 

629 return list(hierarchy) 

630 elif isinstance(key, Iterable): 630 ↛ 634line 630 didn't jump to line 634 because the condition on line 630 was always true

631 return list(key) 

632 else: 

633 # Do not try to guess. 

634 raise TypeError(f"Provided key [{key}] neither str nor iterable.") 

635 

636 def _getKeyHierarchy(self, name: _ConfigKey) -> list[str | int]: 

637 """Retrieve the key hierarchy for accessing the Config. 

638 

639 Parameters 

640 ---------- 

641 name : `str` or `tuple` 

642 Delimited string or `tuple` of hierarchical keys. 

643 

644 Returns 

645 ------- 

646 hierarchy : `list` of `str` 

647 Hierarchy to use as a `list`. If the name is available directly 

648 as a key in the Config it will be used regardless of the presence 

649 of any nominal delimiter. 

650 """ 

651 keys: list[str | int] 

652 if name in self._data: 

653 keys = [name] 

654 else: 

655 keys = self._splitIntoKeys(name) 

656 return keys 

657 

658 def _findInHierarchy(self, keys: Sequence[str | int], create: bool = False) -> tuple[list[Any], bool]: 

659 """Look for hierarchy of keys in Config. 

660 

661 Parameters 

662 ---------- 

663 keys : `list` or `tuple` 

664 Keys to search in hierarchy. 

665 create : `bool`, optional 

666 If `True`, if a part of the hierarchy does not exist, insert an 

667 empty `dict` into the hierarchy. 

668 

669 Returns 

670 ------- 

671 hierarchy : `list` 

672 List of the value corresponding to each key in the supplied 

673 hierarchy. Only keys that exist in the hierarchy will have 

674 a value. 

675 complete : `bool` 

676 `True` if the full hierarchy exists and the final element 

677 in ``hierarchy`` is the value of relevant value. 

678 """ 

679 d: Any = self._data 

680 

681 # For the first key, d must be a dict so it is a waste 

682 # of time to check for a sequence. 

683 must_be_dict = True 

684 

685 hierarchy = [] 

686 complete = True 

687 for k in keys: 

688 d, isThere = _checkNextItem(k, d, create, must_be_dict) 

689 if isThere: 

690 hierarchy.append(d) 

691 else: 

692 complete = False 

693 break 

694 # Second time round it might be a sequence. 

695 must_be_dict = False 

696 

697 return hierarchy, complete 

698 

699 def __getitem__(self, name: _ConfigKey) -> Any: 

700 # Override the split for the simple case where there is an exact 

701 # match. This allows `Config.items()` to work via a simple 

702 # __iter__ implementation that returns top level keys of 

703 # self._data. 

704 

705 # If the name matches a key in the top-level hierarchy, bypass 

706 # all further cleverness. 

707 found_directly = False 

708 try: 

709 if isinstance(name, str): 

710 data = self._data[name] 

711 found_directly = True 

712 except KeyError: 

713 pass 

714 

715 if not found_directly: 

716 keys = self._getKeyHierarchy(name) 

717 

718 hierarchy, complete = self._findInHierarchy(keys) 

719 if not complete: 

720 raise KeyError(f"{name} not found") 

721 data = hierarchy[-1] 

722 

723 # In most cases we have a dict, and it's more efficient 

724 # to check for a dict instance before checking the generic mapping. 

725 if isinstance(data, dict | Mapping): 

726 data = Config(data) 

727 # Ensure that child configs inherit the parent internal delimiter 

728 if self._D != Config._D: 

729 data._D = self._D 

730 return data 

731 

732 def __setitem__(self, name: _ConfigKey, value: Any) -> None: 

733 keys = self._getKeyHierarchy(name) 

734 last = keys.pop() 

735 if isinstance(value, Config): 

736 value = copy.deepcopy(value._data) 

737 

738 hierarchy, complete = self._findInHierarchy(keys, create=True) 

739 if hierarchy: 

740 data = hierarchy[-1] 

741 else: 

742 data = self._data 

743 

744 try: 

745 data[last] = value 

746 except TypeError: 

747 data[int(last)] = value 

748 

749 def __contains__(self, key: Any) -> bool: 

750 if not isinstance(key, str | Sequence): 750 ↛ 751line 750 didn't jump to line 751 because the condition on line 750 was never true

751 return False 

752 keys = self._getKeyHierarchy(key) 

753 hierarchy, complete = self._findInHierarchy(keys) 

754 return complete 

755 

756 def __delitem__(self, key: str | Sequence[str]) -> None: 

757 keys = self._getKeyHierarchy(key) 

758 last = keys.pop() 

759 hierarchy, complete = self._findInHierarchy(keys) 

760 if complete: 760 ↛ 767line 760 didn't jump to line 767 because the condition on line 760 was always true

761 if hierarchy: 

762 data = hierarchy[-1] 

763 else: 

764 data = self._data 

765 del data[last] 

766 else: 

767 raise KeyError(f"{key} not found in Config") 

768 

769 def update(self, other: Mapping[str, Any]) -> None: # type: ignore[override] 

770 """Update config from other `Config` or `dict`. 

771 

772 Like `dict.update`, but will add or modify keys in nested dicts, 

773 instead of overwriting the nested dict entirely. 

774 

775 Parameters 

776 ---------- 

777 other : `dict` or `Config` 

778 Source of configuration. 

779 

780 Examples 

781 -------- 

782 >>> c = Config({"a": {"b": 1}}) 

783 >>> c.update({"a": {"c": 2}}) 

784 >>> print(c) 

785 {'a': {'b': 1, 'c': 2}} 

786 

787 >>> foo = {"a": {"b": 1}} 

788 >>> foo.update({"a": {"c": 2}}) 

789 >>> print(foo) 

790 {'a': {'c': 2}} 

791 """ 

792 _doUpdate(self._data, other) 

793 

794 def merge(self, other: Mapping) -> None: 

795 """Merge another Config into this one. 

796 

797 Like `Config.update()`, but will add keys & values from other that 

798 DO NOT EXIST in self. 

799 

800 Keys and values that already exist in self will NOT be overwritten. 

801 

802 Parameters 

803 ---------- 

804 other : `dict` or `Config` 

805 Source of configuration. 

806 """ 

807 if not isinstance(other, Mapping): 

808 raise TypeError(f"Can only merge a Mapping into a Config, not {type(other)}") 

809 

810 # Convert the supplied mapping to a Config for consistency 

811 # This will do a deepcopy if it is already a Config 

812 otherCopy = Config(other) 

813 otherCopy.update(self) 

814 self._data = otherCopy._data 

815 

816 def nameTuples(self, topLevelOnly: bool = False) -> list[tuple[str, ...]]: 

817 """Get tuples representing the name hierarchies of all keys. 

818 

819 The tuples returned from this method are guaranteed to be usable 

820 to access items in the configuration object. 

821 

822 Parameters 

823 ---------- 

824 topLevelOnly : `bool`, optional 

825 If False, the default, a full hierarchy of names is returned. 

826 If True, only the top level are returned. 

827 

828 Returns 

829 ------- 

830 names : `list` of `tuple` of `str` 

831 List of all names present in the `Config` where each element 

832 in the list is a `tuple` of strings representing the hierarchy. 

833 """ 

834 if topLevelOnly: 

835 return [(k,) for k in self] 

836 

837 def getKeysAsTuples( 

838 d: Mapping[str, Any] | Sequence[str], keys: list[tuple[str, ...]], base: tuple[str, ...] | None 

839 ) -> None: 

840 if isinstance(d, Sequence): 

841 theseKeys: Iterable[Any] = range(len(d)) 

842 else: 

843 theseKeys = d.keys() 

844 for key in theseKeys: 

845 val = d[key] 

846 levelKey = base + (key,) if base is not None else (key,) 

847 keys.append(levelKey) 

848 if isinstance(val, Mapping | Sequence) and not isinstance(val, str): 

849 getKeysAsTuples(val, keys, levelKey) 

850 

851 keys: list[tuple[str, ...]] = [] 

852 getKeysAsTuples(self._data, keys, None) 

853 return keys 

854 

855 def names(self, topLevelOnly: bool = False, delimiter: str | None = None) -> list[str]: 

856 """Get a delimited name of all the keys in the hierarchy. 

857 

858 The values returned from this method are guaranteed to be usable 

859 to access items in the configuration object. 

860 

861 Parameters 

862 ---------- 

863 topLevelOnly : `bool`, optional 

864 If False, the default, a full hierarchy of names is returned. 

865 If True, only the top level are returned. 

866 delimiter : `str`, optional 

867 Delimiter to use when forming the keys. If the delimiter is 

868 present in any of the keys, it will be escaped in the returned 

869 names. If `None` given a delimiter will be automatically provided. 

870 The delimiter can not be alphanumeric. 

871 

872 Returns 

873 ------- 

874 names : `list` of `str` 

875 List of all names present in the `Config`. 

876 

877 Notes 

878 ----- 

879 This is different than the built-in method `dict.keys`, which will 

880 return only the first level keys. 

881 

882 Raises 

883 ------ 

884 ValueError 

885 The supplied delimiter is alphanumeric. 

886 """ 

887 if topLevelOnly: 

888 return list(self.keys()) 

889 

890 # Get all the tuples of hierarchical keys 

891 nameTuples = self.nameTuples() 

892 

893 if delimiter is not None and delimiter.isalnum(): 893 ↛ 894line 893 didn't jump to line 894 because the condition on line 893 was never true

894 raise ValueError(f"Supplied delimiter ({delimiter!r}) must not be alphanumeric.") 

895 

896 if delimiter is None: 

897 # Start with something, and ensure it does not need to be 

898 # escaped (it is much easier to understand if not escaped) 

899 delimiter = self._D 

900 

901 # Form big string for easy check of delimiter clash 

902 combined = "".join("".join(str(s) for s in k) for k in nameTuples) 

903 

904 # Try a delimiter and keep trying until we get something that 

905 # works. 

906 ntries = 0 

907 while delimiter in combined: 

908 log.debug("Delimiter '%s' could not be used. Trying another.", delimiter) 

909 ntries += 1 

910 

911 if ntries > 100: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true

912 raise ValueError(f"Unable to determine a delimiter for Config {self}") 

913 

914 # try another one 

915 while True: 

916 delimiter = chr(ord(delimiter) + 1) 

917 if not delimiter.isalnum(): 917 ↛ 915line 917 didn't jump to line 915 because the condition on line 917 was always true

918 break 

919 

920 log.debug("Using delimiter %r", delimiter) 

921 

922 # Form the keys, escaping the delimiter if necessary 

923 strings = [ 

924 delimiter + delimiter.join(str(s).replace(delimiter, f"\\{delimiter}") for s in k) 

925 for k in nameTuples 

926 ] 

927 return strings 

928 

929 def asArray(self, name: str | Sequence[str]) -> Sequence[Any]: 

930 """Get a value as an array. 

931 

932 May contain one or more elements. 

933 

934 Parameters 

935 ---------- 

936 name : `str` 

937 Key to use to retrieve value. 

938 

939 Returns 

940 ------- 

941 array : `collections.abc.Sequence` 

942 The value corresponding to name, but guaranteed to be returned 

943 as a list with at least one element. If the value is a 

944 `~collections.abc.Sequence` (and not a `str`) the value itself 

945 will be returned, else the value will be the first element. 

946 """ 

947 val = self.get(name) 

948 if isinstance(val, str) or not isinstance(val, Sequence): 

949 val = [val] 

950 return val 

951 

952 def __eq__(self, other: Any) -> bool: 

953 if isinstance(other, Config): 

954 other = other._data 

955 return self._data == other 

956 

957 def __ne__(self, other: Any) -> bool: 

958 if isinstance(other, Config): 

959 other = other._data 

960 return self._data != other 

961 

962 ####### 

963 # i/o # 

964 

965 def dump(self, output: IO | None = None, format: str = "yaml") -> str | None: 

966 """Write the config to an output stream. 

967 

968 Parameters 

969 ---------- 

970 output : `IO`, optional 

971 The stream to use for output. If `None` the serialized content 

972 will be returned. 

973 format : `str`, optional 

974 The format to use for the output. Can be "yaml" or "json". 

975 

976 Returns 

977 ------- 

978 serialized : `str` or `None` 

979 If a stream was given the stream will be used and the return 

980 value will be `None`. If the stream was `None` the 

981 serialization will be returned as a string. 

982 """ 

983 if format == "yaml": 

984 return yaml.safe_dump(self._data, output, default_flow_style=False) 

985 elif format == "json": 985 ↛ 991line 985 didn't jump to line 991 because the condition on line 985 was always true

986 if output is not None: 986 ↛ 987line 986 didn't jump to line 987 because the condition on line 986 was never true

987 json.dump(self._data, output, ensure_ascii=False) 

988 return None 

989 else: 

990 return json.dumps(self._data, ensure_ascii=False) 

991 raise ValueError(f"Unsupported format for Config serialization: {format}") 

992 

993 def dumpToUri( 

994 self, 

995 uri: ResourcePathExpression, 

996 updateFile: bool = True, 

997 defaultFileName: str = "butler.yaml", 

998 overwrite: bool = True, 

999 ) -> None: 

1000 """Write the config to location pointed to by given URI. 

1001 

1002 Currently supports 's3' and 'file' URI schemes. 

1003 

1004 Parameters 

1005 ---------- 

1006 uri : `lsst.resources.ResourcePathExpression` 

1007 URI of location where the Config will be written. 

1008 updateFile : bool, optional 

1009 If True and uri does not end on a filename with extension, will 

1010 append ``defaultFileName`` to the target uri. True by default. 

1011 defaultFileName : bool, optional 

1012 The file name that will be appended to target uri if updateFile is 

1013 True and uri does not end on a file with an extension. 

1014 overwrite : bool, optional 

1015 If True the configuration will be written even if it already 

1016 exists at that location. 

1017 """ 

1018 # Make local copy of URI or create new one 

1019 uri = ResourcePath(uri) 

1020 

1021 if updateFile and not uri.getExtension(): 

1022 if uri.isdir(): 1022 ↛ 1025line 1022 didn't jump to line 1025 because the condition on line 1022 was always true

1023 uri = uri.join(defaultFileName, forceDirectory=False) 

1024 else: 

1025 uri = uri.updatedFile(defaultFileName) 

1026 

1027 # Try to work out the format from the extension 

1028 ext = uri.getExtension() 

1029 format = ext[1:].lower() 

1030 

1031 output = self.dump(format=format) 

1032 assert output is not None, "Config.dump guarantees not-None return when output arg is None" 

1033 uri.write(output.encode(), overwrite=overwrite) 

1034 self.configFile = uri 

1035 

1036 @staticmethod 

1037 def updateParameters( 

1038 configType: type[ConfigSubset], 

1039 config: Config, 

1040 full: Config, 

1041 toUpdate: dict[str, Any] | None = None, 

1042 toCopy: Sequence[str | Sequence[str]] | None = None, 

1043 overwrite: bool = True, 

1044 toMerge: Sequence[str | Sequence[str]] | None = None, 

1045 ) -> None: 

1046 """Update specific config parameters. 

1047 

1048 Allows for named parameters to be set to new values in bulk, and 

1049 for other values to be set by copying from a reference config. 

1050 

1051 Assumes that the supplied config is compatible with ``configType`` 

1052 and will attach the updated values to the supplied config by 

1053 looking for the related component key. It is assumed that 

1054 ``config`` and ``full`` are from the same part of the 

1055 configuration hierarchy. 

1056 

1057 Parameters 

1058 ---------- 

1059 configType : `ConfigSubset` 

1060 Config type to use to extract relevant items from ``config``. 

1061 config : `Config` 

1062 A `Config` to update. Only the subset understood by 

1063 the supplied `ConfigSubset` will be modified. Default values 

1064 will not be inserted and the content will not be validated 

1065 since mandatory keys are allowed to be missing until 

1066 populated later by merging. 

1067 full : `Config` 

1068 A complete config with all defaults expanded that can be 

1069 converted to a ``configType``. Read-only and will not be 

1070 modified by this method. Values are read from here if 

1071 ``toCopy`` is defined. 

1072 

1073 Repository-specific options that should not be obtained 

1074 from defaults when Butler instances are constructed 

1075 should be copied from ``full`` to ``config``. 

1076 toUpdate : `dict`, optional 

1077 A `dict` defining the keys to update and the new value to use. 

1078 The keys and values can be any supported by `Config` 

1079 assignment. 

1080 toCopy : `tuple`, optional 

1081 `tuple` of keys whose values should be copied from ``full`` 

1082 into ``config``. 

1083 overwrite : `bool`, optional 

1084 If `False`, do not modify a value in ``config`` if the key 

1085 already exists. Default is always to overwrite. 

1086 toMerge : `tuple`, optional 

1087 Keys to merge content from full to config without overwriting 

1088 pre-existing values. Only works if the key refers to a hierarchy. 

1089 The ``overwrite`` flag is ignored. 

1090 

1091 Raises 

1092 ------ 

1093 ValueError 

1094 Neither ``toUpdate``, ``toCopy`` nor ``toMerge`` were defined. 

1095 """ 

1096 if toUpdate is None and toCopy is None and toMerge is None: 1096 ↛ 1097line 1096 didn't jump to line 1097 because the condition on line 1096 was never true

1097 raise ValueError("At least one of toUpdate, toCopy, or toMerge parameters must be set.") 

1098 

1099 # If this is a parent configuration then we need to ensure that 

1100 # the supplied config has the relevant component key in it. 

1101 # If this is a parent configuration we add in the stub entry 

1102 # so that the ConfigSubset constructor will do the right thing. 

1103 # We check full for this since that is guaranteed to be complete. 

1104 if ( 

1105 configType.component is not None 

1106 and configType.component in full 

1107 and configType.component not in config 

1108 ): 

1109 config[configType.component] = {} 

1110 

1111 # Extract the part of the config we wish to update 

1112 localConfig = configType(config, mergeDefaults=False, validate=False) 

1113 

1114 key: str | Sequence[str] 

1115 if toUpdate: 

1116 for key, value in toUpdate.items(): 

1117 if key in localConfig and not overwrite: 

1118 log.debug( 

1119 "Not overriding key '%s' with value '%s' in config %s", 

1120 key, 

1121 value, 

1122 localConfig.__class__.__name__, 

1123 ) 

1124 else: 

1125 localConfig[key] = value 

1126 

1127 if toCopy or toMerge: 

1128 localFullConfig = configType(full, mergeDefaults=False) 

1129 

1130 if toCopy: 

1131 for key in toCopy: 

1132 if key in localConfig and not overwrite: 

1133 log.debug( 

1134 "Not overriding key '%s' from defaults in config %s", 

1135 key, 

1136 localConfig.__class__.__name__, 

1137 ) 

1138 else: 

1139 localConfig[key] = localFullConfig[key] 

1140 if toMerge: 

1141 for key in toMerge: 

1142 if key in localConfig: 

1143 # Get the node from the config to do the merge 

1144 # but then have to reattach to the config. 

1145 subset = localConfig[key] 

1146 subset.merge(localFullConfig[key]) 

1147 localConfig[key] = subset 

1148 else: 

1149 localConfig[key] = localFullConfig[key] 

1150 

1151 # Reattach to parent if this is a child config 

1152 if configType.component is not None and configType.component in config: 

1153 config[configType.component] = localConfig 

1154 else: 

1155 config.update(localConfig) 

1156 

1157 def toDict(self) -> dict[str, Any]: 

1158 """Convert a `Config` to a standalone hierarchical `dict`. 

1159 

1160 Returns 

1161 ------- 

1162 d : `dict` 

1163 The standalone hierarchical `dict` with any `Config` classes 

1164 in the hierarchy converted to `dict`. 

1165 

1166 Notes 

1167 ----- 

1168 This can be useful when passing a Config to some code that 

1169 expects native Python types. 

1170 """ 

1171 output = copy.deepcopy(self._data) 

1172 for k, v in output.items(): 

1173 if isinstance(v, Config): 1173 ↛ 1174line 1173 didn't jump to line 1174 because the condition on line 1173 was never true

1174 v = v.toDict() 

1175 output[k] = v 

1176 return output 

1177 

1178 

1179class ConfigSubset(Config): 

1180 """Config representing a subset of a more general configuration. 

1181 

1182 Subclasses define their own component and when given a configuration 

1183 that includes that component, the resulting configuration only includes 

1184 the subset. For example, your config might contain ``dimensions`` if it's 

1185 part of a global config and that subset will be stored. If ``dimensions`` 

1186 can not be found it is assumed that the entire contents of the 

1187 configuration should be used. 

1188 

1189 Default values are read from the environment or supplied search paths 

1190 using the default configuration file name specified in the subclass. 

1191 This allows a configuration class to be instantiated without any 

1192 additional arguments. 

1193 

1194 Additional validation can be specified to check for keys that are mandatory 

1195 in the configuration. 

1196 

1197 Parameters 

1198 ---------- 

1199 other : `Config` or `~lsst.resources.ResourcePathExpression` or `dict` 

1200 Argument specifying the configuration information as understood 

1201 by `Config`. 

1202 validate : `bool`, optional 

1203 If `True` required keys will be checked to ensure configuration 

1204 consistency. 

1205 mergeDefaults : `bool`, optional 

1206 If `True` defaults will be read and the supplied config will 

1207 be combined with the defaults, with the supplied values taking 

1208 precedence. 

1209 searchPaths : `list` or `tuple`, optional 

1210 Explicit additional paths to search for defaults. They should 

1211 be supplied in priority order. These paths have higher priority 

1212 than those read from the environment in 

1213 `ConfigSubset.defaultSearchPaths()`. Paths can be `str` referring to 

1214 the local file system or URIs, `lsst.resources.ResourcePath`. 

1215 """ 

1216 

1217 component: ClassVar[str | None] = None 

1218 """Component to use from supplied config. Can be None. If specified the 

1219 key is not required. Can be a full dot-separated path to a component. 

1220 """ 

1221 

1222 requiredKeys: ClassVar[Sequence[str]] = () 

1223 """Keys that are required to be specified in the configuration. 

1224 """ 

1225 

1226 defaultConfigFile: ClassVar[str | None] = None 

1227 """Name of the file containing defaults for this config class. 

1228 """ 

1229 

1230 def __init__( 

1231 self, 

1232 other: Config | ResourcePathExpression | Mapping[str, Any] | None = None, 

1233 validate: bool = True, 

1234 mergeDefaults: bool = True, 

1235 searchPaths: Sequence[ResourcePathExpression] | None = None, 

1236 ): 

1237 # Create a blank object to receive the defaults 

1238 # Once we have the defaults we then update with the external values 

1239 super().__init__() 

1240 

1241 # Create a standard Config rather than subset 

1242 externalConfig = Config(other) 

1243 

1244 # Select the part we need from it 

1245 # To simplify the use of !include we also check for the existence of 

1246 # component.component (since the included files can themselves 

1247 # include the component name) 

1248 if self.component is not None: 

1249 doubled = (self.component, self.component) 

1250 # Must check for double depth first 

1251 if doubled in externalConfig: 

1252 externalConfig = externalConfig[doubled] 

1253 elif self.component in externalConfig: 

1254 externalConfig._data = externalConfig._data[self.component] 

1255 

1256 # Default files read to create this configuration 

1257 self.filesRead: list[ResourcePath | str] = [] 

1258 

1259 # Assume we are not looking up child configurations 

1260 containerKey = None 

1261 

1262 # Sometimes we do not want to merge with defaults. 

1263 if mergeDefaults: 

1264 # Supplied search paths have highest priority 

1265 fullSearchPath: list[ResourcePath | str] = [] 

1266 if searchPaths: 

1267 fullSearchPath = [ResourcePath(path, forceDirectory=True) for path in searchPaths] 

1268 

1269 # Read default paths from environment 

1270 fullSearchPath.extend(self.defaultSearchPaths()) 

1271 

1272 # There are two places to find defaults for this particular config 

1273 # - The "defaultConfigFile" defined in the subclass 

1274 # - The class specified in the "cls" element in the config. 

1275 # Read cls after merging in case it changes. 

1276 if self.defaultConfigFile is not None: 

1277 self._updateWithConfigsFromPath(fullSearchPath, self.defaultConfigFile) 

1278 

1279 # Can have a class specification in the external config (priority) 

1280 # or from the defaults. 

1281 pytype = None 

1282 if "cls" in externalConfig: 

1283 pytype = externalConfig["cls"] 

1284 elif "cls" in self: 

1285 pytype = self["cls"] 

1286 

1287 if pytype is not None: 

1288 try: 

1289 cls = doImportType(pytype) 

1290 except ImportError as e: 

1291 raise RuntimeError(f"Failed to import cls '{pytype}' for config {type(self)}") from e 

1292 # The class referenced from the config file is not required 

1293 # to specify a default config file. 

1294 defaultsFile = getattr(cls, "defaultConfigFile", None) 

1295 if defaultsFile is not None: 1295 ↛ 1299line 1295 didn't jump to line 1299 because the condition on line 1295 was always true

1296 self._updateWithConfigsFromPath(fullSearchPath, defaultsFile) 

1297 

1298 # Get the container key in case we need it and it is specified. 

1299 containerKey = getattr(cls, "containerKey", None) 

1300 

1301 # Now update this object with the external values so that the external 

1302 # values always override the defaults 

1303 self.update(externalConfig) 

1304 if not self.configFile: 1304 ↛ 1310line 1304 didn't jump to line 1310 because the condition on line 1304 was always true

1305 self.configFile = externalConfig.configFile 

1306 

1307 # If this configuration has child configurations of the same 

1308 # config class, we need to expand those defaults as well. 

1309 

1310 if mergeDefaults and containerKey is not None and containerKey in self: 

1311 for idx, subConfig in enumerate(self[containerKey]): 

1312 self[containerKey, idx] = type(self)( 

1313 other=subConfig, validate=validate, mergeDefaults=mergeDefaults, searchPaths=searchPaths 

1314 ) 

1315 

1316 if validate: 

1317 self.validate() 

1318 

1319 @classmethod 

1320 def defaultSearchPaths(cls) -> list[ResourcePath | str]: 

1321 """Read environment to determine search paths to use. 

1322 

1323 Global defaults, at lowest priority, are found in the ``config`` 

1324 directory of the butler source tree. Additional defaults can be 

1325 defined using the environment variable ``$DAF_BUTLER_CONFIG_PATH`` 

1326 which is a PATH-like variable where paths at the front of the list 

1327 have priority over those later. 

1328 

1329 Returns 

1330 ------- 

1331 paths : `list` 

1332 Returns a list of paths to search. The returned order is in 

1333 priority with the highest priority paths first. The butler config 

1334 configuration resources will not be included here but will 

1335 always be searched last. 

1336 

1337 Notes 

1338 ----- 

1339 The environment variable is split on the standard ``:`` path separator. 

1340 This currently makes it incompatible with usage of URIs. 

1341 """ 

1342 # We can pick up defaults from multiple search paths 

1343 # We fill defaults by using the butler config path and then 

1344 # the config path environment variable in reverse order. 

1345 defaultsPaths: list[str | ResourcePath] = [] 

1346 

1347 if CONFIG_PATH in os.environ: 

1348 externalPaths = os.environ[CONFIG_PATH].split(os.pathsep) 

1349 defaultsPaths.extend(externalPaths) 

1350 

1351 # Add the package defaults as a resource 

1352 defaultsPaths.append(ResourcePath(f"resource://{cls.resourcesPackage}/configs", forceDirectory=True)) 

1353 return defaultsPaths 

1354 

1355 def _updateWithConfigsFromPath( 

1356 self, searchPaths: Sequence[str | ResourcePath], configFile: ResourcePath | str 

1357 ) -> None: 

1358 """Search the supplied paths, merging the configuration values. 

1359 

1360 The values read will override values currently stored in the object. 

1361 Every file found in the path will be read, such that the earlier 

1362 path entries have higher priority. 

1363 

1364 Parameters 

1365 ---------- 

1366 searchPaths : `list` of `lsst.resources.ResourcePath`, `str` 

1367 Paths to search for the supplied configFile. This path 

1368 is the priority order, such that files read from the 

1369 first path entry will be selected over those read from 

1370 a later path. Can contain `str` referring to the local file 

1371 system or a URI string. 

1372 configFile : `lsst.resources.ResourcePath` 

1373 File to locate in path. If absolute path it will be read 

1374 directly and the search path will not be used. Can be a URI 

1375 to an explicit resource (which will ignore the search path) 

1376 which is assumed to exist. 

1377 """ 

1378 uri = ResourcePath(configFile, forceDirectory=False) 

1379 if uri.isabs() and uri.exists(): 

1380 # Assume this resource exists 

1381 self._updateWithOtherConfigFile(configFile) 

1382 self.filesRead.append(configFile) 

1383 else: 

1384 # Reverse order so that high priority entries 

1385 # update the object last. 

1386 for pathDir in reversed(searchPaths): 

1387 if isinstance(pathDir, str | ResourcePath): 1387 ↛ 1394line 1387 didn't jump to line 1394 because the condition on line 1387 was always true

1388 pathDir = ResourcePath(pathDir, forceDirectory=True) 

1389 file = pathDir.join(configFile) 

1390 if file.exists(): 

1391 self.filesRead.append(file) 

1392 self._updateWithOtherConfigFile(file) 

1393 else: 

1394 raise ValueError(f"Unexpected search path type encountered: {pathDir!r}") 

1395 

1396 def _updateWithOtherConfigFile(self, file: Config | str | ResourcePath | Mapping[str, Any]) -> None: 

1397 """Read in some defaults and update. 

1398 

1399 Update the configuration by reading the supplied file as a config 

1400 of this class, and merging such that these values override the 

1401 current values. Contents of the external config are not validated. 

1402 

1403 Parameters 

1404 ---------- 

1405 file : `Config`, `str`, `lsst.resources.ResourcePath`, or `dict` 

1406 Entity that can be converted to a `ConfigSubset`. 

1407 """ 

1408 # Use this class to read the defaults so that subsetting can happen 

1409 # correctly. 

1410 externalConfig = type(self)(file, validate=False, mergeDefaults=False) 

1411 self.update(externalConfig) 

1412 

1413 def validate(self) -> None: 

1414 """Check that mandatory keys are present in this configuration. 

1415 

1416 Ignored if ``requiredKeys`` is empty. 

1417 """ 

1418 # Validation 

1419 missing = [k for k in self.requiredKeys if k not in self._data] 

1420 if missing: 

1421 raise KeyError(f"Mandatory keys ({missing}) missing from supplied configuration for {type(self)}")