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

531 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-24 01:10 -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 Returns 

914 ------- 

915 None 

916 

917 Raises 

918 ------ 

919 FormatterNotImplementedError 

920 Raised if the formatter subclass has not implemented 

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

922 Exception 

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

924 

925 Notes 

926 ----- 

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

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

929 """ 

930 # Ensure we are using the correct file extension. 

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

932 

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

934 # a different object. 

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

936 

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

938 if not written: 

939 self.write_locally_then_move(in_memory_dataset, uri, cache_manager) 

940 

941 def write_direct( 

942 self, 

943 in_memory_dataset: Any, 

944 uri: ResourcePath, 

945 cache_manager: AbstractDatastoreCacheManager | None = None, 

946 ) -> bool: 

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

948 

949 Parameters 

950 ---------- 

951 in_memory_dataset : `object` 

952 The Dataset to serialize. 

953 uri : `lsst.resources.ResourcePath` 

954 URI to use when writing the serialized dataset. 

955 cache_manager : `AbstractDatastoreCacheManager` 

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

957 file. 

958 

959 Returns 

960 ------- 

961 written : `bool` 

962 Flag to indicate whether the direct write did happen. 

963 

964 Raises 

965 ------ 

966 Exception 

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

968 was not `FormatterNotImplementedError`. 

969 

970 Notes 

971 ----- 

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

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

974 directly. 

975 

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

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

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

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

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

981 subclassed and the atomic write re-implemented. 

982 """ 

983 cache_manager = self._ensure_cache(cache_manager) 

984 

985 # For remote URIs some datasets can be serialized directly 

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

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

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

989 # datastore is bypassed. 

990 data_written = False 

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

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

993 try: 

994 serialized_dataset = self.to_bytes(in_memory_dataset) 

995 except FormatterNotImplementedError: 

996 # Fallback to the file writing option. 

997 pass 

998 except Exception as e: 

999 e.add_note( 

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

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

1002 ) 

1003 raise 

1004 else: 

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

1006 uri.write(serialized_dataset, overwrite=True) 

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

1008 data_written = True 

1009 return data_written 

1010 

1011 def write_locally_then_move( 

1012 self, 

1013 in_memory_dataset: Any, 

1014 uri: ResourcePath, 

1015 cache_manager: AbstractDatastoreCacheManager | None = None, 

1016 ) -> None: 

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

1018 

1019 Parameters 

1020 ---------- 

1021 in_memory_dataset : `object` 

1022 The Dataset to serialize. 

1023 uri : `lsst.resources.ResourcePath` 

1024 URI to use when writing the serialized dataset. 

1025 cache_manager : `AbstractDatastoreCacheManager` 

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

1027 file. 

1028 

1029 Raises 

1030 ------ 

1031 FormatterNotImplementedError 

1032 Raised if the formatter subclass has not implemented 

1033 `write_local_file`. 

1034 Exception 

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

1036 """ 

1037 cache_manager = self._ensure_cache(cache_manager) 

1038 

1039 with TemporaryForIngest.make_path(uri) as temporary_uri: 

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

1041 # location and that needs us to overwrite internals 

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

1043 

1044 # Assumes that if write_local_file is not subclassed that 

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

1046 try: 

1047 self.write_local_file(in_memory_dataset, temporary_uri) 

1048 except Exception as e: 

1049 e.add_note( 

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

1051 f" {get_full_type_name(in_memory_dataset)} to " 

1052 f"temporary location {temporary_uri}." 

1053 ) 

1054 raise 

1055 

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

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

1058 # file to be cached afterwards. 

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

1060 

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

1062 

1063 if transfer == "copy": 

1064 # Cache if required 

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

1066 

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

1068 

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

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

1071 

1072 Parameters 

1073 ---------- 

1074 in_memory_dataset : `object` 

1075 The Python object to serialize. 

1076 uri : `ResourcePath` 

1077 The URI to use when writing the file. 

1078 

1079 Notes 

1080 ----- 

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

1082 write these bytes to the file. 

1083 

1084 Raises 

1085 ------ 

1086 FormatterNotImplementedError 

1087 Raised if the formatter subclass has not implemented this method 

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

1089 """ 

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

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

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

1093 

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

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

1096 

1097 Parameters 

1098 ---------- 

1099 in_memory_dataset : `object` 

1100 The Python object to serialize. 

1101 

1102 Returns 

1103 ------- 

1104 serialized_dataset : `bytes` 

1105 Bytes representing the serialized dataset. 

1106 

1107 Raises 

1108 ------ 

1109 FormatterNotImplementedError 

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

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

1112 """ 

1113 raise FormatterNotImplementedError( 

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

1115 ) 

1116 

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

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

1119 

1120 Parameters 

1121 ---------- 

1122 location : `Location` 

1123 The location to update. 

1124 

1125 Returns 

1126 ------- 

1127 updated : `Location` 

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

1129 """ 

1130 location = location.clone() 

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

1132 location.updateExtension(self.get_write_extension()) 

1133 return location 

1134 

1135 @classmethod 

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

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

1138 

1139 Parameters 

1140 ---------- 

1141 location : `Location` 

1142 Location from which to extract a file extension. 

1143 

1144 Returns 

1145 ------- 

1146 None 

1147 

1148 Raises 

1149 ------ 

1150 ValueError 

1151 Raised if the formatter does not understand this extension. 

1152 """ 

1153 supported = set(cls.supported_extensions) 

1154 default = cls.default_extension # type: ignore 

1155 

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

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

1158 # the supported extensions class property is complete. 

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

1160 supported.add(default) 

1161 

1162 # Get the file name from the uri 

1163 file = location.uri.basename() 

1164 

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

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

1167 # its extension and then doing a set comparison 

1168 for ext in supported: 

1169 if file.endswith(ext): 

1170 return 

1171 

1172 raise ValueError( 

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

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

1175 ) 

1176 

1177 def predict_path(self) -> str: 

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

1179 

1180 Does not write any data file. 

1181 

1182 Uses the `FileDescriptor` associated with the instance. 

1183 

1184 Returns 

1185 ------- 

1186 path : `str` 

1187 Path within datastore that would be associated with the location 

1188 stored in this `Formatter`. 

1189 """ 

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

1191 return updated.pathInStore.path 

1192 

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

1194 """Segregate the supplied parameters. 

1195 

1196 This splits the parameters into those understood by the 

1197 formatter and those not understood by the formatter. 

1198 

1199 Any unsupported parameters are assumed to be usable by associated 

1200 assemblers. 

1201 

1202 Parameters 

1203 ---------- 

1204 parameters : `dict`, optional 

1205 Parameters with values that have been supplied by the caller 

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

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

1208 

1209 Returns 

1210 ------- 

1211 supported : `dict` 

1212 Those parameters supported by this formatter. 

1213 unsupported : `dict` 

1214 Those parameters not supported by this formatter. 

1215 """ 

1216 if parameters is None: 

1217 parameters = self.file_descriptor.parameters 

1218 

1219 if parameters is None: 

1220 return {}, {} 

1221 

1222 if self.unsupported_parameters is None: 

1223 # Support none of the parameters 

1224 return {}, parameters.copy() 

1225 

1226 # Start by assuming all are supported 

1227 supported = parameters.copy() 

1228 unsupported = {} 

1229 

1230 # And remove any we know are not supported 

1231 for p in set(supported): 

1232 if p in self.unsupported_parameters: 

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

1234 

1235 return supported, unsupported 

1236 

1237 

1238class Formatter(metaclass=ABCMeta): 

1239 """Interface for reading and writing Datasets. 

1240 

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

1242 

1243 Parameters 

1244 ---------- 

1245 fileDescriptor : `FileDescriptor`, optional 

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

1247 classes and parameter information. 

1248 dataId : `DataCoordinate` 

1249 Data ID associated with this formatter. 

1250 writeParameters : `dict`, optional 

1251 Parameters to control how the dataset is serialized. 

1252 writeRecipes : `dict`, optional 

1253 Detailed write recipes indexed by recipe name. 

1254 **kwargs 

1255 Additional parameters that can allow parameters 

1256 from `FormatterV2` to be provided. 

1257 

1258 Notes 

1259 ----- 

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

1261 signature. 

1262 """ 

1263 

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

1265 # this property for compatibility with v2. 

1266 extension: str | None = None 

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

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

1269 

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

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

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

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

1274 class definition. (`frozenset`). 

1275 """ 

1276 

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

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

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

1280 supported.""" 

1281 

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

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

1284 

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

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

1287 the list of supported extensions.""" 

1288 

1289 def __init__( 

1290 self, 

1291 fileDescriptor: FileDescriptor, 

1292 *, 

1293 dataId: DataCoordinate | None = None, 

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

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

1296 # Allow FormatterV2 parameters to be dropped. 

1297 **kwargs: Any, 

1298 ): 

1299 if not isinstance(fileDescriptor, FileDescriptor): 

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

1301 self._fileDescriptor = fileDescriptor 

1302 

1303 if dataId is None: 

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

1305 if not isinstance(dataId, DataCoordinate): 

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

1307 self._dataId = dataId 

1308 

1309 # V2 compatibility. 

1310 if writeParameters is None: 

1311 writeParameters = kwargs.get("write_parameters") 

1312 if writeRecipes is None: 

1313 writeRecipes = kwargs.get("write_recipes") 

1314 

1315 # Check that the write parameters are allowed 

1316 if writeParameters: 

1317 if self.supportedWriteParameters is None: 

1318 raise ValueError( 

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

1320 ) 

1321 else: 

1322 given = set(writeParameters) 

1323 unknown = given - self.supportedWriteParameters 

1324 if unknown: 

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

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

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

1328 

1329 self._writeParameters = writeParameters 

1330 self._writeRecipes = self.validate_write_recipes(writeRecipes) 

1331 

1332 def __str__(self) -> str: 

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

1334 

1335 def __repr__(self) -> str: 

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

1337 

1338 @property 

1339 def fileDescriptor(self) -> FileDescriptor: 

1340 """File descriptor associated with this formatter 

1341 (`FileDescriptor`). 

1342 """ 

1343 return self._fileDescriptor 

1344 

1345 @property 

1346 def dataId(self) -> DataCoordinate: 

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

1348 return self._dataId 

1349 

1350 @property 

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

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

1353 if self._writeParameters is not None: 

1354 return self._writeParameters 

1355 return {} 

1356 

1357 @property 

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

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

1360 if self._writeRecipes is not None: 

1361 return self._writeRecipes 

1362 return {} 

1363 

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

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

1366 storage class directly. 

1367 

1368 Parameters 

1369 ---------- 

1370 in_memory_dataset : `object` 

1371 The dataset that is to be written. 

1372 

1373 Returns 

1374 ------- 

1375 accepts : `bool` 

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

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

1378 will attempt to convert before writing. 

1379 

1380 Notes 

1381 ----- 

1382 The base class checks that the given python type matches 

1383 the python type specified for this formatter when 

1384 constructed. 

1385 """ 

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

1387 

1388 @classmethod 

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

1390 """Validate supplied recipes for this formatter. 

1391 

1392 The recipes are supplemented with default values where appropriate. 

1393 

1394 Parameters 

1395 ---------- 

1396 recipes : `dict` 

1397 Recipes to validate. 

1398 

1399 Returns 

1400 ------- 

1401 validated : `dict` 

1402 Validated recipes. 

1403 

1404 Raises 

1405 ------ 

1406 RuntimeError 

1407 Raised if validation fails. The default implementation raises 

1408 if any recipes are given. 

1409 """ 

1410 if recipes: 

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

1412 return recipes 

1413 

1414 @classmethod 

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

1416 return cls.validateWriteRecipes(recipes) 

1417 

1418 @classmethod 

1419 def name(cls) -> str: 

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

1421 

1422 Returns 

1423 ------- 

1424 name : `str` 

1425 Fully-qualified name of formatter class. 

1426 """ 

1427 return get_full_type_name(cls) 

1428 

1429 @abstractmethod 

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

1431 """Read a Dataset. 

1432 

1433 Parameters 

1434 ---------- 

1435 component : `str`, optional 

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

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

1438 file. 

1439 

1440 Returns 

1441 ------- 

1442 inMemoryDataset : `object` 

1443 The requested Dataset. 

1444 """ 

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

1446 

1447 @abstractmethod 

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

1449 """Write a Dataset. 

1450 

1451 Parameters 

1452 ---------- 

1453 inMemoryDataset : `object` 

1454 The Dataset to store. 

1455 """ 

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

1457 

1458 @classmethod 

1459 def can_read_bytes(cls) -> bool: 

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

1461 

1462 Returns 

1463 ------- 

1464 can : `bool` 

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

1466 """ 

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

1468 # and see what happens 

1469 try: 

1470 # We know the arguments are incompatible 

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

1472 except FormatterNotImplementedError: 

1473 return False 

1474 except Exception: 

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

1476 pass 

1477 return True 

1478 

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

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

1481 

1482 Parameters 

1483 ---------- 

1484 serializedDataset : `bytes` 

1485 Bytes object to unserialize. 

1486 component : `str`, optional 

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

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

1489 file. 

1490 

1491 Returns 

1492 ------- 

1493 inMemoryDataset : `object` 

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

1495 is controlled by the specific formatter. 

1496 """ 

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

1498 

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

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

1501 

1502 Parameters 

1503 ---------- 

1504 inMemoryDataset : `object` 

1505 The Python object to serialize. 

1506 

1507 Returns 

1508 ------- 

1509 serializedDataset : `bytes` 

1510 Bytes representing the serialized dataset. 

1511 """ 

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

1513 

1514 @contextlib.contextmanager 

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

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

1517 

1518 Parameters 

1519 ---------- 

1520 location : `Location` 

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

1522 formatter will not change but it will still return 

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

1524 path where the location may not need to be updated 

1525 but the with block is still convenient. 

1526 

1527 Yields 

1528 ------ 

1529 old : `Location` 

1530 The old location that will be restored. 

1531 

1532 Notes 

1533 ----- 

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

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

1536 manager to restore the location when the temporary is no 

1537 longer required. 

1538 """ 

1539 old = self._fileDescriptor.location 

1540 try: 

1541 if location is not None: 

1542 self._fileDescriptor.location = location 

1543 yield old 

1544 finally: 

1545 if location is not None: 

1546 self._fileDescriptor.location = old 

1547 

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

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

1550 

1551 Parameters 

1552 ---------- 

1553 location : `Location` 

1554 The location to update. 

1555 

1556 Returns 

1557 ------- 

1558 updated : `Location` 

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

1560 

1561 Raises 

1562 ------ 

1563 NotImplementedError 

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

1565 this formatter. 

1566 

1567 Notes 

1568 ----- 

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

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

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

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

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

1574 defined in the base class. 

1575 """ 

1576 location = location.clone() 

1577 try: 

1578 # We are deliberately allowing extension to be undefined by 

1579 # default in the base class and mypy complains. 

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

1581 except AttributeError: 

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

1583 return location 

1584 

1585 @classmethod 

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

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

1588 

1589 Parameters 

1590 ---------- 

1591 location : `Location` 

1592 Location from which to extract a file extension. 

1593 

1594 Returns 

1595 ------- 

1596 None 

1597 

1598 Raises 

1599 ------ 

1600 NotImplementedError 

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

1602 formatter. 

1603 ValueError 

1604 Raised if the formatter does not understand this extension. 

1605 

1606 Notes 

1607 ----- 

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

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

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

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

1612 extensions will be examined. 

1613 """ 

1614 supported = set(cls.supportedExtensions) 

1615 

1616 try: 

1617 # We are deliberately allowing extension to be undefined by 

1618 # default in the base class and mypy complains. 

1619 default = cls.extension # type: ignore 

1620 except AttributeError: 

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

1622 

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

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

1625 # the supported extensions class property is complete. 

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

1627 supported.add(default) 

1628 

1629 # Get the file name from the uri 

1630 file = location.uri.basename() 

1631 

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

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

1634 # its extension and then doing a set comparison 

1635 for ext in supported: 

1636 if file.endswith(ext): 

1637 return 

1638 

1639 raise ValueError( 

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

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

1642 ) 

1643 

1644 def predict_path(self) -> str: 

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

1646 

1647 Does not write any data file. 

1648 

1649 Uses the `FileDescriptor` associated with the instance. 

1650 

1651 Returns 

1652 ------- 

1653 path : `str` 

1654 Path within datastore that would be associated with the location 

1655 stored in this `Formatter`. 

1656 """ 

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

1658 return updated.pathInStore.path 

1659 

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

1661 """Segregate the supplied parameters. 

1662 

1663 This splits the parameters into those understood by the 

1664 formatter and those not understood by the formatter. 

1665 

1666 Any unsupported parameters are assumed to be usable by associated 

1667 assemblers. 

1668 

1669 Parameters 

1670 ---------- 

1671 parameters : `dict`, optional 

1672 Parameters with values that have been supplied by the caller 

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

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

1675 

1676 Returns 

1677 ------- 

1678 supported : `dict` 

1679 Those parameters supported by this formatter. 

1680 unsupported : `dict` 

1681 Those parameters not supported by this formatter. 

1682 """ 

1683 if parameters is None: 

1684 parameters = self.fileDescriptor.parameters 

1685 

1686 if parameters is None: 

1687 return {}, {} 

1688 

1689 if self.unsupportedParameters is None: 

1690 # Support none of the parameters 

1691 return {}, parameters.copy() 

1692 

1693 # Start by assuming all are supported 

1694 supported = parameters.copy() 

1695 unsupported = {} 

1696 

1697 # And remove any we know are not supported 

1698 for p in set(supported): 

1699 if p in self.unsupportedParameters: 

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

1701 

1702 return supported, unsupported 

1703 

1704 # Support classic V1 interface. 

1705 makeUpdatedLocation = make_updated_location 

1706 validateExtension = validate_extension 

1707 segregateParameters = segregate_parameters 

1708 predictPath = predict_path 

1709 

1710 # Compatibility with V2 properties. 

1711 @property 

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

1713 return self.writeParameters 

1714 

1715 @property 

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

1717 return self.writeRecipes 

1718 

1719 @property 

1720 def file_descriptor(self) -> FileDescriptor: 

1721 return self.fileDescriptor 

1722 

1723 @property 

1724 def data_id(self) -> DataCoordinate: 

1725 return self.dataId 

1726 

1727 

1728class FormatterFactory: 

1729 """Factory for `Formatter` instances.""" 

1730 

1731 defaultKey = LookupKey("default") 

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

1733 

1734 writeRecipesKey = LookupKey("write_recipes") 

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

1736 

1737 def __init__(self) -> None: 

1738 self._mappingFactory = MappingFactory(Formatter) 

1739 

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

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

1742 

1743 Parameters 

1744 ---------- 

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

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

1747 formatter is present. 

1748 

1749 Returns 

1750 ------- 

1751 in : `bool` 

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

1753 """ 

1754 return key in self._mappingFactory 

1755 

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

1757 """Bulk register formatters from a config. 

1758 

1759 Parameters 

1760 ---------- 

1761 config : `Config` 

1762 ``formatters`` section of a configuration. 

1763 universe : `DimensionUniverse`, optional 

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

1765 in lookup keys. 

1766 

1767 Notes 

1768 ----- 

1769 The configuration can include one level of hierarchy where an 

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

1771 template specifications. This is represented in YAML using a 

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

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

1774 name in the data ID. 

1775 

1776 The config is parsed using the function 

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

1778 

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

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

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

1782 the dict case the following keys are supported: 

1783 

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

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

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

1787 These parameters are validated at instance creation and not at 

1788 configuration. 

1789 

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

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

1792 default write parameters that should be used whenever an instance 

1793 of that class is constructed. 

1794 

1795 .. code-block:: yaml 

1796 

1797 formatters: 

1798 default: 

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

1800 max: 10 

1801 min: 2 

1802 comment: Default comment 

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

1804 coadd: 

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

1806 parameters: 

1807 max: 5 

1808 

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

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

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

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

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

1814 ``max``. 

1815 

1816 Formatter configuration can also include a special section describing 

1817 collections of write parameters that can be accessed through a 

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

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

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

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

1822 

1823 .. code-block:: yaml 

1824 

1825 formatters: 

1826 write_recipes: 

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

1828 lossless: 

1829 ... 

1830 noCompression: 

1831 ... 

1832 

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

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

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

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

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

1838 advance. See the specific formatter documentation for details on 

1839 acceptable recipe options. 

1840 """ 

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

1842 

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

1844 

1845 # Extract any default parameter settings 

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

1847 if not isinstance(defaultParameters, Mapping): 

1848 raise RuntimeError( 

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

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

1851 ) 

1852 

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

1854 # Formatter class name. 

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

1856 if isinstance(writeRecipes, str): 

1857 raise RuntimeError( 

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

1859 ) 

1860 

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

1862 # default is handled in a special way 

1863 if key == self.defaultKey: 

1864 continue 

1865 if key == self.writeRecipesKey: 

1866 continue 

1867 

1868 # Can be a str or a dict. 

1869 specificWriteParameters = {} 

1870 if isinstance(f, str): 

1871 formatter = f 

1872 elif isinstance(f, Mapping): 

1873 all_keys = set(f) 

1874 unexpected_keys = all_keys - allowed_keys 

1875 if unexpected_keys: 

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

1877 if "formatter" not in f: 

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

1879 formatter = f["formatter"] 

1880 if "parameters" in f: 

1881 specificWriteParameters = f["parameters"] 

1882 else: 

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

1884 

1885 # Apply any default parameters for this formatter 

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

1887 writeParameters.update(specificWriteParameters) 

1888 

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

1890 if writeParameters: 

1891 kwargs["write_parameters"] = writeParameters 

1892 

1893 if formatter in writeRecipes: 

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

1895 

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

1897 

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

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

1900 

1901 Returns 

1902 ------- 

1903 keys : `set` of `LookupKey` 

1904 The keys available for matching in the registry. 

1905 """ 

1906 return self._mappingFactory.getLookupKeys() 

1907 

1908 def getFormatterClassWithMatch( 

1909 self, entity: Entity 

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

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

1912 

1913 Parameters 

1914 ---------- 

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

1916 Entity to use to determine the formatter to return. 

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

1918 or `DatasetType` instance is provided. Supports instrument 

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

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

1921 

1922 Returns 

1923 ------- 

1924 matchKey : `LookupKey` 

1925 The key that resulted in the successful match. 

1926 formatter : `type` 

1927 The class of the registered formatter. 

1928 formatter_kwargs : `dict` 

1929 Keyword arguments that are associated with this formatter entry. 

1930 """ 

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

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

1933 log.debug( 

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

1935 get_full_type_name(formatter), 

1936 matchKey, 

1937 entity, 

1938 ) 

1939 

1940 return matchKey, formatter, formatter_kwargs 

1941 

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

1943 """Get the matching formatter class. 

1944 

1945 Parameters 

1946 ---------- 

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

1948 Entity to use to determine the formatter to return. 

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

1950 or `DatasetType` instance is provided. Supports instrument 

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

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

1953 

1954 Returns 

1955 ------- 

1956 formatter : `type` 

1957 The class of the registered formatter. 

1958 """ 

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

1960 return formatter 

1961 

1962 def getFormatterWithMatch( 

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

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

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

1966 

1967 Parameters 

1968 ---------- 

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

1970 Entity to use to determine the formatter to return. 

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

1972 or `DatasetType` instance is provided. Supports instrument 

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

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

1975 *args : `tuple` 

1976 Positional arguments to use pass to the object constructor. 

1977 **kwargs 

1978 Keyword arguments to pass to object constructor. 

1979 

1980 Returns 

1981 ------- 

1982 matchKey : `LookupKey` 

1983 The key that resulted in the successful match. 

1984 formatter : `Formatter` 

1985 An instance of the registered formatter. 

1986 """ 

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

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

1989 log.debug( 

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

1991 get_full_type_name(formatter), 

1992 matchKey, 

1993 entity, 

1994 ) 

1995 

1996 return matchKey, formatter 

1997 

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

1999 """Get a new formatter instance. 

2000 

2001 Parameters 

2002 ---------- 

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

2004 Entity to use to determine the formatter to return. 

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

2006 or `DatasetType` instance is provided. Supports instrument 

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

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

2009 *args : `tuple` 

2010 Positional arguments to use pass to the object constructor. 

2011 **kwargs 

2012 Keyword arguments to pass to object constructor. 

2013 

2014 Returns 

2015 ------- 

2016 formatter : `Formatter` 

2017 An instance of the registered formatter. 

2018 """ 

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

2020 return formatter 

2021 

2022 def registerFormatter( 

2023 self, 

2024 type_: LookupKey | str | StorageClass | DatasetType, 

2025 formatter: str, 

2026 *, 

2027 overwrite: bool = False, 

2028 **kwargs: Any, 

2029 ) -> None: 

2030 """Register a `Formatter`. 

2031 

2032 Parameters 

2033 ---------- 

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

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

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

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

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

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

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

2041 overwrite : `bool`, optional 

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

2043 Default is `False`. 

2044 **kwargs 

2045 Keyword arguments to always pass to object constructor when 

2046 retrieved. 

2047 

2048 Raises 

2049 ------ 

2050 ValueError 

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

2052 ``overwrite`` is `False`. 

2053 """ 

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

2055 

2056 

2057class FormatterV1inV2(FormatterV2): 

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

2059 interface for V1 formatters. 

2060 

2061 Parameters 

2062 ---------- 

2063 file_descriptor : `FileDescriptor`, optional 

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

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

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

2067 ref : `DatasetRef` 

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

2069 dataset ref. 

2070 formatter : `Formatter` 

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

2072 to this formatter. 

2073 write_parameters : `dict`, optional 

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

2075 the dataset is serialized. 

2076 write_recipes : `dict`, optional 

2077 Detailed write Recipes indexed by recipe name. 

2078 **kwargs 

2079 Additional arguments that will be ignored but allow for 

2080 `Formatter` V1 parameters to be given. 

2081 """ 

2082 

2083 can_read_from_local_file = True 

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

2085 

2086 def __init__( 

2087 self, 

2088 file_descriptor: FileDescriptor, 

2089 *, 

2090 ref: DatasetRef, 

2091 formatter: Formatter, 

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

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

2094 # Compatibility parameters. Unused in v2. 

2095 **kwargs: Any, 

2096 ): 

2097 if not isinstance(formatter, Formatter): 

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

2099 

2100 # Replace the class property with instance values from this 

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

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

2103 self._formatter = formatter 

2104 

2105 super().__init__( 

2106 file_descriptor, ref=ref, write_parameters=write_parameters, write_recipes=write_recipes 

2107 ) 

2108 

2109 def get_write_extension(self) -> str: 

2110 ext = self._formatter.extension 

2111 return ext if ext is not None else "" 

2112 

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

2114 return self._formatter.segregate_parameters(parameters) 

2115 

2116 def validate_write_recipes( # type: ignore 

2117 self, 

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

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

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

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

2122 return self._formatter.validate_write_recipes(recipes) 

2123 

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

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

2126 # will not know anything about this local file. 

2127 

2128 # V2 does not have a fromBytes equivalent. 

2129 if self._formatter.can_read_bytes(): 

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

2131 serialized_dataset = fd.read() 

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

2133 

2134 location = Location(None, path) 

2135 with self._formatter._updateLocation(location): 

2136 try: 

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

2138 except NotImplementedError: 

2139 # V1 raises NotImplementedError but V2 is expecting something 

2140 # slightly different. 

2141 return NotImplemented 

2142 return result 

2143 

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

2145 try: 

2146 return self._formatter.toBytes(in_memory_dataset) 

2147 except NotImplementedError as e: 

2148 # V1 raises NotImplementedError but V2 is expecting something 

2149 # slightly different. 

2150 raise FormatterNotImplementedError(str(e)) from e 

2151 

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

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

2154 try: 

2155 self._formatter.write(in_memory_dataset) 

2156 except NotImplementedError as e: 

2157 # V1 raises NotImplementedError but V2 is expecting something 

2158 # slightly different. 

2159 raise FormatterNotImplementedError(str(e)) from e 

2160 

2161 

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

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