Coverage for python/lsst/daf/butler/core/formatter.py: 28%

196 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-15 09:13 +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 program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22from __future__ import annotations 

23 

24__all__ = ("Formatter", "FormatterFactory", "FormatterParameter") 

25 

26import contextlib 

27import copy 

28import logging 

29from abc import ABCMeta, abstractmethod 

30from collections.abc import Iterator, Mapping, Set 

31from typing import TYPE_CHECKING, Any, ClassVar 

32 

33from lsst.utils.introspection import get_full_type_name 

34 

35from .config import Config 

36from .configSupport import LookupKey, processLookupConfigs 

37from .datasets import DatasetRef, DatasetType 

38from .dimensions import DimensionUniverse 

39from .fileDescriptor import FileDescriptor 

40from .location import Location 

41from .mappingFactory import MappingFactory 

42from .storageClass import StorageClass 

43 

44log = logging.getLogger(__name__) 

45 

46# Define a new special type for functions that take "entity" 

47Entity = DatasetType | DatasetRef | StorageClass | str 

48 

49 

50if TYPE_CHECKING: 

51 from .dimensions import DataCoordinate 

52 

53 

54class Formatter(metaclass=ABCMeta): 

55 """Interface for reading and writing Datasets. 

56 

57 The formatters are associated with a particular `StorageClass`. 

58 

59 Parameters 

60 ---------- 

61 fileDescriptor : `FileDescriptor`, optional 

62 Identifies the file to read or write, and the associated storage 

63 classes and parameter information. Its value can be `None` if the 

64 caller will never call `Formatter.read` or `Formatter.write`. 

65 dataId : `DataCoordinate` 

66 Data ID associated with this formatter. 

67 writeParameters : `dict`, optional 

68 Any parameters to be hard-coded into this instance to control how 

69 the dataset is serialized. 

70 writeRecipes : `dict`, optional 

71 Detailed write Recipes indexed by recipe name. 

72 

73 Notes 

74 ----- 

75 All Formatter subclasses should share the base class's constructor 

76 signature. 

77 """ 

78 

79 unsupportedParameters: ClassVar[Set[str] | None] = frozenset() 

80 """Set of read parameters not understood by this `Formatter`. An empty set 

81 means all parameters are supported. `None` indicates that no parameters 

82 are supported. These param (`frozenset`). 

83 """ 

84 

85 supportedWriteParameters: ClassVar[Set[str] | None] = None 

86 """Parameters understood by this formatter that can be used to control 

87 how a dataset is serialized. `None` indicates that no parameters are 

88 supported.""" 

89 

90 supportedExtensions: ClassVar[Set[str]] = frozenset() 

91 """Set of all extensions supported by this formatter. 

92 

93 Only expected to be populated by Formatters that write files. Any extension 

94 assigned to the ``extension`` property will be automatically included in 

95 the list of supported extensions.""" 

96 

97 def __init__( 

98 self, 

99 fileDescriptor: FileDescriptor, 

100 dataId: DataCoordinate, 

101 writeParameters: dict[str, Any] | None = None, 

102 writeRecipes: dict[str, Any] | None = None, 

103 ): 

104 if not isinstance(fileDescriptor, FileDescriptor): 

105 raise TypeError("File descriptor must be a FileDescriptor") 

106 assert dataId is not None, "dataId is now required for formatter initialization" 

107 self._fileDescriptor = fileDescriptor 

108 self._dataId = dataId 

109 

110 # Check that the write parameters are allowed 

111 if writeParameters: 

112 if self.supportedWriteParameters is None: 

113 raise ValueError( 

114 f"This formatter does not accept any write parameters. Got: {', '.join(writeParameters)}" 

115 ) 

116 else: 

117 given = set(writeParameters) 

118 unknown = given - self.supportedWriteParameters 

119 if unknown: 

120 s = "s" if len(unknown) != 1 else "" 

121 unknownStr = ", ".join(f"'{u}'" for u in unknown) 

122 raise ValueError(f"This formatter does not accept parameter{s} {unknownStr}") 

123 

124 self._writeParameters = writeParameters 

125 self._writeRecipes = self.validateWriteRecipes(writeRecipes) 

126 

127 def __str__(self) -> str: 

128 return f"{self.name()}@{self.fileDescriptor.location.path}" 

129 

130 def __repr__(self) -> str: 

131 return f"{self.name()}({self.fileDescriptor!r})" 

132 

133 @property 

134 def fileDescriptor(self) -> FileDescriptor: 

135 """File descriptor associated with this formatter (`FileDescriptor`). 

136 

137 Read-only property. 

138 """ 

139 return self._fileDescriptor 

140 

141 @property 

142 def dataId(self) -> DataCoordinate: 

143 """Return Data ID associated with this formatter (`DataCoordinate`).""" 

144 return self._dataId 

145 

146 @property 

147 def writeParameters(self) -> Mapping[str, Any]: 

148 """Parameters to use when writing out datasets.""" 

149 if self._writeParameters is not None: 

150 return self._writeParameters 

151 return {} 

152 

153 @property 

154 def writeRecipes(self) -> Mapping[str, Any]: 

155 """Detailed write Recipes indexed by recipe name.""" 

156 if self._writeRecipes is not None: 

157 return self._writeRecipes 

158 return {} 

159 

160 @classmethod 

161 def validateWriteRecipes(cls, recipes: Mapping[str, Any] | None) -> Mapping[str, Any] | None: 

162 """Validate supplied recipes for this formatter. 

163 

164 The recipes are supplemented with default values where appropriate. 

165 

166 Parameters 

167 ---------- 

168 recipes : `dict` 

169 Recipes to validate. 

170 

171 Returns 

172 ------- 

173 validated : `dict` 

174 Validated recipes. 

175 

176 Raises 

177 ------ 

178 RuntimeError 

179 Raised if validation fails. The default implementation raises 

180 if any recipes are given. 

181 """ 

182 if recipes: 

183 raise RuntimeError(f"This formatter does not understand these writeRecipes: {recipes}") 

184 return recipes 

185 

186 @classmethod 

187 def name(cls) -> str: 

188 """Return the fully qualified name of the formatter. 

189 

190 Returns 

191 ------- 

192 name : `str` 

193 Fully-qualified name of formatter class. 

194 """ 

195 return get_full_type_name(cls) 

196 

197 @abstractmethod 

198 def read(self, component: str | None = None) -> Any: 

199 """Read a Dataset. 

200 

201 Parameters 

202 ---------- 

203 component : `str`, optional 

204 Component to read from the file. Only used if the `StorageClass` 

205 for reading differed from the `StorageClass` used to write the 

206 file. 

207 

208 Returns 

209 ------- 

210 inMemoryDataset : `object` 

211 The requested Dataset. 

212 """ 

213 raise NotImplementedError("Type does not support reading") 

214 

215 @abstractmethod 

216 def write(self, inMemoryDataset: Any) -> None: 

217 """Write a Dataset. 

218 

219 Parameters 

220 ---------- 

221 inMemoryDataset : `object` 

222 The Dataset to store. 

223 """ 

224 raise NotImplementedError("Type does not support writing") 

225 

226 @classmethod 

227 def can_read_bytes(cls) -> bool: 

228 """Indicate if this formatter can format from bytes. 

229 

230 Returns 

231 ------- 

232 can : `bool` 

233 `True` if the `fromBytes` method is implemented. 

234 """ 

235 # We have no property to read so instead try to format from a byte 

236 # and see what happens 

237 try: 

238 # We know the arguments are incompatible 

239 cls.fromBytes(cls, b"") # type: ignore 

240 except NotImplementedError: 

241 return False 

242 except Exception: 

243 # There will be problems with the bytes we are supplying so ignore 

244 pass 

245 return True 

246 

247 def fromBytes(self, serializedDataset: bytes, component: str | None = None) -> object: 

248 """Read serialized data into a Dataset or its component. 

249 

250 Parameters 

251 ---------- 

252 serializedDataset : `bytes` 

253 Bytes object to unserialize. 

254 component : `str`, optional 

255 Component to read from the Dataset. Only used if the `StorageClass` 

256 for reading differed from the `StorageClass` used to write the 

257 file. 

258 

259 Returns 

260 ------- 

261 inMemoryDataset : `object` 

262 The requested data as a Python object. The type of object 

263 is controlled by the specific formatter. 

264 """ 

265 raise NotImplementedError("Type does not support reading from bytes.") 

266 

267 def toBytes(self, inMemoryDataset: Any) -> bytes: 

268 """Serialize the Dataset to bytes based on formatter. 

269 

270 Parameters 

271 ---------- 

272 inMemoryDataset : `object` 

273 The Python object to serialize. 

274 

275 Returns 

276 ------- 

277 serializedDataset : `bytes` 

278 Bytes representing the serialized dataset. 

279 """ 

280 raise NotImplementedError("Type does not support writing to bytes.") 

281 

282 @contextlib.contextmanager 

283 def _updateLocation(self, location: Location | None) -> Iterator[Location]: 

284 """Temporarily replace the location associated with this formatter. 

285 

286 Parameters 

287 ---------- 

288 location : `Location` 

289 New location to use for this formatter. If `None` the 

290 formatter will not change but it will still return 

291 the old location. This allows it to be used in a code 

292 path where the location may not need to be updated 

293 but the with block is still convenient. 

294 

295 Yields 

296 ------ 

297 old : `Location` 

298 The old location that will be restored. 

299 

300 Notes 

301 ----- 

302 This is an internal method that should be used with care. 

303 It may change in the future. Should be used as a context 

304 manager to restore the location when the temporary is no 

305 longer required. 

306 """ 

307 old = self._fileDescriptor.location 

308 try: 

309 if location is not None: 

310 self._fileDescriptor.location = location 

311 yield old 

312 finally: 

313 if location is not None: 

314 self._fileDescriptor.location = old 

315 

316 def makeUpdatedLocation(self, location: Location) -> Location: 

317 """Return a new `Location` updated with this formatter's extension. 

318 

319 Parameters 

320 ---------- 

321 location : `Location` 

322 The location to update. 

323 

324 Returns 

325 ------- 

326 updated : `Location` 

327 A new `Location` with a new file extension applied. 

328 

329 Raises 

330 ------ 

331 NotImplementedError 

332 Raised if there is no ``extension`` attribute associated with 

333 this formatter. 

334 

335 Notes 

336 ----- 

337 This method is available to all Formatters but might not be 

338 implemented by all formatters. It requires that a formatter set 

339 an ``extension`` attribute containing the file extension used when 

340 writing files. If ``extension`` is `None` the supplied file will 

341 not be updated. Not all formatters write files so this is not 

342 defined in the base class. 

343 """ 

344 location = copy.deepcopy(location) 

345 try: 

346 # We are deliberately allowing extension to be undefined by 

347 # default in the base class and mypy complains. 

348 location.updateExtension(self.extension) # type:ignore 

349 except AttributeError: 

350 raise NotImplementedError("No file extension registered with this formatter") from None 

351 return location 

352 

353 @classmethod 

354 def validateExtension(cls, location: Location) -> None: 

355 """Check the extension of the provided location for compatibility. 

356 

357 Parameters 

358 ---------- 

359 location : `Location` 

360 Location from which to extract a file extension. 

361 

362 Raises 

363 ------ 

364 NotImplementedError 

365 Raised if file extensions are a concept not understood by this 

366 formatter. 

367 ValueError 

368 Raised if the formatter does not understand this extension. 

369 

370 Notes 

371 ----- 

372 This method is available to all Formatters but might not be 

373 implemented by all formatters. It requires that a formatter set 

374 an ``extension`` attribute containing the file extension used when 

375 writing files. If ``extension`` is `None` only the set of supported 

376 extensions will be examined. 

377 """ 

378 supported = set(cls.supportedExtensions) 

379 

380 try: 

381 # We are deliberately allowing extension to be undefined by 

382 # default in the base class and mypy complains. 

383 default = cls.extension # type: ignore 

384 except AttributeError: 

385 raise NotImplementedError("No file extension registered with this formatter") from None 

386 

387 # If extension is implemented as an instance property it won't return 

388 # a string when called as a class property. Assume that 

389 # the supported extensions class property is complete. 

390 if default is not None and isinstance(default, str): 

391 supported.add(default) 

392 

393 # Get the file name from the uri 

394 file = location.uri.basename() 

395 

396 # Check that this file name ends with one of the supported extensions. 

397 # This is less prone to confusion than asking the location for 

398 # its extension and then doing a set comparison 

399 for ext in supported: 

400 if file.endswith(ext): 

401 return 

402 

403 raise ValueError( 

404 f"Extension '{location.getExtension()}' on '{location}' " 

405 f"is not supported by Formatter '{cls.__name__}' (supports: {supported})" 

406 ) 

407 

408 def predictPath(self) -> str: 

409 """Return the path that would be returned by write. 

410 

411 Does not write any data file. 

412 

413 Uses the `FileDescriptor` associated with the instance. 

414 

415 Returns 

416 ------- 

417 path : `str` 

418 Path within datastore that would be associated with the location 

419 stored in this `Formatter`. 

420 """ 

421 updated = self.makeUpdatedLocation(self.fileDescriptor.location) 

422 return updated.pathInStore.path 

423 

424 def segregateParameters(self, parameters: dict[str, Any] | None = None) -> tuple[dict, dict]: 

425 """Segregate the supplied parameters. 

426 

427 This splits the parameters into those understood by the 

428 formatter and those not understood by the formatter. 

429 

430 Any unsupported parameters are assumed to be usable by associated 

431 assemblers. 

432 

433 Parameters 

434 ---------- 

435 parameters : `dict`, optional 

436 Parameters with values that have been supplied by the caller 

437 and which might be relevant for the formatter. If `None` 

438 parameters will be read from the registered `FileDescriptor`. 

439 

440 Returns 

441 ------- 

442 supported : `dict` 

443 Those parameters supported by this formatter. 

444 unsupported : `dict` 

445 Those parameters not supported by this formatter. 

446 """ 

447 if parameters is None: 

448 parameters = self.fileDescriptor.parameters 

449 

450 if parameters is None: 

451 return {}, {} 

452 

453 if self.unsupportedParameters is None: 

454 # Support none of the parameters 

455 return {}, parameters.copy() 

456 

457 # Start by assuming all are supported 

458 supported = parameters.copy() 

459 unsupported = {} 

460 

461 # And remove any we know are not supported 

462 for p in set(supported): 

463 if p in self.unsupportedParameters: 

464 unsupported[p] = supported.pop(p) 

465 

466 return supported, unsupported 

467 

468 

469class FormatterFactory: 

470 """Factory for `Formatter` instances.""" 

471 

472 defaultKey = LookupKey("default") 

473 """Configuration key associated with default write parameter settings.""" 

474 

475 writeRecipesKey = LookupKey("write_recipes") 

476 """Configuration key associated with write recipes.""" 

477 

478 def __init__(self) -> None: 

479 self._mappingFactory = MappingFactory(Formatter) 

480 

481 def __contains__(self, key: LookupKey | str) -> bool: 

482 """Indicate whether the supplied key is present in the factory. 

483 

484 Parameters 

485 ---------- 

486 key : `LookupKey`, `str` or objects with ``name`` attribute 

487 Key to use to lookup in the factory whether a corresponding 

488 formatter is present. 

489 

490 Returns 

491 ------- 

492 in : `bool` 

493 `True` if the supplied key is present in the factory. 

494 """ 

495 return key in self._mappingFactory 

496 

497 def registerFormatters(self, config: Config, *, universe: DimensionUniverse) -> None: 

498 """Bulk register formatters from a config. 

499 

500 Parameters 

501 ---------- 

502 config : `Config` 

503 ``formatters`` section of a configuration. 

504 universe : `DimensionUniverse`, optional 

505 Set of all known dimensions, used to expand and validate any used 

506 in lookup keys. 

507 

508 Notes 

509 ----- 

510 The configuration can include one level of hierarchy where an 

511 instrument-specific section can be defined to override more general 

512 template specifications. This is represented in YAML using a 

513 key of form ``instrument<name>`` which can then define templates 

514 that will be returned if a `DatasetRef` contains a matching instrument 

515 name in the data ID. 

516 

517 The config is parsed using the function 

518 `~lsst.daf.butler.configSubset.processLookupConfigs`. 

519 

520 The values for formatter entries can be either a simple string 

521 referring to a python type or a dict representing the formatter and 

522 parameters to be hard-coded into the formatter constructor. For 

523 the dict case the following keys are supported: 

524 

525 - formatter: The python type to be used as the formatter class. 

526 - parameters: A further dict to be passed directly to the 

527 ``writeParameters`` Formatter constructor to seed it. 

528 These parameters are validated at instance creation and not at 

529 configuration. 

530 

531 Additionally, a special ``default`` section can be defined that 

532 uses the formatter type (class) name as the keys and specifies 

533 default write parameters that should be used whenever an instance 

534 of that class is constructed. 

535 

536 .. code-block:: yaml 

537 

538 formatters: 

539 default: 

540 lsst.daf.butler.formatters.example.ExampleFormatter: 

541 max: 10 

542 min: 2 

543 comment: Default comment 

544 calexp: lsst.daf.butler.formatters.example.ExampleFormatter 

545 coadd: 

546 formatter: lsst.daf.butler.formatters.example.ExampleFormatter 

547 parameters: 

548 max: 5 

549 

550 Any time an ``ExampleFormatter`` is constructed it will use those 

551 parameters. If an explicit entry later in the configuration specifies 

552 a different set of parameters, the two will be merged with the later 

553 entry taking priority. In the example above ``calexp`` will use 

554 the default parameters but ``coadd`` will override the value for 

555 ``max``. 

556 

557 Formatter configuration can also include a special section describing 

558 collections of write parameters that can be accessed through a 

559 simple label. This allows common collections of options to be 

560 specified in one place in the configuration and reused later. 

561 The ``write_recipes`` section is indexed by Formatter class name 

562 and each key is the label to associate with the parameters. 

563 

564 .. code-block:: yaml 

565 

566 formatters: 

567 write_recipes: 

568 lsst.obs.base.formatters.fitsExposure.FixExposureFormatter: 

569 lossless: 

570 ... 

571 noCompression: 

572 ... 

573 

574 By convention a formatter that uses write recipes will support a 

575 ``recipe`` write parameter that will refer to a recipe name in 

576 the ``write_recipes`` component. The `Formatter` will be constructed 

577 in the `FormatterFactory` with all the relevant recipes and 

578 will not attempt to filter by looking at ``writeParameters`` in 

579 advance. See the specific formatter documentation for details on 

580 acceptable recipe options. 

581 """ 

582 allowed_keys = {"formatter", "parameters"} 

583 

584 contents = processLookupConfigs(config, allow_hierarchy=True, universe=universe) 

585 

586 # Extract any default parameter settings 

587 defaultParameters = contents.get(self.defaultKey, {}) 

588 if not isinstance(defaultParameters, Mapping): 

589 raise RuntimeError( 

590 "Default formatter parameters in config can not be a single string" 

591 f" (got: {type(defaultParameters)})" 

592 ) 

593 

594 # Extract any global write recipes -- these are indexed by 

595 # Formatter class name. 

596 writeRecipes = contents.get(self.writeRecipesKey, {}) 

597 if isinstance(writeRecipes, str): 

598 raise RuntimeError( 

599 f"The formatters.{self.writeRecipesKey} section must refer to a dict not '{writeRecipes}'" 

600 ) 

601 

602 for key, f in contents.items(): 

603 # default is handled in a special way 

604 if key == self.defaultKey: 

605 continue 

606 if key == self.writeRecipesKey: 

607 continue 

608 

609 # Can be a str or a dict. 

610 specificWriteParameters = {} 

611 if isinstance(f, str): 

612 formatter = f 

613 elif isinstance(f, Mapping): 

614 all_keys = set(f) 

615 unexpected_keys = all_keys - allowed_keys 

616 if unexpected_keys: 

617 raise ValueError(f"Formatter {key} uses unexpected keys {unexpected_keys} in config") 

618 if "formatter" not in f: 

619 raise ValueError(f"Mandatory 'formatter' key missing for formatter key {key}") 

620 formatter = f["formatter"] 

621 if "parameters" in f: 

622 specificWriteParameters = f["parameters"] 

623 else: 

624 raise ValueError(f"Formatter for key {key} has unexpected value: '{f}'") 

625 

626 # Apply any default parameters for this formatter 

627 writeParameters = copy.deepcopy(defaultParameters.get(formatter, {})) 

628 writeParameters.update(specificWriteParameters) 

629 

630 kwargs: dict[str, Any] = {} 

631 if writeParameters: 

632 kwargs["writeParameters"] = writeParameters 

633 

634 if formatter in writeRecipes: 

635 kwargs["writeRecipes"] = writeRecipes[formatter] 

636 

637 self.registerFormatter(key, formatter, **kwargs) 

638 

639 def getLookupKeys(self) -> set[LookupKey]: 

640 """Retrieve the look up keys for all the registry entries. 

641 

642 Returns 

643 ------- 

644 keys : `set` of `LookupKey` 

645 The keys available for matching in the registry. 

646 """ 

647 return self._mappingFactory.getLookupKeys() 

648 

649 def getFormatterClassWithMatch(self, entity: Entity) -> tuple[LookupKey, type[Formatter], dict[str, Any]]: 

650 """Get the matching formatter class along with the registry key. 

651 

652 Parameters 

653 ---------- 

654 entity : `DatasetRef`, `DatasetType`, `StorageClass`, or `str` 

655 Entity to use to determine the formatter to return. 

656 `StorageClass` will be used as a last resort if `DatasetRef` 

657 or `DatasetType` instance is provided. Supports instrument 

658 override if a `DatasetRef` is provided configured with an 

659 ``instrument`` value for the data ID. 

660 

661 Returns 

662 ------- 

663 matchKey : `LookupKey` 

664 The key that resulted in the successful match. 

665 formatter : `type` 

666 The class of the registered formatter. 

667 formatter_kwargs : `dict` 

668 Keyword arguments that are associated with this formatter entry. 

669 """ 

670 names = (LookupKey(name=entity),) if isinstance(entity, str) else entity._lookupNames() 

671 matchKey, formatter, formatter_kwargs = self._mappingFactory.getClassFromRegistryWithMatch(names) 

672 log.debug( 

673 "Retrieved formatter %s from key '%s' for entity '%s'", 

674 get_full_type_name(formatter), 

675 matchKey, 

676 entity, 

677 ) 

678 

679 return matchKey, formatter, formatter_kwargs 

680 

681 def getFormatterClass(self, entity: Entity) -> type: 

682 """Get the matching formatter class. 

683 

684 Parameters 

685 ---------- 

686 entity : `DatasetRef`, `DatasetType`, `StorageClass`, or `str` 

687 Entity to use to determine the formatter to return. 

688 `StorageClass` will be used as a last resort if `DatasetRef` 

689 or `DatasetType` instance is provided. Supports instrument 

690 override if a `DatasetRef` is provided configured with an 

691 ``instrument`` value for the data ID. 

692 

693 Returns 

694 ------- 

695 formatter : `type` 

696 The class of the registered formatter. 

697 """ 

698 _, formatter, _ = self.getFormatterClassWithMatch(entity) 

699 return formatter 

700 

701 def getFormatterWithMatch(self, entity: Entity, *args: Any, **kwargs: Any) -> tuple[LookupKey, Formatter]: 

702 """Get a new formatter instance along with the matching registry key. 

703 

704 Parameters 

705 ---------- 

706 entity : `DatasetRef`, `DatasetType`, `StorageClass`, or `str` 

707 Entity to use to determine the formatter to return. 

708 `StorageClass` will be used as a last resort if `DatasetRef` 

709 or `DatasetType` instance is provided. Supports instrument 

710 override if a `DatasetRef` is provided configured with an 

711 ``instrument`` value for the data ID. 

712 args : `tuple` 

713 Positional arguments to use pass to the object constructor. 

714 **kwargs 

715 Keyword arguments to pass to object constructor. 

716 

717 Returns 

718 ------- 

719 matchKey : `LookupKey` 

720 The key that resulted in the successful match. 

721 formatter : `Formatter` 

722 An instance of the registered formatter. 

723 """ 

724 names = (LookupKey(name=entity),) if isinstance(entity, str) else entity._lookupNames() 

725 matchKey, formatter = self._mappingFactory.getFromRegistryWithMatch(names, *args, **kwargs) 

726 log.debug( 

727 "Retrieved formatter %s from key '%s' for entity '%s'", 

728 get_full_type_name(formatter), 

729 matchKey, 

730 entity, 

731 ) 

732 

733 return matchKey, formatter 

734 

735 def getFormatter(self, entity: Entity, *args: Any, **kwargs: Any) -> Formatter: 

736 """Get a new formatter instance. 

737 

738 Parameters 

739 ---------- 

740 entity : `DatasetRef`, `DatasetType`, `StorageClass`, or `str` 

741 Entity to use to determine the formatter to return. 

742 `StorageClass` will be used as a last resort if `DatasetRef` 

743 or `DatasetType` instance is provided. Supports instrument 

744 override if a `DatasetRef` is provided configured with an 

745 ``instrument`` value for the data ID. 

746 args : `tuple` 

747 Positional arguments to use pass to the object constructor. 

748 **kwargs 

749 Keyword arguments to pass to object constructor. 

750 

751 Returns 

752 ------- 

753 formatter : `Formatter` 

754 An instance of the registered formatter. 

755 """ 

756 _, formatter = self.getFormatterWithMatch(entity, *args, **kwargs) 

757 return formatter 

758 

759 def registerFormatter( 

760 self, 

761 type_: LookupKey | str | StorageClass | DatasetType, 

762 formatter: str, 

763 *, 

764 overwrite: bool = False, 

765 **kwargs: Any, 

766 ) -> None: 

767 """Register a `Formatter`. 

768 

769 Parameters 

770 ---------- 

771 type_ : `LookupKey`, `str`, `StorageClass` or `DatasetType` 

772 Type for which this formatter is to be used. If a `LookupKey` 

773 is not provided, one will be constructed from the supplied string 

774 or by using the ``name`` property of the supplied entity. 

775 formatter : `str` or class of type `Formatter` 

776 Identifies a `Formatter` subclass to use for reading and writing 

777 Datasets of this type. Can be a `Formatter` class. 

778 overwrite : `bool`, optional 

779 If `True` an existing entry will be replaced by the new value. 

780 Default is `False`. 

781 **kwargs 

782 Keyword arguments to always pass to object constructor when 

783 retrieved. 

784 

785 Raises 

786 ------ 

787 ValueError 

788 Raised if the formatter does not name a valid formatter type and 

789 ``overwrite`` is `False`. 

790 """ 

791 self._mappingFactory.placeInRegistry(type_, formatter, overwrite=overwrite, **kwargs) 

792 

793 

794# Type to use when allowing a Formatter or its class name 

795FormatterParameter = str | type[Formatter] | Formatter