Coverage for python / lsst / daf / butler / _formatter.py: 23%

531 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-10 01:37 -0800

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 

28from __future__ import annotations 

29 

30__all__ = ( 

31 "FileIntegrityError", 

32 "Formatter", 

33 "FormatterFactory", 

34 "FormatterNotImplementedError", 

35 "FormatterParameter", 

36 "FormatterV1inV2", 

37 "FormatterV2", 

38) 

39 

40import contextlib 

41import copy 

42import logging 

43import os 

44import zipfile 

45from abc import ABCMeta, abstractmethod 

46from collections.abc import Callable, Iterator, Mapping, Set 

47from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, TypeAlias, final 

48 

49from lsst.resources import ResourceHandleProtocol, ResourcePath 

50from lsst.utils.introspection import get_full_type_name 

51from lsst.utils.timer import time_this 

52 

53from ._config import Config 

54from ._config_support import LookupKey, processLookupConfigs 

55from ._file_descriptor import FileDescriptor 

56from ._location import Location 

57from ._rubin.temporary_for_ingest import TemporaryForIngest 

58from .dimensions import DataCoordinate, DimensionUniverse 

59from .mapping_factory import MappingFactory 

60 

61log = logging.getLogger(__name__) 

62 

63if TYPE_CHECKING: 

64 from ._dataset_provenance import DatasetProvenance 

65 from ._dataset_ref import DatasetRef 

66 from ._dataset_type import DatasetType 

67 from ._storage_class import StorageClass 

68 from .datastore.cache_manager import AbstractDatastoreCacheManager 

69 

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

71 Entity: TypeAlias = DatasetType | DatasetRef | StorageClass | str 

72 

73 

74class FileIntegrityError(RuntimeError): 

75 """The file metadata is inconsistent with the metadata supplied by 

76 the datastore. 

77 """ 

78 

79 

80class FormatterNotImplementedError(NotImplementedError): 

81 """Formatter does not implement the specific read or write method 

82 that is being requested. 

83 """ 

84 

85 

86class FormatterV2: 

87 """Interface for reading and writing datasets using URIs. 

88 

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

90 

91 Parameters 

92 ---------- 

93 file_descriptor : `FileDescriptor`, optional 

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

95 classes and parameter information. 

96 ref : `DatasetRef` 

97 The dataset associated with this formatter. Should not be a component 

98 dataset ref. 

99 write_parameters : `dict`, optional 

100 Parameters to control how the dataset is serialized. 

101 write_recipes : `dict`, optional 

102 Detailed write recipes indexed by recipe name. 

103 

104 **kwargs 

105 Additional arguments that will be ignored but allow for 

106 `Formatter` V1 parameters to be given. 

107 

108 Notes 

109 ----- 

110 A `FormatterV2` author should not override the default `read` or `write` 

111 method. Instead for read the formatter author should implement one or all 

112 of `read_from_stream`, `read_from_uri`, or `read_from_local_file`. The 

113 method `read_from_uri` will always be attempted first and could be more 

114 efficient (since it allows the possibility for a subset of the data file to 

115 be accessed remotely when parameters or components are specified) but it 

116 will not update the local cache. If the entire contents of the remote file 

117 are being accessed (no component or parameters defined) and the dataset 

118 would be cached, `read_from_uri` will be called with a local file. If the 

119 file is remote and the parameters that have been included are known to be 

120 more efficiently handled with a local file, the `read_from_uri` method can 

121 return `NotImplemented` to indicate that a local file should be given 

122 instead. 

123 

124 Similarly for writes, the `write` method can not be subclassed. Instead 

125 the formatter author should implement `to_bytes` or `write_local_file`. 

126 For local URIs the system will always call `write_local_file` first (which 

127 by default will call `to_bytes`) to ensure atomic writes are implemented. 

128 For remote URIs with local caching disabled, `to_bytes` will be called 

129 first and the remote updated directly. If the dataset should be cached 

130 it will always be written locally first. 

131 """ 

132 

133 unsupported_parameters: ClassVar[Set[str] | None] = frozenset() 

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

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

136 are supported. These parameters should match those defined in the storage 

137 class definition. (`frozenset`). 

138 """ 

139 

140 supported_write_parameters: ClassVar[Set[str] | None] = None 

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

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

143 supported.""" 

144 

145 default_extension: ClassVar[str | None] = None 

146 """Default extension to use when writing a file. 

147 

148 Can be `None` if the extension is determined dynamically. Use the 

149 `get_write_extension` method to get the actual extension to use. 

150 """ 

151 

152 supported_extensions: ClassVar[Set[str]] = frozenset() 

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

154 

155 Any extension assigned to the ``default_extension`` property will be 

156 automatically included in the list of supported extensions. 

157 """ 

158 

159 can_read_from_uri: ClassVar[bool] = False 

160 """Declare whether `read_from_uri` is available to this formatter.""" 

161 

162 can_read_from_stream: ClassVar[bool] = False 

163 """Declare whether `read_from_stream` is available to this formatter.""" 

164 

165 can_read_from_local_file: ClassVar[bool] = False 

166 """Declare whether `read_from_file` is available to this formatter.""" 

167 

168 def __init__( 

169 self, 

170 file_descriptor: FileDescriptor, 

171 *, 

172 ref: DatasetRef, 

173 write_parameters: Mapping[str, Any] | None = None, 

174 write_recipes: Mapping[str, Any] | None = None, 

175 # Compatibility parameters. Unused in v2. 

176 **kwargs: Any, 

177 ): 

178 if not isinstance(file_descriptor, FileDescriptor): 

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

180 

181 self._file_descriptor = file_descriptor 

182 

183 if ref.isComponent(): 

184 # It is a component ref for disassembled composites. 

185 ref = ref.makeCompositeRef() 

186 self._dataset_ref = ref 

187 

188 # Check that the write parameters are allowed 

189 if write_parameters: 

190 if self.supported_write_parameters is None: 

191 raise ValueError( 

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

193 ) 

194 else: 

195 given = set(write_parameters) 

196 unknown = given - self.supported_write_parameters 

197 if unknown: 

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

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

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

201 

202 self._write_parameters = write_parameters 

203 self._write_recipes = self.validate_write_recipes(write_recipes) 

204 

205 def __str__(self) -> str: 

206 return f"{self.name()}@{self.file_descriptor.location.uri}" 

207 

208 def __repr__(self) -> str: 

209 return f"{self.name()}({self.file_descriptor!r})" 

210 

211 @property 

212 def file_descriptor(self) -> FileDescriptor: 

213 """File descriptor associated with this formatter 

214 (`FileDescriptor`). 

215 """ 

216 return self._file_descriptor 

217 

218 @property 

219 def data_id(self) -> DataCoordinate: 

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

221 return self._dataset_ref.dataId 

222 

223 @property 

224 def dataset_ref(self) -> DatasetRef: 

225 """Return Dataset Ref associated with this formatter (`DatasetRef`).""" 

226 return self._dataset_ref 

227 

228 @property 

229 def write_parameters(self) -> Mapping[str, Any]: 

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

231 if self._write_parameters is not None: 

232 return self._write_parameters 

233 return {} 

234 

235 @property 

236 def write_recipes(self) -> Mapping[str, Any]: 

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

238 if self._write_recipes is not None: 

239 return self._write_recipes 

240 return {} 

241 

242 def get_write_extension(self) -> str: 

243 """Extension to use when writing a file.""" 

244 default_extension = self.default_extension 

245 extension = default_extension if default_extension is not None else "" 

246 return extension 

247 

248 def can_accept(self, in_memory_dataset: Any) -> bool: 

249 """Indicate whether this formatter can accept the specified 

250 storage class directly. 

251 

252 Parameters 

253 ---------- 

254 in_memory_dataset : `object` 

255 The dataset that is to be written. 

256 

257 Returns 

258 ------- 

259 accepts : `bool` 

260 If `True` the formatter can write data of this type without 

261 requiring datastore to convert it. If `False` the datastore 

262 will attempt to convert before writing. 

263 

264 Notes 

265 ----- 

266 The base class always returns `False` even if the given type is an 

267 instance of the storage class type. This will result in a storage 

268 class conversion no-op but also allows mocks with mocked storage 

269 classes to work properly. 

270 """ 

271 return False 

272 

273 @classmethod 

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

275 """Validate supplied recipes for this formatter. 

276 

277 The recipes are supplemented with default values where appropriate. 

278 

279 Parameters 

280 ---------- 

281 recipes : `dict` 

282 Recipes to validate. 

283 

284 Returns 

285 ------- 

286 validated : `dict` 

287 Validated recipes. 

288 

289 Raises 

290 ------ 

291 RuntimeError 

292 Raised if validation fails. The default implementation raises 

293 if any recipes are given. 

294 """ 

295 if recipes: 

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

297 return recipes 

298 

299 @classmethod 

300 def name(cls) -> str: 

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

302 

303 Returns 

304 ------- 

305 name : `str` 

306 Fully-qualified name of formatter class. 

307 """ 

308 return get_full_type_name(cls) 

309 

310 def _is_disassembled(self) -> bool: 

311 """Return `True` if this formatter is looking at a disassembled 

312 component. 

313 """ 

314 return self.file_descriptor.component is not None 

315 

316 def _check_resource_size(self, uri: ResourcePath, recorded_size: int, resource_size: int) -> None: 

317 """Compare the recorded size with the resource size. 

318 

319 The given URI will not be accessed. 

320 """ 

321 if recorded_size >= 0 and resource_size != recorded_size: 

322 raise FileIntegrityError( 

323 "Integrity failure in Datastore. " 

324 f"Size of file {uri} ({resource_size}) " 

325 f"does not match size recorded in registry of {recorded_size}" 

326 ) 

327 

328 def _get_cache_ref(self) -> DatasetRef: 

329 """Get the `DatasetRef` to use for cache look ups. 

330 

331 Returns 

332 ------- 

333 ref : `lsst.daf.butler.DatasetRef` 

334 The dataset ref to use when looking in the cache. 

335 For single-file dataset this will be the dataset ref directly. 

336 If this is disassembled we need the component and the component 

337 will be in the `FileDescriptor`. 

338 """ 

339 if self.file_descriptor.component is None: 

340 cache_ref = self.dataset_ref 

341 else: 

342 cache_ref = self.dataset_ref.makeComponentRef(self.file_descriptor.component) 

343 return cache_ref 

344 

345 def _ensure_cache( 

346 self, cache_manager: AbstractDatastoreCacheManager | None = None 

347 ) -> AbstractDatastoreCacheManager: 

348 """Return the cache if given else return a null cache.""" 

349 if cache_manager is None: 

350 # Circular import avoidance. 

351 from .datastore.cache_manager import DatastoreDisabledCacheManager 

352 

353 cache_manager = DatastoreDisabledCacheManager(None, None) 

354 return cache_manager 

355 

356 def read( 

357 self, 

358 component: str | None = None, 

359 expected_size: int = -1, 

360 cache_manager: AbstractDatastoreCacheManager | None = None, 

361 ) -> Any: 

362 """Read a Dataset. 

363 

364 Parameters 

365 ---------- 

366 component : `str`, optional 

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

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

369 file. 

370 expected_size : `int`, optional 

371 If known, the expected size of the resource to read. This can be 

372 used for verification or to decide whether to do a direct read or a 

373 file download. ``-1`` indicates the file size is not known. 

374 cache_manager : `AbstractDatastoreCacheManager` 

375 A cache manager to use to allow a formatter to cache a remote file 

376 locally or read a cached file that is already local. 

377 

378 Returns 

379 ------- 

380 in_memory_dataset : `object` 

381 The requested Dataset. 

382 

383 Notes 

384 ----- 

385 This method should not be subclassed. Instead formatter subclasses 

386 should re-implement the specific `read_from_*` methods as appropriate. 

387 Each of these methods has a corresponding class property that must 

388 be `True` for the method to be called. 

389 

390 The priority for reading is: 

391 

392 * `read_from_uri` 

393 * `read_from_stream` 

394 * `read_from_local_file` 

395 * `read_from_uri` (but with a local file) 

396 

397 Any of these methods can return `NotImplemented` if there is a desire 

398 to skip to the next one in the list. If a dataset is being requested 

399 with no component, no parameters, and it should also be added to the 

400 local cache, the first two calls will be skipped (unless 

401 `read_from_stream` is the only implemented read method) such that a 

402 local file will be used. 

403 

404 A Formatter can also read a file from within a Zip file if the 

405 URI associated with the `FileDescriptor` corresponds to a file with 

406 a `.zip` extension and a URI fragment of the form 

407 ``zip-path={path_in_zip}``. When reading a file from within a Zip 

408 file the priority for reading is: 

409 

410 * `read_from_stream` 

411 * `read_from_local_file` 

412 * `read_from_uri` 

413 

414 There are multiple cases that must be handled for reading: 

415 

416 For a single file: 

417 

418 * No component requested, read the whole file. 

419 * Component requested, optionally read the component efficiently, 

420 else read the whole file and extract the component. 

421 * Derived component requested, read whole file or read relevant 

422 component and derive. 

423 

424 Disassembled Composite: 

425 

426 * The file to read here is the component itself. Formatter only knows 

427 about this one component file. Should be no component specified 

428 in the ``read`` call but the `FileDescriptor` will know which 

429 component this is. 

430 * A derived component. The file to read is a component but not the 

431 specified component. The caching needs the component from which 

432 it's derived. 

433 

434 Raises 

435 ------ 

436 FormatterNotImplementedError 

437 Raised if no implementations were found that could read this 

438 resource. 

439 """ 

440 # If the file to read is a ZIP file with a fragment requesting 

441 # a file within the ZIP file, it is no longer possible to use the 

442 # direct read from URI option and the contents of the Zip file must 

443 # be extracted. 

444 uri = self.file_descriptor.location.uri 

445 if uri.fragment and uri.unquoted_fragment.startswith("zip-path="): 

446 _, _, path_in_zip = uri.unquoted_fragment.partition("=") 

447 

448 # Open the Zip file using ResourcePath. 

449 with uri.open("rb") as fd: 

450 with zipfile.ZipFile(fd) as zf: # type: ignore 

451 if self.can_read_from_stream: 

452 with contextlib.closing(zf.open(path_in_zip)) as zip_fd: 

453 result = self.read_from_stream(zip_fd, component, expected_size=expected_size) 

454 

455 if result is not NotImplemented: 

456 return result 

457 

458 # For now for both URI and local file options we retrieve 

459 # the bytes to a temporary local and use that. 

460 _, suffix = os.path.splitext(path_in_zip) 

461 with ResourcePath.temporary_uri(suffix=suffix) as tmp_uri: 

462 tmp_uri.write(zf.read(path_in_zip)) 

463 

464 if self.can_read_from_local_file: 

465 result = self.read_from_local_file( 

466 tmp_uri.ospath, component, expected_size=expected_size 

467 ) 

468 if result is not NotImplemented: 

469 return result 

470 if self.can_read_from_uri: 

471 result = self.read_from_uri(tmp_uri, component, expected_size=expected_size) 

472 if result is not NotImplemented: 

473 return result 

474 

475 raise FormatterNotImplementedError( 

476 f"Formatter {self.name()} could not read the file at {uri} using any method." 

477 ) 

478 

479 # If the there are no parameters, no component request, and 

480 # the file should be cached, it is better to defer the read_from_uri 

481 # to use the local file and populate the cache. 

482 prefer_local = False 

483 if ( 

484 component is None 

485 and not self.file_descriptor.parameters 

486 and self._ensure_cache(cache_manager).should_be_cached(self._get_cache_ref()) 

487 ): 

488 prefer_local = True 

489 

490 # First see if the formatter can support direct remote read from 

491 # a URI. This can be called later from the local path. If it returns 

492 # NotImplemented the formatter decided on its own that local 

493 # reads are preferred. 

494 if not prefer_local and self.can_read_from_uri: 

495 result = self.read_directly_from_possibly_cached_uri( 

496 component, expected_size, cache_manager=cache_manager 

497 ) 

498 if result is not NotImplemented: 

499 return result 

500 

501 # Some formatters might want to be able to read directly from 

502 # an open file stream. This is preferred over forcing a download 

503 # to local file system unless a file read option is available and we 

504 # want to store it in the cache because the whole file is being read. 

505 if self.can_read_from_stream and not ( 

506 prefer_local and (self.can_read_from_uri or self.can_read_from_local_file) 

507 ): 

508 result = self.read_from_possibly_cached_stream( 

509 component, expected_size, cache_manager=cache_manager 

510 ) 

511 if result is not NotImplemented: 

512 return result 

513 

514 # Finally, try to read the local file. 

515 if self.can_read_from_local_file or self.can_read_from_uri: 

516 result = self.read_from_possibly_cached_local_file( 

517 component, expected_size, cache_manager=cache_manager 

518 ) 

519 if result is not NotImplemented: 

520 return result 

521 

522 raise FormatterNotImplementedError( 

523 f"Formatter {self.name()} could not read the file at {uri} using any method." 

524 ) 

525 

526 def _read_from_possibly_cached_location_no_cache_write( 

527 self, 

528 callback: Callable[[ResourcePath, str | None, int], Any], 

529 component: str | None = None, 

530 expected_size: int = -1, 

531 *, 

532 cache_manager: AbstractDatastoreCacheManager | None = None, 

533 ) -> Any: 

534 """Read from the cache and call payload without writing to cache.""" 

535 cache_manager = self._ensure_cache(cache_manager) 

536 

537 uri = self.file_descriptor.location.uri 

538 cache_ref = self._get_cache_ref() 

539 

540 # The component for log messages is either the component requested 

541 # explicitly or the component from the file descriptor. 

542 log_component = component if component is not None else self.file_descriptor.component 

543 

544 with cache_manager.find_in_cache(cache_ref, uri.getExtension()) as cached_file: 

545 if cached_file is not None: 

546 desired_uri = cached_file 

547 msg = f" (cached version of {uri})" 

548 else: 

549 desired_uri = uri 

550 msg = "" 

551 

552 if desired_uri.isLocal: 

553 # Do not spend the time doing a slow size() call to a remote 

554 # resource. 

555 self._check_resource_size(desired_uri, expected_size, desired_uri.size()) 

556 

557 with time_this( 

558 log, 

559 msg="Reading%s from file handle %s%s with formatter %s", 

560 args=( 

561 f" component {log_component}" if log_component else "", 

562 desired_uri, 

563 msg, 

564 self.name(), 

565 ), 

566 ): 

567 return callback(desired_uri, component, expected_size) 

568 

569 def read_from_possibly_cached_stream( 

570 self, 

571 component: str | None = None, 

572 expected_size: int = -1, 

573 *, 

574 cache_manager: AbstractDatastoreCacheManager | None = None, 

575 ) -> Any: 

576 """Read from a stream, checking for possible presence in local cache. 

577 

578 Parameters 

579 ---------- 

580 component : `str`, optional 

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

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

583 file. 

584 expected_size : `int`, optional 

585 If known, the expected size of the resource to read. This can be 

586 used for verification or to decide whether to do a direct read or a 

587 file download. ``-1`` indicates the file size is not known. 

588 cache_manager : `AbstractDatastoreCacheManager` 

589 A cache manager to use to allow a formatter to check if there is 

590 a copy of the file in the local cache. 

591 

592 Returns 

593 ------- 

594 in_memory_dataset : `object` or `NotImplemented` 

595 The requested Dataset or an indication that the read mode was 

596 not implemented. 

597 

598 Notes 

599 ----- 

600 Calls `read_from_stream` but will first check the datastore cache 

601 in case the file is present locally. This method will not download 

602 a file to the local cache. 

603 """ 

604 

605 def _open_stream(uri: ResourcePath, comp: str | None, size: int = -1) -> Any: 

606 with uri.open("rb") as fd: 

607 return self.read_from_stream(fd, comp, expected_size=size) 

608 

609 return self._read_from_possibly_cached_location_no_cache_write( 

610 _open_stream, component, expected_size=expected_size, cache_manager=cache_manager 

611 ) 

612 

613 def read_directly_from_possibly_cached_uri( 

614 self, 

615 component: str | None = None, 

616 expected_size: int = -1, 

617 *, 

618 cache_manager: AbstractDatastoreCacheManager | None = None, 

619 ) -> Any: 

620 """Read from arbitrary URI, checking for possible presence in local 

621 cache. 

622 

623 Parameters 

624 ---------- 

625 component : `str`, optional 

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

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

628 file. 

629 expected_size : `int`, optional 

630 If known, the expected size of the resource to read. This can be 

631 ``-1`` indicates the file size is not known. 

632 cache_manager : `AbstractDatastoreCacheManager` 

633 A cache manager to use to allow a formatter to check if there is 

634 a copy of the file in the local cache. 

635 

636 Returns 

637 ------- 

638 in_memory_dataset : `object` or `NotImplemented` 

639 The requested Dataset or an indication that the read mode was 

640 not implemented. 

641 

642 Notes 

643 ----- 

644 This method will first check the datastore cache 

645 in case the file is present locally. This method will not cache a 

646 remote dataset and will only do a size check for local files to avoid 

647 unnecessary round trips to a remote server. 

648 

649 The URI will be read by calling `read_from_uri`. 

650 """ 

651 

652 def _open_uri(uri: ResourcePath, comp: str | None, size: int = -1) -> Any: 

653 return self.read_from_uri(uri, comp, expected_size=size) 

654 

655 return self._read_from_possibly_cached_location_no_cache_write( 

656 _open_uri, component, expected_size=expected_size, cache_manager=cache_manager 

657 ) 

658 

659 def read_from_possibly_cached_local_file( 

660 self, 

661 component: str | None = None, 

662 expected_size: int = -1, 

663 *, 

664 cache_manager: AbstractDatastoreCacheManager | None = None, 

665 ) -> Any: 

666 """Read a dataset ensuring that a local file is used, checking the 

667 cache for it. 

668 

669 Parameters 

670 ---------- 

671 component : `str`, optional 

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

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

674 file. 

675 expected_size : `int`, optional 

676 If known, the expected size of the resource to read. This can be 

677 used for verification or to decide whether to do a direct read or a 

678 file download. ``-1`` indicates the file size is not known. 

679 cache_manager : `AbstractDatastoreCacheManager` 

680 A cache manager to use to allow a formatter to cache a remote file 

681 locally or read a cached file that is already local. 

682 

683 Returns 

684 ------- 

685 in_memory_dataset : `object` or `NotImplemented` 

686 The requested Dataset or an indication that the read mode was 

687 not implemented. 

688 

689 Notes 

690 ----- 

691 The file will be downloaded and cached if it is a remote resource. 

692 The file contents will be read using `read_from_local_file` or 

693 `read_from_uri`, with preference given to the former. 

694 """ 

695 cache_manager = self._ensure_cache(cache_manager) 

696 uri = self.file_descriptor.location.uri 

697 

698 # Need to have something we can look up in the cache. 

699 cache_ref = self._get_cache_ref() 

700 

701 # The component for log messages is either the component requested 

702 # explicitly or the component from the file descriptor. 

703 log_component = component if component is not None else self.file_descriptor.component 

704 

705 result = NotImplemented 

706 

707 # Ensure we have a local file. 

708 with cache_manager.find_in_cache(cache_ref, uri.getExtension()) as cached_file: 

709 if cached_file is not None: 

710 msg = f"(via cache read of remote file {uri})" 

711 uri = cached_file 

712 else: 

713 msg = "" 

714 

715 with uri.as_local() as local_uri: 

716 self._check_resource_size(self.file_descriptor.location.uri, expected_size, local_uri.size()) 

717 can_be_cached = False 

718 if uri != local_uri: 

719 # URI was remote and file was downloaded 

720 cache_msg = "" 

721 

722 if cache_manager.should_be_cached(cache_ref): 

723 # In this scenario we want to ask if the downloaded 

724 # file should be cached but we should not cache 

725 # it until after we've used it (to ensure it can't 

726 # be expired whilst we are using it). 

727 can_be_cached = True 

728 

729 # Say that it is "likely" to be cached because 

730 # if the formatter read fails we will not be 

731 # caching this file. 

732 cache_msg = " and likely cached" 

733 

734 msg = f"(via download to local file{cache_msg})" 

735 

736 with time_this( 

737 log, 

738 msg="Reading%s from location %s %s with formatter %s", 

739 args=( 

740 f" component {log_component}" if log_component else "", 

741 uri, 

742 msg, 

743 self.name(), 

744 ), 

745 ): 

746 if self.can_read_from_local_file: 

747 result = self.read_from_local_file( 

748 local_uri.ospath, component=component, expected_size=expected_size 

749 ) 

750 if result is NotImplemented and self.can_read_from_uri: 

751 # If the direct URI reader was skipped earlier and 

752 # there is no explicit local file implementation, pass 

753 # in the guaranteed local URI to the generic reader. 

754 result = self.read_from_uri( 

755 local_uri, component=component, expected_size=expected_size 

756 ) 

757 

758 # File was read successfully so can move to cache. 

759 # Also move to cache even if NotImplemented was returned. 

760 if can_be_cached: 

761 cache_manager.move_to_cache(local_uri, cache_ref) 

762 

763 return result 

764 

765 def read_from_uri(self, uri: ResourcePath, component: str | None = None, expected_size: int = -1) -> Any: 

766 """Read a dataset from a URI that can be local or remote. 

767 

768 Parameters 

769 ---------- 

770 uri : `lsst.resources.ResourcePath` 

771 URI to use to read the dataset. This URI can be local or remote 

772 and can refer to the actual resource or to a locally cached file. 

773 component : `str` or `None`, optional 

774 The component to be read from the dataset. 

775 expected_size : `int`, optional 

776 If known, the expected size of the resource to read. This can be 

777 ``-1`` indicates the file size is not known. 

778 

779 Returns 

780 ------- 

781 in_memory_dataset : `object` or `NotImplemented` 

782 The Python object read from the resource or `NotImplemented`. 

783 

784 Raises 

785 ------ 

786 FormatterNotImplementedError 

787 Raised if there is no support for direct reads from a, possibly, 

788 remote URI. 

789 

790 Notes 

791 ----- 

792 This method is only called if the class property 

793 ``can_read_from_uri`` is set to `True`. 

794 

795 It is possible that a cached local file will be given to this method 

796 even if it was originally a remote URI. This can happen if the original 

797 write resulted in the file being added to the local cache. 

798 

799 If the full file is being read this file will not be added to the 

800 local cache. Consider returning `NotImplemented` in 

801 this situation, for example if there are no parameters or component 

802 specified, and allowing the system to fall back to calling 

803 `read_from_local_file` (which will populate the cache if configured 

804 to do so). 

805 """ 

806 return NotImplemented 

807 

808 def read_from_stream( 

809 self, stream: BinaryIO | ResourceHandleProtocol, component: str | None = None, expected_size: int = -1 

810 ) -> Any: 

811 """Read from an open file descriptor. 

812 

813 Parameters 

814 ---------- 

815 stream : `lsst.resources.ResourceHandleProtocol` or \ 

816 `typing.BinaryIO` 

817 File stream to use to read the dataset. 

818 component : `str` or `None`, optional 

819 The component to be read from the dataset. 

820 expected_size : `int`, optional 

821 If known, the expected size of the resource to read. This can be 

822 ``-1`` indicates the file size is not known. 

823 

824 Returns 

825 ------- 

826 in_memory_dataset : `object` or `NotImplemented` 

827 The Python object read from the stream or `NotImplemented`. 

828 

829 Notes 

830 ----- 

831 Only called if the class property ``can_read_from_stream`` is `True`. 

832 """ 

833 return NotImplemented 

834 

835 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any: 

836 """Read a dataset from a URI guaranteed to refer to the local file 

837 system. 

838 

839 Parameters 

840 ---------- 

841 path : `str` 

842 Path to a local file that should be read. 

843 component : `str` or `None`, optional 

844 The component to be read from the dataset. 

845 expected_size : `int`, optional 

846 If known, the expected size of the resource to read. This can be 

847 ``-1`` indicates the file size is not known. 

848 

849 Returns 

850 ------- 

851 in_memory_dataset : `object` or `NotImplemented` 

852 The Python object read from the resource or `NotImplemented`. 

853 

854 Raises 

855 ------ 

856 FormatterNotImplementedError 

857 Raised if there is no implementation written to read data 

858 from a local file. 

859 

860 Notes 

861 ----- 

862 This method will only be called if the class property 

863 ``can_read_from_local_file`` is `True` and other options were not 

864 used. 

865 """ 

866 return NotImplemented 

867 

868 def add_provenance( 

869 self, in_memory_dataset: Any, /, *, provenance: DatasetProvenance | None = None 

870 ) -> Any: 

871 """Add provenance to the dataset. 

872 

873 Parameters 

874 ---------- 

875 in_memory_dataset : `object` 

876 The dataset to serialize. 

877 provenance : `DatasetProvenance` or `None`, optional 

878 Provenance to attach to dataset. 

879 

880 Returns 

881 ------- 

882 dataset_to_write : `object` 

883 The dataset to use for serialization. Can be the same object as 

884 given. 

885 

886 Notes 

887 ----- 

888 The base class implementation returns the given object unchanged. 

889 """ 

890 return in_memory_dataset 

891 

892 @final 

893 def write( 

894 self, 

895 in_memory_dataset: Any, 

896 /, 

897 *, 

898 cache_manager: AbstractDatastoreCacheManager | None = None, 

899 provenance: DatasetProvenance | None = None, 

900 ) -> None: 

901 """Write a Dataset. 

902 

903 Parameters 

904 ---------- 

905 in_memory_dataset : `object` 

906 The Dataset to serialize. 

907 cache_manager : `AbstractDatastoreCacheManager` 

908 A cache manager to use to allow a formatter to cache the written 

909 file. 

910 provenance : `DatasetProvenance` | `None`, optional 

911 Provenance to attach to the file being written. 

912 

913 Raises 

914 ------ 

915 FormatterNotImplementedError 

916 Raised if the formatter subclass has not implemented 

917 `write_local_file` and `to_bytes` was not called. 

918 Exception 

919 Raised if there is an error serializing the dataset to disk. 

920 

921 Notes 

922 ----- 

923 The intent is for subclasses to implement either `to_bytes` or 

924 `write_local_file` or both and not to subclass this method. 

925 """ 

926 # Ensure we are using the correct file extension. 

927 uri = self.file_descriptor.location.uri.updatedExtension(self.get_write_extension()) 

928 

929 # Attach any provenance to the dataset. This could involve returning 

930 # a different object. 

931 in_memory_dataset = self.add_provenance(in_memory_dataset, provenance=provenance) 

932 

933 written = self.write_direct(in_memory_dataset, uri, cache_manager) 

934 if not written: 

935 self.write_locally_then_move(in_memory_dataset, uri, cache_manager) 

936 

937 def write_direct( 

938 self, 

939 in_memory_dataset: Any, 

940 uri: ResourcePath, 

941 cache_manager: AbstractDatastoreCacheManager | None = None, 

942 ) -> bool: 

943 """Serialize and write directly to final location. 

944 

945 Parameters 

946 ---------- 

947 in_memory_dataset : `object` 

948 The Dataset to serialize. 

949 uri : `lsst.resources.ResourcePath` 

950 URI to use when writing the serialized dataset. 

951 cache_manager : `AbstractDatastoreCacheManager` 

952 A cache manager to use to allow a formatter to cache the written 

953 file. 

954 

955 Returns 

956 ------- 

957 written : `bool` 

958 Flag to indicate whether the direct write did happen. 

959 

960 Raises 

961 ------ 

962 Exception 

963 Raised if there was a failure from serializing to bytes that 

964 was not `FormatterNotImplementedError`. 

965 

966 Notes 

967 ----- 

968 This method will call `to_bytes` to serialize the in-memory dataset 

969 and then will call the `~lsst.resources.ResourcePath.write` method 

970 directly. 

971 

972 If the dataset should be cached or is local the file will not be 

973 written and the method will return `False`. This is because local URIs 

974 should be written to a temporary file name and then renamed to allow 

975 atomic writes. That path is handled by `write_locally_then_move` 

976 through `write_local_file`) and is preferred over this method being 

977 subclassed and the atomic write re-implemented. 

978 """ 

979 cache_manager = self._ensure_cache(cache_manager) 

980 

981 # For remote URIs some datasets can be serialized directly 

982 # to bytes and sent to the remote datastore without writing a 

983 # file. If the dataset is intended to be saved to the cache 

984 # a file is always written and direct write to the remote 

985 # datastore is bypassed. 

986 data_written = False 

987 if not uri.isLocal and not cache_manager.should_be_cached(self._get_cache_ref()): 

988 # Remote URI that is not cached so can write directly. 

989 try: 

990 serialized_dataset = self.to_bytes(in_memory_dataset) 

991 except FormatterNotImplementedError: 

992 # Fallback to the file writing option. 

993 pass 

994 except Exception as e: 

995 e.add_note( 

996 f"Failed to serialize dataset {self.dataset_ref} of " 

997 f"type {get_full_type_name(in_memory_dataset)} to bytes." 

998 ) 

999 raise 

1000 else: 

1001 log.debug("Writing bytes directly to %s", uri) 

1002 uri.write(serialized_dataset, overwrite=True) 

1003 log.debug("Successfully wrote bytes directly to %s", uri) 

1004 data_written = True 

1005 return data_written 

1006 

1007 def write_locally_then_move( 

1008 self, 

1009 in_memory_dataset: Any, 

1010 uri: ResourcePath, 

1011 cache_manager: AbstractDatastoreCacheManager | None = None, 

1012 ) -> None: 

1013 """Write file to file system and then move to final location. 

1014 

1015 Parameters 

1016 ---------- 

1017 in_memory_dataset : `object` 

1018 The Dataset to serialize. 

1019 uri : `lsst.resources.ResourcePath` 

1020 URI to use when writing the serialized dataset. 

1021 cache_manager : `AbstractDatastoreCacheManager` 

1022 A cache manager to use to allow a formatter to cache the written 

1023 file. 

1024 

1025 Raises 

1026 ------ 

1027 FormatterNotImplementedError 

1028 Raised if the formatter subclass has not implemented 

1029 `write_local_file`. 

1030 Exception 

1031 Raised if there is an error serializing the dataset to disk. 

1032 """ 

1033 cache_manager = self._ensure_cache(cache_manager) 

1034 

1035 with TemporaryForIngest.make_path(uri) as temporary_uri: 

1036 # Need to configure the formatter to write to a different 

1037 # location and that needs us to overwrite internals 

1038 log.debug("Writing dataset to temporary location at %s", temporary_uri) 

1039 

1040 # Assumes that if write_local_file is not subclassed that 

1041 # to_bytes will be called using the base class definition. 

1042 try: 

1043 self.write_local_file(in_memory_dataset, temporary_uri) 

1044 except Exception as e: 

1045 e.add_note( 

1046 f"Failed to serialize dataset {self.dataset_ref} of type" 

1047 f" {get_full_type_name(in_memory_dataset)} to " 

1048 f"temporary location {temporary_uri}." 

1049 ) 

1050 raise 

1051 

1052 # Use move for a local file since that becomes an efficient 

1053 # os.rename. For remote resources we use copy to allow the 

1054 # file to be cached afterwards. 

1055 transfer = "move" if uri.isLocal else "copy" 

1056 

1057 uri.transfer_from(temporary_uri, transfer=transfer, overwrite=True) 

1058 

1059 if transfer == "copy": 

1060 # Cache if required 

1061 cache_manager.move_to_cache(temporary_uri, self._get_cache_ref()) 

1062 

1063 log.debug("Successfully wrote dataset to %s via a temporary file.", uri) 

1064 

1065 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None: 

1066 """Serialize the in-memory dataset to a local file. 

1067 

1068 Parameters 

1069 ---------- 

1070 in_memory_dataset : `object` 

1071 The Python object to serialize. 

1072 uri : `ResourcePath` 

1073 The URI to use when writing the file. 

1074 

1075 Notes 

1076 ----- 

1077 By default this method will attempt to call `to_bytes` and then 

1078 write these bytes to the file. 

1079 

1080 Raises 

1081 ------ 

1082 FormatterNotImplementedError 

1083 Raised if the formatter subclass has not implemented this method 

1084 or has failed to implement the `to_bytes` method. 

1085 """ 

1086 log.debug("Writing bytes directly to %s", uri) 

1087 uri.write(self.to_bytes(in_memory_dataset)) 

1088 log.debug("Successfully wrote bytes directly to %s", uri) 

1089 

1090 def to_bytes(self, in_memory_dataset: Any) -> bytes: 

1091 """Serialize the in-memory dataset to bytes. 

1092 

1093 Parameters 

1094 ---------- 

1095 in_memory_dataset : `object` 

1096 The Python object to serialize. 

1097 

1098 Returns 

1099 ------- 

1100 serialized_dataset : `bytes` 

1101 Bytes representing the serialized dataset. 

1102 

1103 Raises 

1104 ------ 

1105 FormatterNotImplementedError 

1106 Raised if the formatter has not implemented the method. This will 

1107 not cause a problem if `write_local_file` has been implemented. 

1108 """ 

1109 raise FormatterNotImplementedError( 

1110 f"This formatter can not convert {get_full_type_name(in_memory_dataset)} directly to bytes." 

1111 ) 

1112 

1113 def make_updated_location(self, location: Location) -> Location: 

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

1115 

1116 Parameters 

1117 ---------- 

1118 location : `Location` 

1119 The location to update. 

1120 

1121 Returns 

1122 ------- 

1123 updated : `Location` 

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

1125 """ 

1126 location = location.clone() 

1127 # If the extension is "" the extension will be removed. 

1128 location.updateExtension(self.get_write_extension()) 

1129 return location 

1130 

1131 @classmethod 

1132 def validate_extension(cls, location: Location) -> None: 

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

1134 

1135 Parameters 

1136 ---------- 

1137 location : `Location` 

1138 Location from which to extract a file extension. 

1139 

1140 Raises 

1141 ------ 

1142 ValueError 

1143 Raised if the formatter does not understand this extension. 

1144 """ 

1145 supported = set(cls.supported_extensions) 

1146 default = cls.default_extension # type: ignore 

1147 

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

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

1150 # the supported extensions class property is complete. 

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

1152 supported.add(default) 

1153 

1154 # Get the file name from the uri 

1155 file = location.uri.basename() 

1156 

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

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

1159 # its extension and then doing a set comparison 

1160 for ext in supported: 

1161 if file.endswith(ext): 

1162 return 

1163 

1164 raise ValueError( 

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

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

1167 ) 

1168 

1169 def predict_path(self) -> str: 

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

1171 

1172 Does not write any data file. 

1173 

1174 Uses the `FileDescriptor` associated with the instance. 

1175 

1176 Returns 

1177 ------- 

1178 path : `str` 

1179 Path within datastore that would be associated with the location 

1180 stored in this `Formatter`. 

1181 """ 

1182 updated = self.make_updated_location(self.file_descriptor.location) 

1183 return updated.pathInStore.path 

1184 

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

1186 """Segregate the supplied parameters. 

1187 

1188 This splits the parameters into those understood by the 

1189 formatter and those not understood by the formatter. 

1190 

1191 Any unsupported parameters are assumed to be usable by associated 

1192 assemblers. 

1193 

1194 Parameters 

1195 ---------- 

1196 parameters : `dict`, optional 

1197 Parameters with values that have been supplied by the caller 

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

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

1200 

1201 Returns 

1202 ------- 

1203 supported : `dict` 

1204 Those parameters supported by this formatter. 

1205 unsupported : `dict` 

1206 Those parameters not supported by this formatter. 

1207 """ 

1208 if parameters is None: 

1209 parameters = self.file_descriptor.parameters 

1210 

1211 if parameters is None: 

1212 return {}, {} 

1213 

1214 if self.unsupported_parameters is None: 

1215 # Support none of the parameters 

1216 return {}, parameters.copy() 

1217 

1218 # Start by assuming all are supported 

1219 supported = parameters.copy() 

1220 unsupported = {} 

1221 

1222 # And remove any we know are not supported 

1223 for p in set(supported): 

1224 if p in self.unsupported_parameters: 

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

1226 

1227 return supported, unsupported 

1228 

1229 

1230class Formatter(metaclass=ABCMeta): 

1231 """Interface for reading and writing Datasets. 

1232 

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

1234 

1235 Parameters 

1236 ---------- 

1237 fileDescriptor : `FileDescriptor`, optional 

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

1239 classes and parameter information. 

1240 dataId : `DataCoordinate` 

1241 Data ID associated with this formatter. 

1242 writeParameters : `dict`, optional 

1243 Parameters to control how the dataset is serialized. 

1244 writeRecipes : `dict`, optional 

1245 Detailed write recipes indexed by recipe name. 

1246 **kwargs 

1247 Additional parameters that can allow parameters 

1248 from `FormatterV2` to be provided. 

1249 

1250 Notes 

1251 ----- 

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

1253 signature. 

1254 """ 

1255 

1256 # Now assuming that Formatter v1 can only refer to files so can add 

1257 # this property for compatibility with v2. 

1258 extension: str | None = None 

1259 """Default file extension to use for writing files. None means that no 

1260 modifications will be made to the supplied file extension. (`str`)""" 

1261 

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

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

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

1265 are supported. These parameters should match those defined in the storage 

1266 class definition. (`frozenset`). 

1267 """ 

1268 

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

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

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

1272 supported.""" 

1273 

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

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

1276 

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

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

1279 the list of supported extensions.""" 

1280 

1281 def __init__( 

1282 self, 

1283 fileDescriptor: FileDescriptor, 

1284 *, 

1285 dataId: DataCoordinate | None = None, 

1286 writeParameters: Mapping[str, Any] | None = None, 

1287 writeRecipes: Mapping[str, Any] | None = None, 

1288 # Allow FormatterV2 parameters to be dropped. 

1289 **kwargs: Any, 

1290 ): 

1291 if not isinstance(fileDescriptor, FileDescriptor): 

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

1293 self._fileDescriptor = fileDescriptor 

1294 

1295 if dataId is None: 

1296 raise RuntimeError("dataId is now required for formatter initialization") 

1297 if not isinstance(dataId, DataCoordinate): 

1298 raise TypeError(f"DataId is required to be a DataCoordinate but got {type(dataId)}.") 

1299 self._dataId = dataId 

1300 

1301 # V2 compatibility. 

1302 if writeParameters is None: 

1303 writeParameters = kwargs.get("write_parameters") 

1304 if writeRecipes is None: 

1305 writeRecipes = kwargs.get("write_recipes") 

1306 

1307 # Check that the write parameters are allowed 

1308 if writeParameters: 

1309 if self.supportedWriteParameters is None: 

1310 raise ValueError( 

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

1312 ) 

1313 else: 

1314 given = set(writeParameters) 

1315 unknown = given - self.supportedWriteParameters 

1316 if unknown: 

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

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

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

1320 

1321 self._writeParameters = writeParameters 

1322 self._writeRecipes = self.validate_write_recipes(writeRecipes) 

1323 

1324 def __str__(self) -> str: 

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

1326 

1327 def __repr__(self) -> str: 

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

1329 

1330 @property 

1331 def fileDescriptor(self) -> FileDescriptor: 

1332 """File descriptor associated with this formatter 

1333 (`FileDescriptor`). 

1334 """ 

1335 return self._fileDescriptor 

1336 

1337 @property 

1338 def dataId(self) -> DataCoordinate: 

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

1340 return self._dataId 

1341 

1342 @property 

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

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

1345 if self._writeParameters is not None: 

1346 return self._writeParameters 

1347 return {} 

1348 

1349 @property 

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

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

1352 if self._writeRecipes is not None: 

1353 return self._writeRecipes 

1354 return {} 

1355 

1356 def can_accept(self, in_memory_dataset: Any) -> bool: 

1357 """Indicate whether this formatter can accept the specified 

1358 storage class directly. 

1359 

1360 Parameters 

1361 ---------- 

1362 in_memory_dataset : `object` 

1363 The dataset that is to be written. 

1364 

1365 Returns 

1366 ------- 

1367 accepts : `bool` 

1368 If `True` the formatter can write data of this type without 

1369 requiring datastore to convert it. If `False` the datastore 

1370 will attempt to convert before writing. 

1371 

1372 Notes 

1373 ----- 

1374 The base class checks that the given python type matches 

1375 the python type specified for this formatter when 

1376 constructed. 

1377 """ 

1378 return isinstance(in_memory_dataset, self.file_descriptor.storageClass.pytype) 

1379 

1380 @classmethod 

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

1382 """Validate supplied recipes for this formatter. 

1383 

1384 The recipes are supplemented with default values where appropriate. 

1385 

1386 Parameters 

1387 ---------- 

1388 recipes : `dict` 

1389 Recipes to validate. 

1390 

1391 Returns 

1392 ------- 

1393 validated : `dict` 

1394 Validated recipes. 

1395 

1396 Raises 

1397 ------ 

1398 RuntimeError 

1399 Raised if validation fails. The default implementation raises 

1400 if any recipes are given. 

1401 """ 

1402 if recipes: 

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

1404 return recipes 

1405 

1406 @classmethod 

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

1408 return cls.validateWriteRecipes(recipes) 

1409 

1410 @classmethod 

1411 def name(cls) -> str: 

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

1413 

1414 Returns 

1415 ------- 

1416 name : `str` 

1417 Fully-qualified name of formatter class. 

1418 """ 

1419 return get_full_type_name(cls) 

1420 

1421 @abstractmethod 

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

1423 """Read a Dataset. 

1424 

1425 Parameters 

1426 ---------- 

1427 component : `str`, optional 

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

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

1430 file. 

1431 

1432 Returns 

1433 ------- 

1434 inMemoryDataset : `object` 

1435 The requested Dataset. 

1436 """ 

1437 raise FormatterNotImplementedError("Type does not support reading") 

1438 

1439 @abstractmethod 

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

1441 """Write a Dataset. 

1442 

1443 Parameters 

1444 ---------- 

1445 inMemoryDataset : `object` 

1446 The Dataset to store. 

1447 """ 

1448 raise FormatterNotImplementedError("Type does not support writing") 

1449 

1450 @classmethod 

1451 def can_read_bytes(cls) -> bool: 

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

1453 

1454 Returns 

1455 ------- 

1456 can : `bool` 

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

1458 """ 

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

1460 # and see what happens 

1461 try: 

1462 # We know the arguments are incompatible 

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

1464 except FormatterNotImplementedError: 

1465 return False 

1466 except Exception: 

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

1468 pass 

1469 return True 

1470 

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

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

1473 

1474 Parameters 

1475 ---------- 

1476 serializedDataset : `bytes` 

1477 Bytes object to unserialize. 

1478 component : `str`, optional 

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

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

1481 file. 

1482 

1483 Returns 

1484 ------- 

1485 inMemoryDataset : `object` 

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

1487 is controlled by the specific formatter. 

1488 """ 

1489 raise FormatterNotImplementedError("Type does not support reading from bytes.") 

1490 

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

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

1493 

1494 Parameters 

1495 ---------- 

1496 inMemoryDataset : `object` 

1497 The Python object to serialize. 

1498 

1499 Returns 

1500 ------- 

1501 serializedDataset : `bytes` 

1502 Bytes representing the serialized dataset. 

1503 """ 

1504 raise FormatterNotImplementedError("Type does not support writing to bytes.") 

1505 

1506 @contextlib.contextmanager 

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

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

1509 

1510 Parameters 

1511 ---------- 

1512 location : `Location` 

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

1514 formatter will not change but it will still return 

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

1516 path where the location may not need to be updated 

1517 but the with block is still convenient. 

1518 

1519 Yields 

1520 ------ 

1521 old : `Location` 

1522 The old location that will be restored. 

1523 

1524 Notes 

1525 ----- 

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

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

1528 manager to restore the location when the temporary is no 

1529 longer required. 

1530 """ 

1531 old = self._fileDescriptor.location 

1532 try: 

1533 if location is not None: 

1534 self._fileDescriptor.location = location 

1535 yield old 

1536 finally: 

1537 if location is not None: 

1538 self._fileDescriptor.location = old 

1539 

1540 def make_updated_location(self, location: Location) -> Location: 

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

1542 

1543 Parameters 

1544 ---------- 

1545 location : `Location` 

1546 The location to update. 

1547 

1548 Returns 

1549 ------- 

1550 updated : `Location` 

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

1552 

1553 Raises 

1554 ------ 

1555 NotImplementedError 

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

1557 this formatter. 

1558 

1559 Notes 

1560 ----- 

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

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

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

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

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

1566 defined in the base class. 

1567 """ 

1568 location = location.clone() 

1569 try: 

1570 # We are deliberately allowing extension to be undefined by 

1571 # default in the base class and mypy complains. 

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

1573 except AttributeError: 

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

1575 return location 

1576 

1577 @classmethod 

1578 def validate_extension(cls, location: Location) -> None: 

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

1580 

1581 Parameters 

1582 ---------- 

1583 location : `Location` 

1584 Location from which to extract a file extension. 

1585 

1586 Raises 

1587 ------ 

1588 NotImplementedError 

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

1590 formatter. 

1591 ValueError 

1592 Raised if the formatter does not understand this extension. 

1593 

1594 Notes 

1595 ----- 

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

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

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

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

1600 extensions will be examined. 

1601 """ 

1602 supported = set(cls.supportedExtensions) 

1603 

1604 try: 

1605 # We are deliberately allowing extension to be undefined by 

1606 # default in the base class and mypy complains. 

1607 default = cls.extension # type: ignore 

1608 except AttributeError: 

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

1610 

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

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

1613 # the supported extensions class property is complete. 

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

1615 supported.add(default) 

1616 

1617 # Get the file name from the uri 

1618 file = location.uri.basename() 

1619 

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

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

1622 # its extension and then doing a set comparison 

1623 for ext in supported: 

1624 if file.endswith(ext): 

1625 return 

1626 

1627 raise ValueError( 

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

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

1630 ) 

1631 

1632 def predict_path(self) -> str: 

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

1634 

1635 Does not write any data file. 

1636 

1637 Uses the `FileDescriptor` associated with the instance. 

1638 

1639 Returns 

1640 ------- 

1641 path : `str` 

1642 Path within datastore that would be associated with the location 

1643 stored in this `Formatter`. 

1644 """ 

1645 updated = self.make_updated_location(self.fileDescriptor.location) 

1646 return updated.pathInStore.path 

1647 

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

1649 """Segregate the supplied parameters. 

1650 

1651 This splits the parameters into those understood by the 

1652 formatter and those not understood by the formatter. 

1653 

1654 Any unsupported parameters are assumed to be usable by associated 

1655 assemblers. 

1656 

1657 Parameters 

1658 ---------- 

1659 parameters : `dict`, optional 

1660 Parameters with values that have been supplied by the caller 

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

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

1663 

1664 Returns 

1665 ------- 

1666 supported : `dict` 

1667 Those parameters supported by this formatter. 

1668 unsupported : `dict` 

1669 Those parameters not supported by this formatter. 

1670 """ 

1671 if parameters is None: 

1672 parameters = self.fileDescriptor.parameters 

1673 

1674 if parameters is None: 

1675 return {}, {} 

1676 

1677 if self.unsupportedParameters is None: 

1678 # Support none of the parameters 

1679 return {}, parameters.copy() 

1680 

1681 # Start by assuming all are supported 

1682 supported = parameters.copy() 

1683 unsupported = {} 

1684 

1685 # And remove any we know are not supported 

1686 for p in set(supported): 

1687 if p in self.unsupportedParameters: 

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

1689 

1690 return supported, unsupported 

1691 

1692 # Support classic V1 interface. 

1693 makeUpdatedLocation = make_updated_location 

1694 validateExtension = validate_extension 

1695 segregateParameters = segregate_parameters 

1696 predictPath = predict_path 

1697 

1698 # Compatibility with V2 properties. 

1699 @property 

1700 def write_parameters(self) -> Mapping[str, Any]: 

1701 return self.writeParameters 

1702 

1703 @property 

1704 def write_recipes(self) -> Mapping[str, Any]: 

1705 return self.writeRecipes 

1706 

1707 @property 

1708 def file_descriptor(self) -> FileDescriptor: 

1709 return self.fileDescriptor 

1710 

1711 @property 

1712 def data_id(self) -> DataCoordinate: 

1713 return self.dataId 

1714 

1715 

1716class FormatterFactory: 

1717 """Factory for `Formatter` instances.""" 

1718 

1719 defaultKey = LookupKey("default") 

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

1721 

1722 writeRecipesKey = LookupKey("write_recipes") 

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

1724 

1725 def __init__(self) -> None: 

1726 self._mappingFactory = MappingFactory(Formatter) 

1727 

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

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

1730 

1731 Parameters 

1732 ---------- 

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

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

1735 formatter is present. 

1736 

1737 Returns 

1738 ------- 

1739 in : `bool` 

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

1741 """ 

1742 return key in self._mappingFactory 

1743 

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

1745 """Bulk register formatters from a config. 

1746 

1747 Parameters 

1748 ---------- 

1749 config : `Config` 

1750 ``formatters`` section of a configuration. 

1751 universe : `DimensionUniverse`, optional 

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

1753 in lookup keys. 

1754 

1755 Notes 

1756 ----- 

1757 The configuration can include one level of hierarchy where an 

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

1759 template specifications. This is represented in YAML using a 

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

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

1762 name in the data ID. 

1763 

1764 The config is parsed using the function 

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

1766 

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

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

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

1770 the dict case the following keys are supported: 

1771 

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

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

1774 ``write_parameters`` Formatter constructor to seed it. 

1775 These parameters are validated at instance creation and not at 

1776 configuration. 

1777 

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

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

1780 default write parameters that should be used whenever an instance 

1781 of that class is constructed. 

1782 

1783 .. code-block:: yaml 

1784 

1785 formatters: 

1786 default: 

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

1788 max: 10 

1789 min: 2 

1790 comment: Default comment 

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

1792 coadd: 

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

1794 parameters: 

1795 max: 5 

1796 

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

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

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

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

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

1802 ``max``. 

1803 

1804 Formatter configuration can also include a special section describing 

1805 collections of write parameters that can be accessed through a 

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

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

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

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

1810 

1811 .. code-block:: yaml 

1812 

1813 formatters: 

1814 write_recipes: 

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

1816 lossless: 

1817 ... 

1818 noCompression: 

1819 ... 

1820 

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

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

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

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

1825 will not attempt to filter by looking at ``write_parameters`` in 

1826 advance. See the specific formatter documentation for details on 

1827 acceptable recipe options. 

1828 """ 

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

1830 

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

1832 

1833 # Extract any default parameter settings 

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

1835 if not isinstance(defaultParameters, Mapping): 

1836 raise RuntimeError( 

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

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

1839 ) 

1840 

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

1842 # Formatter class name. 

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

1844 if isinstance(writeRecipes, str): 

1845 raise RuntimeError( 

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

1847 ) 

1848 

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

1850 # default is handled in a special way 

1851 if key == self.defaultKey: 

1852 continue 

1853 if key == self.writeRecipesKey: 

1854 continue 

1855 

1856 # Can be a str or a dict. 

1857 specificWriteParameters = {} 

1858 if isinstance(f, str): 

1859 formatter = f 

1860 elif isinstance(f, Mapping): 

1861 all_keys = set(f) 

1862 unexpected_keys = all_keys - allowed_keys 

1863 if unexpected_keys: 

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

1865 if "formatter" not in f: 

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

1867 formatter = f["formatter"] 

1868 if "parameters" in f: 

1869 specificWriteParameters = f["parameters"] 

1870 else: 

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

1872 

1873 # Apply any default parameters for this formatter 

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

1875 writeParameters.update(specificWriteParameters) 

1876 

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

1878 if writeParameters: 

1879 kwargs["write_parameters"] = writeParameters 

1880 

1881 if formatter in writeRecipes: 

1882 kwargs["write_recipes"] = writeRecipes[formatter] 

1883 

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

1885 

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

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

1888 

1889 Returns 

1890 ------- 

1891 keys : `set` of `LookupKey` 

1892 The keys available for matching in the registry. 

1893 """ 

1894 return self._mappingFactory.getLookupKeys() 

1895 

1896 def getFormatterClassWithMatch( 

1897 self, entity: Entity 

1898 ) -> tuple[LookupKey, type[Formatter | FormatterV2], dict[str, Any]]: 

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

1900 

1901 Parameters 

1902 ---------- 

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

1904 Entity to use to determine the formatter to return. 

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

1906 or `DatasetType` instance is provided. Supports instrument 

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

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

1909 

1910 Returns 

1911 ------- 

1912 matchKey : `LookupKey` 

1913 The key that resulted in the successful match. 

1914 formatter : `type` 

1915 The class of the registered formatter. 

1916 formatter_kwargs : `dict` 

1917 Keyword arguments that are associated with this formatter entry. 

1918 """ 

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

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

1921 log.debug( 

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

1923 get_full_type_name(formatter), 

1924 matchKey, 

1925 entity, 

1926 ) 

1927 

1928 return matchKey, formatter, formatter_kwargs 

1929 

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

1931 """Get the matching formatter class. 

1932 

1933 Parameters 

1934 ---------- 

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

1936 Entity to use to determine the formatter to return. 

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

1938 or `DatasetType` instance is provided. Supports instrument 

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

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

1941 

1942 Returns 

1943 ------- 

1944 formatter : `type` 

1945 The class of the registered formatter. 

1946 """ 

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

1948 return formatter 

1949 

1950 def getFormatterWithMatch( 

1951 self, entity: Entity, *args: Any, **kwargs: Any 

1952 ) -> tuple[LookupKey, Formatter | FormatterV2]: 

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

1954 

1955 Parameters 

1956 ---------- 

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

1958 Entity to use to determine the formatter to return. 

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

1960 or `DatasetType` instance is provided. Supports instrument 

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

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

1963 *args : `tuple` 

1964 Positional arguments to use pass to the object constructor. 

1965 **kwargs 

1966 Keyword arguments to pass to object constructor. 

1967 

1968 Returns 

1969 ------- 

1970 matchKey : `LookupKey` 

1971 The key that resulted in the successful match. 

1972 formatter : `Formatter` 

1973 An instance of the registered formatter. 

1974 """ 

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

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

1977 log.debug( 

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

1979 get_full_type_name(formatter), 

1980 matchKey, 

1981 entity, 

1982 ) 

1983 

1984 return matchKey, formatter 

1985 

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

1987 """Get a new formatter instance. 

1988 

1989 Parameters 

1990 ---------- 

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

1992 Entity to use to determine the formatter to return. 

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

1994 or `DatasetType` instance is provided. Supports instrument 

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

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

1997 *args : `tuple` 

1998 Positional arguments to use pass to the object constructor. 

1999 **kwargs 

2000 Keyword arguments to pass to object constructor. 

2001 

2002 Returns 

2003 ------- 

2004 formatter : `Formatter` 

2005 An instance of the registered formatter. 

2006 """ 

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

2008 return formatter 

2009 

2010 def registerFormatter( 

2011 self, 

2012 type_: LookupKey | str | StorageClass | DatasetType, 

2013 formatter: str, 

2014 *, 

2015 overwrite: bool = False, 

2016 **kwargs: Any, 

2017 ) -> None: 

2018 """Register a `Formatter`. 

2019 

2020 Parameters 

2021 ---------- 

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

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

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

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

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

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

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

2029 overwrite : `bool`, optional 

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

2031 Default is `False`. 

2032 **kwargs 

2033 Keyword arguments to always pass to object constructor when 

2034 retrieved. 

2035 

2036 Raises 

2037 ------ 

2038 ValueError 

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

2040 ``overwrite`` is `False`. 

2041 """ 

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

2043 

2044 

2045class FormatterV1inV2(FormatterV2): 

2046 """An implementation of a V2 formatter that provides a compatibility 

2047 interface for V1 formatters. 

2048 

2049 Parameters 

2050 ---------- 

2051 file_descriptor : `FileDescriptor`, optional 

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

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

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

2055 ref : `DatasetRef` 

2056 The dataset associated with this formatter. Should not be a component 

2057 dataset ref. 

2058 formatter : `Formatter` 

2059 A version 1 `Formatter` instance. The V2 formatter layer forwards calls 

2060 to this formatter. 

2061 write_parameters : `dict`, optional 

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

2063 the dataset is serialized. 

2064 write_recipes : `dict`, optional 

2065 Detailed write Recipes indexed by recipe name. 

2066 **kwargs 

2067 Additional arguments that will be ignored but allow for 

2068 `Formatter` V1 parameters to be given. 

2069 """ 

2070 

2071 can_read_from_local_file = True 

2072 """This formatter can read from a local file.""" 

2073 

2074 def __init__( 

2075 self, 

2076 file_descriptor: FileDescriptor, 

2077 *, 

2078 ref: DatasetRef, 

2079 formatter: Formatter, 

2080 write_parameters: Mapping[str, Any] | None = None, 

2081 write_recipes: Mapping[str, Any] | None = None, 

2082 # Compatibility parameters. Unused in v2. 

2083 **kwargs: Any, 

2084 ): 

2085 if not isinstance(formatter, Formatter): 

2086 raise TypeError(f"Formatter parameter was not a V1 formatter (was {type(formatter)})") 

2087 

2088 # Replace the class property with instance values from this 

2089 # V1 formatter so that the V2 __init__ will be able to validate. 

2090 self.supported_write_parameters = formatter.supportedWriteParameters # type: ignore 

2091 self._formatter = formatter 

2092 

2093 super().__init__( 

2094 file_descriptor, ref=ref, write_parameters=write_parameters, write_recipes=write_recipes 

2095 ) 

2096 

2097 def get_write_extension(self) -> str: 

2098 ext = self._formatter.extension 

2099 return ext if ext is not None else "" 

2100 

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

2102 return self._formatter.segregate_parameters(parameters) 

2103 

2104 def validate_write_recipes( # type: ignore 

2105 self, 

2106 recipes: Mapping[str, Any] | None, 

2107 ) -> Mapping[str, Any] | None: 

2108 # This should be a class method but a class method can not work 

2109 # for a dynamic shim. Luckily the shim is only used as an instance. 

2110 return self._formatter.validate_write_recipes(recipes) 

2111 

2112 def read_from_local_file(self, path: str, component: str | None = None, expected_size: int = -1) -> Any: 

2113 # Need to temporarily override the location since the V1 formatter 

2114 # will not know anything about this local file. 

2115 

2116 # V2 does not have a fromBytes equivalent. 

2117 if self._formatter.can_read_bytes(): 

2118 with open(path, "rb") as fd: 

2119 serialized_dataset = fd.read() 

2120 return self._formatter.fromBytes(serialized_dataset, component=component) 

2121 

2122 location = Location(None, path) 

2123 with self._formatter._updateLocation(location): 

2124 try: 

2125 result = self._formatter.read(component=component) 

2126 except NotImplementedError: 

2127 # V1 raises NotImplementedError but V2 is expecting something 

2128 # slightly different. 

2129 return NotImplemented 

2130 return result 

2131 

2132 def to_bytes(self, in_memory_dataset: Any) -> bytes: 

2133 try: 

2134 return self._formatter.toBytes(in_memory_dataset) 

2135 except NotImplementedError as e: 

2136 # V1 raises NotImplementedError but V2 is expecting something 

2137 # slightly different. 

2138 raise FormatterNotImplementedError(str(e)) from e 

2139 

2140 def write_local_file(self, in_memory_dataset: Any, uri: ResourcePath) -> None: 

2141 with self._formatter._updateLocation(Location(None, uri)): 

2142 try: 

2143 self._formatter.write(in_memory_dataset) 

2144 except NotImplementedError as e: 

2145 # V1 raises NotImplementedError but V2 is expecting something 

2146 # slightly different. 

2147 raise FormatterNotImplementedError(str(e)) from e 

2148 

2149 

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

2151FormatterParameter: TypeAlias = str | type[Formatter] | Formatter | FormatterV2 | type[FormatterV2]