Coverage for python/lsst/daf/butler/remote_butler/_remote_butler.py: 31%

275 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 01:26 -0700

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__ = ("RemoteButler",) 

31 

32import logging 

33import uuid 

34from collections.abc import Collection, Iterable, Iterator, Sequence 

35from contextlib import AbstractContextManager, contextmanager 

36from types import EllipsisType 

37from typing import TYPE_CHECKING, Any, TextIO, cast 

38 

39from deprecated.sphinx import deprecated 

40 

41from lsst.daf.butler.datastores.file_datastore.retrieve_artifacts import ( 

42 ArtifactIndexInfo, 

43 ZipIndex, 

44 determine_destination_for_retrieved_artifact, 

45 retrieve_and_zip, 

46 unpack_zips, 

47) 

48from lsst.resources import ResourcePath, ResourcePathExpression 

49 

50from .._butler import Butler, _DeprecatedDefault 

51from .._butler_collections import ButlerCollections 

52from .._butler_metrics import ButlerMetrics 

53from .._dataset_existence import DatasetExistence 

54from .._dataset_ref import DatasetId, DatasetRef 

55from .._dataset_type import DatasetType 

56from .._deferredDatasetHandle import DeferredDatasetHandle 

57from .._exceptions import DatasetNotFoundError 

58from .._query_all_datasets import QueryAllDatasetsParameters 

59from .._storage_class import StorageClass, StorageClassFactory 

60from .._utilities.locked_object import LockedObject 

61from ..datastore import DatasetRefURIs, DatastoreConfig 

62from ..datastore.cache_manager import AbstractDatastoreCacheManager, DatastoreCacheManager 

63from ..dimensions import DataCoordinate, DataIdValue, DimensionConfig, DimensionUniverse, SerializedDataId 

64from ..queries import Query 

65from ..queries.tree import make_column_literal 

66from ..registry import CollectionArgType, NoDefaultCollectionError, Registry, RegistryDefaults 

67from ._collection_args import convert_collection_arg_to_glob_string_list 

68from ._defaults import DefaultsHolder 

69from ._get import convert_http_url_to_resource_path, get_dataset_as_python_object 

70from ._http_connection import RemoteButlerHttpConnection, parse_model, quote_path_variable 

71from ._query_driver import RemoteQueryDriver 

72from ._query_results import convert_dataset_ref_results, read_query_results 

73from ._ref_utils import apply_storage_class_override, normalize_dataset_type_name, simplify_dataId 

74from ._registry import RemoteButlerRegistry 

75from ._remote_butler_collections import RemoteButlerCollections 

76from ._remote_file_transfer_source import RemoteFileTransferSource 

77from .server_models import ( 

78 CollectionList, 

79 FileInfoPayload, 

80 FindDatasetRequestModel, 

81 FindDatasetResponseModel, 

82 GetDatasetTypeResponseModel, 

83 GetFileByDataIdRequestModel, 

84 GetFileResponseModel, 

85 GetUniverseResponseModel, 

86 QueryAllDatasetsRequestModel, 

87) 

88 

89if TYPE_CHECKING: 

90 from .._dataset_provenance import DatasetProvenance 

91 from .._file_dataset import FileDataset 

92 from .._limited_butler import LimitedButler 

93 from .._timespan import Timespan 

94 from ..dimensions import DataId 

95 from ..transfers import RepoExportContext 

96 

97 

98_LOG = logging.getLogger(__name__) 

99 

100 

101class RemoteButler(Butler): # numpydoc ignore=PR02 

102 """A `Butler` that can be used to connect through a remote server. 

103 

104 Parameters 

105 ---------- 

106 options : `ButlerInstanceOptions` 

107 Default values and other settings for the Butler instance. 

108 connection : `RemoteButlerHttpConnection` 

109 Connection to Butler server. 

110 cache : `RemoteButlerCache` 

111 Cache of data shared between multiple RemoteButler instances connected 

112 to the same server. 

113 use_disabled_datastore_cache : `bool`, optional 

114 If `True`, a datastore cache manager will be created with a default 

115 disabled state which can be enabled by the environment. If `False` 

116 a cache manager will be constructed from the default local 

117 configuration, likely caching by default but only specific storage 

118 classes. 

119 

120 Notes 

121 ----- 

122 Instead of using this constructor, most users should use either 

123 `Butler.from_config` or `RemoteButlerFactory`. 

124 """ 

125 

126 _registry_defaults: DefaultsHolder 

127 _connection: RemoteButlerHttpConnection 

128 _cache: RemoteButlerCache 

129 _registry: RemoteButlerRegistry 

130 _datastore_cache_manager: AbstractDatastoreCacheManager | None 

131 _use_disabled_datastore_cache: bool 

132 

133 # This is __new__ instead of __init__ because we have to support 

134 # instantiation via the legacy constructor Butler.__new__(), which 

135 # reads the configuration and selects which subclass to instantiate. The 

136 # interaction between __new__ and __init__ is kind of wacky in Python. If 

137 # we were using __init__ here, __init__ would be called twice (once when 

138 # the RemoteButler instance is constructed inside Butler.from_config(), and 

139 # a second time with the original arguments to Butler() when the instance 

140 # is returned from Butler.__new__() 

141 def __new__( 

142 cls, 

143 *, 

144 connection: RemoteButlerHttpConnection, 

145 defaults: RegistryDefaults, 

146 cache: RemoteButlerCache, 

147 use_disabled_datastore_cache: bool = True, 

148 metrics: ButlerMetrics | None = None, 

149 ) -> RemoteButler: 

150 self = cast(RemoteButler, super().__new__(cls)) 

151 self.storageClasses = StorageClassFactory() 

152 

153 self._connection = connection 

154 self._cache = cache 

155 self._datastore_cache_manager = None 

156 self._use_disabled_datastore_cache = use_disabled_datastore_cache 

157 self._metrics = metrics if metrics is not None else ButlerMetrics() 

158 

159 self._registry_defaults = DefaultsHolder(defaults) 

160 self._registry = RemoteButlerRegistry(self, self._registry_defaults, self._connection) 

161 defaults.finish(self._registry) 

162 

163 return self 

164 

165 def isWriteable(self) -> bool: 

166 # Docstring inherited. 

167 return False 

168 

169 @property 

170 @deprecated( 

171 "Please use 'collections' instead. collection_chains will be removed after v28.", 

172 version="v28", 

173 category=FutureWarning, 

174 ) 

175 def collection_chains(self) -> ButlerCollections: 

176 """Object with methods for modifying collection chains.""" 

177 return self.collections 

178 

179 @property 

180 def collections(self) -> ButlerCollections: 

181 """Object with methods for modifying and querying collections.""" 

182 return RemoteButlerCollections(self._registry_defaults, self._connection) 

183 

184 @property 

185 def dimensions(self) -> DimensionUniverse: 

186 # Docstring inherited. 

187 with self._cache.access() as cache: 

188 if cache.dimensions is not None: 

189 return cache.dimensions 

190 

191 response = self._connection.get("universe") 

192 model = parse_model(response, GetUniverseResponseModel) 

193 

194 config = DimensionConfig.from_simple(model.universe) 

195 universe = DimensionUniverse(config) 

196 with self._cache.access() as cache: 

197 if cache.dimensions is None: 

198 cache.dimensions = universe 

199 return cache.dimensions 

200 

201 @property 

202 def _cache_manager(self) -> AbstractDatastoreCacheManager: 

203 """Cache manager to use when reading files from the butler.""" 

204 # RemoteButler does not get any cache configuration from the server. 

205 # Either create a disabled cache manager which can be enabled via the 

206 # environment, or create a cache manager from the default FileDatastore 

207 # config. This will not work properly if the defaults for 

208 # DatastoreConfig no longer include the cache. 

209 if self._datastore_cache_manager is None: 

210 datastore_config = DatastoreConfig() 

211 if not self._use_disabled_datastore_cache and "cached" in datastore_config: 

212 self._datastore_cache_manager = DatastoreCacheManager( 

213 datastore_config["cached"], universe=self.dimensions 

214 ) 

215 else: 

216 self._datastore_cache_manager = DatastoreCacheManager.create_disabled( 

217 universe=self.dimensions 

218 ) 

219 return self._datastore_cache_manager 

220 

221 def _caching_context(self) -> AbstractContextManager[None]: 

222 # Docstring inherited. 

223 # Not implemented for now, will have to think whether this needs to 

224 # do something on client side and/or remote side. 

225 raise NotImplementedError() 

226 

227 def transaction(self) -> AbstractContextManager[None]: 

228 """Will always raise NotImplementedError. 

229 Transactions are not supported by RemoteButler. 

230 """ 

231 raise NotImplementedError() 

232 

233 def put( 

234 self, 

235 obj: Any, 

236 datasetRefOrType: DatasetRef | DatasetType | str, 

237 /, 

238 dataId: DataId | None = None, 

239 *, 

240 run: str | None = None, 

241 provenance: DatasetProvenance | None = None, 

242 **kwargs: Any, 

243 ) -> DatasetRef: 

244 # Docstring inherited. 

245 raise NotImplementedError() 

246 

247 def getDeferred( 

248 self, 

249 datasetRefOrType: DatasetRef | DatasetType | str, 

250 /, 

251 dataId: DataId | None = None, 

252 *, 

253 parameters: dict | None = None, 

254 collections: Any = None, 

255 storageClass: str | StorageClass | None = None, 

256 timespan: Timespan | None = None, 

257 **kwargs: Any, 

258 ) -> DeferredDatasetHandle: 

259 response = self._get_file_info(datasetRefOrType, dataId, collections, timespan, kwargs) 

260 # Check that artifact information is available. 

261 _to_file_payload(response) 

262 ref = DatasetRef.from_simple(response.dataset_ref, universe=self.dimensions) 

263 return DeferredDatasetHandle(butler=self, ref=ref, parameters=parameters, storageClass=storageClass) 

264 

265 def get( 

266 self, 

267 datasetRefOrType: DatasetRef | DatasetType | str, 

268 /, 

269 dataId: DataId | None = None, 

270 *, 

271 parameters: dict[str, Any] | None = None, 

272 collections: Any = None, 

273 storageClass: StorageClass | str | None = None, 

274 timespan: Timespan | None = None, 

275 **kwargs: Any, 

276 ) -> Any: 

277 # Docstring inherited. 

278 with self._metrics.instrument_get(log=_LOG, msg="Retrieved remote dataset"): 

279 model = self._get_file_info(datasetRefOrType, dataId, collections, timespan, kwargs) 

280 

281 ref = DatasetRef.from_simple(model.dataset_ref, universe=self.dimensions) 

282 # If the caller provided a DatasetRef, they may have overridden the 

283 # component on it. We need to explicitly handle this because we 

284 # did not send the DatasetType to the server in this case. 

285 if isinstance(datasetRefOrType, DatasetRef): 

286 componentOverride = datasetRefOrType.datasetType.component() 

287 if componentOverride: 

288 ref = ref.makeComponentRef(componentOverride) 

289 ref = apply_storage_class_override(ref, datasetRefOrType, storageClass) 

290 

291 return self._get_dataset_as_python_object(ref, model, parameters) 

292 

293 def _get_dataset_as_python_object( 

294 self, 

295 ref: DatasetRef, 

296 model: GetFileResponseModel, 

297 parameters: dict[str, Any] | None, 

298 ) -> Any: 

299 # This thin wrapper method is here to provide a place to hook in a mock 

300 # mimicking DatastoreMock functionality for use in unit tests. 

301 return get_dataset_as_python_object( 

302 ref, 

303 _to_file_payload(model), 

304 auth=self._connection.auth, 

305 parameters=parameters, 

306 cache_manager=self._cache_manager, 

307 ) 

308 

309 def _get_file_info( 

310 self, 

311 datasetRefOrType: DatasetRef | DatasetType | str, 

312 dataId: DataId | None, 

313 collections: CollectionArgType, 

314 timespan: Timespan | None, 

315 kwargs: dict[str, DataIdValue], 

316 ) -> GetFileResponseModel: 

317 """Send a request to the server for the file URLs and metadata 

318 associated with a dataset. 

319 """ 

320 if isinstance(datasetRefOrType, DatasetRef): 

321 if dataId is not None: 

322 raise ValueError("DatasetRef given, cannot use dataId as well") 

323 return self._get_file_info_for_ref(datasetRefOrType) 

324 else: 

325 request = GetFileByDataIdRequestModel( 

326 dataset_type=normalize_dataset_type_name(datasetRefOrType), 

327 collections=self._normalize_collections(collections), 

328 data_id=simplify_dataId(dataId, kwargs), 

329 default_data_id=self._serialize_default_data_id(), 

330 timespan=timespan, 

331 ) 

332 response = self._connection.post("get_file_by_data_id", request) 

333 return parse_model(response, GetFileResponseModel) 

334 

335 def _get_file_info_for_ref(self, ref: DatasetRef) -> GetFileResponseModel: 

336 response = self._connection.get(f"get_file/{_to_uuid_string(ref.id)}") 

337 return parse_model(response, GetFileResponseModel) 

338 

339 def getURIs( 

340 self, 

341 datasetRefOrType: DatasetRef | DatasetType | str, 

342 /, 

343 dataId: DataId | None = None, 

344 *, 

345 predict: bool = False, 

346 collections: Any = None, 

347 run: str | None = None, 

348 **kwargs: Any, 

349 ) -> DatasetRefURIs: 

350 # Docstring inherited. 

351 if predict or run: 

352 raise NotImplementedError("Predict mode is not supported by RemoteButler") 

353 

354 response = self._get_file_info(datasetRefOrType, dataId, collections, None, kwargs) 

355 file_info = _to_file_payload(response).file_info 

356 if len(file_info) == 1: 

357 return DatasetRefURIs( 

358 primaryURI=convert_http_url_to_resource_path( 

359 file_info[0].url, self._connection.auth, file_info[0].auth 

360 ) 

361 ) 

362 else: 

363 components = {} 

364 for f in file_info: 

365 component = f.datastoreRecords.component 

366 if component is None: 

367 raise ValueError( 

368 f"DatasetId {response.dataset_ref.id} has a component file" 

369 " with no component name defined" 

370 ) 

371 components[component] = convert_http_url_to_resource_path( 

372 f.url, self._connection.auth, f.auth 

373 ) 

374 return DatasetRefURIs(componentURIs=components) 

375 

376 def get_dataset_type(self, name: str) -> DatasetType: 

377 with self._cache.access() as cache: 

378 if (cached_value := cache.dataset_types.get(name)) is not None: 

379 return cached_value 

380 

381 response = self._connection.get(f"dataset_type/{quote_path_variable(name)}") 

382 model = parse_model(response, GetDatasetTypeResponseModel) 

383 value = DatasetType.from_simple(model.dataset_type, universe=self.dimensions) 

384 with self._cache.access() as cache: 

385 return cache.dataset_types.setdefault(name, value) 

386 

387 def get_dataset( 

388 self, 

389 id: DatasetId, 

390 *, 

391 storage_class: str | StorageClass | None = None, 

392 dimension_records: bool = False, 

393 datastore_records: bool = False, 

394 ) -> DatasetRef | None: 

395 # datastore_records is intentionally ignored. It is an optimization 

396 # flag that only applies to DirectButler. 

397 path = f"dataset/{_to_uuid_string(id)}" 

398 response = self._connection.get(path, params={"dimension_records": bool(dimension_records)}) 

399 model = parse_model(response, FindDatasetResponseModel) 

400 if model.dataset_ref is None: 

401 return None 

402 ref = DatasetRef.from_simple(model.dataset_ref, universe=self.dimensions) 

403 if storage_class is not None: 

404 ref = ref.overrideStorageClass(storage_class) 

405 return ref 

406 

407 def find_dataset( 

408 self, 

409 dataset_type: DatasetType | str, 

410 data_id: DataId | None = None, 

411 *, 

412 collections: str | Sequence[str] | None = None, 

413 timespan: Timespan | None = None, 

414 storage_class: str | StorageClass | None = None, 

415 dimension_records: bool = False, 

416 datastore_records: bool = False, 

417 **kwargs: Any, 

418 ) -> DatasetRef | None: 

419 # datastore_records is intentionally ignored. It is an optimization 

420 # flag that only applies to DirectButler. 

421 

422 query = FindDatasetRequestModel( 

423 dataset_type=normalize_dataset_type_name(dataset_type), 

424 data_id=simplify_dataId(data_id, kwargs), 

425 default_data_id=self._serialize_default_data_id(), 

426 collections=self._normalize_collections(collections), 

427 timespan=timespan, 

428 dimension_records=dimension_records, 

429 ) 

430 

431 response = self._connection.post("find_dataset", query) 

432 

433 model = parse_model(response, FindDatasetResponseModel) 

434 if model.dataset_ref is None: 

435 return None 

436 

437 ref = DatasetRef.from_simple(model.dataset_ref, universe=self.dimensions) 

438 if isinstance(data_id, DataCoordinate) and data_id.hasRecords(): 

439 ref = ref.expanded(data_id) 

440 return apply_storage_class_override(ref, dataset_type, storage_class) 

441 

442 def _retrieve_artifacts( 

443 self, 

444 refs: Iterable[DatasetRef], 

445 destination: ResourcePathExpression, 

446 transfer: str = "auto", 

447 preserve_path: bool = True, 

448 overwrite: bool = False, 

449 write_index: bool = True, 

450 add_prefix: bool = False, 

451 ) -> dict[ResourcePath, ArtifactIndexInfo]: 

452 destination = ResourcePath(destination).abspath() 

453 if not destination.isdir(): 

454 raise ValueError(f"Destination location must refer to a directory. Given {destination}.") 

455 

456 if transfer not in ("auto", "copy"): 

457 raise ValueError("Only 'copy' and 'auto' transfer modes are supported.") 

458 

459 requested_ids = {ref.id for ref in refs} 

460 have_copied: dict[ResourcePath, ResourcePath] = {} 

461 artifact_map: dict[ResourcePath, ArtifactIndexInfo] = {} 

462 # Sort to ensure that in many refs to one file situation the same 

463 # ref is used for any prefix that might be added. 

464 for ref in sorted(refs): 

465 prefix = str(ref.id)[:8] + "-" if add_prefix else "" 

466 file_info = _to_file_payload(self._get_file_info_for_ref(ref)).file_info 

467 for file in file_info: 

468 source_uri = ResourcePath(str(file.url)) 

469 # For DECam/zip we only want to copy once. 

470 # For zip files we need to unpack so that they can be 

471 # zipped up again if needed. 

472 is_zip = source_uri.getExtension() == ".zip" and "zip-path" in source_uri.fragment 

473 cleaned_source_uri = source_uri.replace(fragment="", query="", params="") 

474 if is_zip: 

475 if cleaned_source_uri not in have_copied: 

476 zipped_artifacts = unpack_zips( 

477 [cleaned_source_uri], requested_ids, destination, preserve_path, overwrite 

478 ) 

479 artifact_map.update(zipped_artifacts) 

480 have_copied[cleaned_source_uri] = cleaned_source_uri 

481 elif cleaned_source_uri not in have_copied: 

482 relative_path = ResourcePath(file.datastoreRecords.path, forceAbsolute=False) 

483 target_uri = determine_destination_for_retrieved_artifact( 

484 destination, relative_path, preserve_path, prefix 

485 ) 

486 # Because signed URLs expire, we want to do the transfer 

487 # soon after retrieving the URL. 

488 target_uri.transfer_from(source_uri, transfer="copy", overwrite=overwrite) 

489 have_copied[cleaned_source_uri] = target_uri 

490 artifact_map[target_uri] = ArtifactIndexInfo.from_single(file.datastoreRecords, ref.id) 

491 else: 

492 target_uri = have_copied[cleaned_source_uri] 

493 artifact_map[target_uri].append(ref.id) 

494 

495 if write_index: 

496 index = ZipIndex.from_artifact_map(refs, artifact_map, destination) 

497 index.write_index(destination) 

498 

499 return artifact_map 

500 

501 def retrieve_artifacts_zip( 

502 self, 

503 refs: Iterable[DatasetRef], 

504 destination: ResourcePathExpression, 

505 overwrite: bool = True, 

506 ) -> ResourcePath: 

507 return retrieve_and_zip(refs, destination, self._retrieve_artifacts, overwrite) 

508 

509 def retrieveArtifacts( 

510 self, 

511 refs: Iterable[DatasetRef], 

512 destination: ResourcePathExpression, 

513 transfer: str = "auto", 

514 preserve_path: bool = True, 

515 overwrite: bool = False, 

516 ) -> list[ResourcePath]: 

517 artifact_map = self._retrieve_artifacts( 

518 refs, 

519 destination, 

520 transfer, 

521 preserve_path, 

522 overwrite, 

523 ) 

524 return list(artifact_map) 

525 

526 def exists( 

527 self, 

528 dataset_ref_or_type: DatasetRef | DatasetType | str, 

529 /, 

530 data_id: DataId | None = None, 

531 *, 

532 full_check: bool = True, 

533 collections: Any = None, 

534 **kwargs: Any, 

535 ) -> DatasetExistence: 

536 try: 

537 response = self._get_file_info( 

538 dataset_ref_or_type, dataId=data_id, collections=collections, timespan=None, kwargs=kwargs 

539 ) 

540 except DatasetNotFoundError: 

541 return DatasetExistence.UNRECOGNIZED 

542 

543 if response.artifact is None: 

544 if full_check: 

545 return DatasetExistence.RECORDED 

546 else: 

547 return DatasetExistence.RECORDED | DatasetExistence._ASSUMED 

548 

549 if full_check: 

550 for file in response.artifact.file_info: 

551 if not ResourcePath(str(file.url)).exists(): 

552 return DatasetExistence.RECORDED | DatasetExistence.DATASTORE 

553 return DatasetExistence.VERIFIED 

554 else: 

555 return DatasetExistence.KNOWN 

556 

557 def _exists_many( 

558 self, 

559 refs: Iterable[DatasetRef], 

560 /, 

561 *, 

562 full_check: bool = True, 

563 ) -> dict[DatasetRef, DatasetExistence]: 

564 return {ref: self.exists(ref, full_check=full_check) for ref in refs} 

565 

566 def removeRuns( 

567 self, 

568 names: Iterable[str], 

569 unstore: bool | type[_DeprecatedDefault] = _DeprecatedDefault, 

570 *, 

571 unlink_from_chains: bool = False, 

572 ) -> None: 

573 # Docstring inherited. 

574 raise NotImplementedError() 

575 

576 def ingest( 

577 self, 

578 *datasets: FileDataset, 

579 transfer: str | None = "auto", 

580 record_validation_info: bool = True, 

581 skip_existing: bool = False, 

582 ) -> None: 

583 # Docstring inherited. 

584 raise NotImplementedError() 

585 

586 def ingest_zip( 

587 self, 

588 zip_file: ResourcePathExpression, 

589 transfer: str = "auto", 

590 *, 

591 transfer_dimensions: bool = False, 

592 dry_run: bool = False, 

593 ) -> None: 

594 # Docstring inherited. 

595 raise NotImplementedError() 

596 

597 def export( 

598 self, 

599 *, 

600 directory: str | None = None, 

601 filename: str | None = None, 

602 format: str | None = None, 

603 transfer: str | None = None, 

604 ) -> AbstractContextManager[RepoExportContext]: 

605 # Docstring inherited. 

606 raise NotImplementedError() 

607 

608 def import_( 

609 self, 

610 *, 

611 directory: ResourcePathExpression | None = None, 

612 filename: ResourcePathExpression | TextIO | None = None, 

613 format: str | None = None, 

614 transfer: str | None = None, 

615 skip_dimensions: set | None = None, 

616 record_validation_info: bool = True, 

617 without_datastore: bool = False, 

618 ) -> None: 

619 # Docstring inherited. 

620 raise NotImplementedError() 

621 

622 def transfer_dimension_records_from( 

623 self, source_butler: LimitedButler | Butler, source_refs: Iterable[DatasetRef] 

624 ) -> None: 

625 # Docstring inherited. 

626 raise NotImplementedError() 

627 

628 def transfer_from( 

629 self, 

630 source_butler: LimitedButler, 

631 source_refs: Iterable[DatasetRef], 

632 transfer: str = "auto", 

633 skip_missing: bool = True, 

634 register_dataset_types: bool = False, 

635 transfer_dimensions: bool = False, 

636 dry_run: bool = False, 

637 ) -> Collection[DatasetRef]: 

638 # Docstring inherited. 

639 raise NotImplementedError() 

640 

641 def validateConfiguration( 

642 self, 

643 logFailures: bool = False, 

644 datasetTypeNames: Iterable[str] | None = None, 

645 ignore: Iterable[str] | None = None, 

646 ) -> None: 

647 # Docstring inherited. 

648 raise NotImplementedError() 

649 

650 @property 

651 def run(self) -> str | None: 

652 # Docstring inherited. 

653 return self._registry_defaults.get().run 

654 

655 @property 

656 def registry(self) -> Registry: 

657 return self._registry 

658 

659 @contextmanager 

660 def query(self) -> Iterator[Query]: 

661 driver = RemoteQueryDriver(self, self._connection) 

662 with driver: 

663 query = Query(driver) 

664 yield query 

665 

666 @contextmanager 

667 def _query_all_datasets_by_page( 

668 self, args: QueryAllDatasetsParameters 

669 ) -> Iterator[Iterator[list[DatasetRef]]]: 

670 universe = self.dimensions 

671 

672 request = QueryAllDatasetsRequestModel( 

673 collections=self._normalize_collections(args.collections), 

674 name=[normalize_dataset_type_name(name) for name in args.name], 

675 find_first=args.find_first, 

676 data_id=simplify_dataId(args.data_id, args.kwargs), 

677 default_data_id=self._serialize_default_data_id(), 

678 where=args.where, 

679 bind={k: make_column_literal(v) for k, v in args.bind.items()}, 

680 limit=args.limit, 

681 with_dimension_records=args.with_dimension_records, 

682 ) 

683 with self._connection.post_with_stream_response("query/all_datasets", request) as response: 

684 pages = read_query_results(response) 

685 yield (convert_dataset_ref_results(page, universe) for page in pages) 

686 

687 def pruneDatasets( 

688 self, 

689 refs: Iterable[DatasetRef], 

690 *, 

691 disassociate: bool = True, 

692 unstore: bool = False, 

693 tags: Iterable[str] = (), 

694 purge: bool = False, 

695 ) -> None: 

696 # Docstring inherited. 

697 raise NotImplementedError() 

698 

699 def _normalize_collections(self, collections: CollectionArgType | None) -> CollectionList: 

700 """Convert the ``collections`` parameter in the format used by Butler 

701 methods to a standardized format for the REST API. 

702 """ 

703 if collections is None: 

704 if not self.collections.defaults: 

705 raise NoDefaultCollectionError( 

706 "No collections provided, and no defaults from butler construction." 

707 ) 

708 collections = self.collections.defaults 

709 return convert_collection_arg_to_glob_string_list(collections) 

710 

711 def clone( 

712 self, 

713 *, 

714 collections: CollectionArgType | None | EllipsisType = ..., 

715 run: str | None | EllipsisType = ..., 

716 inferDefaults: bool | EllipsisType = ..., 

717 dataId: dict[str, str] | EllipsisType = ..., 

718 metrics: ButlerMetrics | None = None, 

719 ) -> RemoteButler: 

720 defaults = self._registry_defaults.get().clone(collections, run, inferDefaults, dataId) 

721 return RemoteButler( 

722 connection=self._connection, cache=self._cache, defaults=defaults, metrics=metrics 

723 ) 

724 

725 @property 

726 def _file_transfer_source(self) -> RemoteFileTransferSource: 

727 return RemoteFileTransferSource(self._connection) 

728 

729 def __str__(self) -> str: 

730 return f"RemoteButler({self._connection.server_url})" 

731 

732 def _serialize_default_data_id(self) -> SerializedDataId: 

733 """Convert the default data ID to a serializable format.""" 

734 # In an ideal world, the default data ID would just get combined with 

735 # the rest of the data ID on the client side instead of being sent 

736 # separately to the server. Unfortunately, that requires knowledge of 

737 # the DatasetType's dimensions which we don't always have available on 

738 # the client. Data ID values can be specified indirectly by "implied" 

739 # dimensions, but knowing what things are implied depends on what the 

740 # required dimensions are. 

741 

742 return self._registry_defaults.get().dataId.to_simple(minimal=True).dataId 

743 

744 

745def _to_file_payload(get_file_response: GetFileResponseModel) -> FileInfoPayload: 

746 if get_file_response.artifact is None: 

747 ref = get_file_response.dataset_ref 

748 raise DatasetNotFoundError(f"Dataset is known, but artifact is not available. (datasetId='{ref.id}')") 

749 

750 return get_file_response.artifact 

751 

752 

753def _to_uuid_string(id: uuid.UUID | str) -> str: 

754 """Convert a UUID, or string parseable as a UUID, into a string formatted 

755 like '1481269e-4c8d-4696-bcca-d1b4c9005d06' 

756 """ 

757 return str(uuid.UUID(str(id))) 

758 

759 

760class _RemoteButlerCacheData: 

761 def __init__(self) -> None: 

762 self.dimensions: DimensionUniverse | None = None 

763 self.dataset_types: dict[str, DatasetType] = {} 

764 

765 

766class RemoteButlerCache(LockedObject[_RemoteButlerCacheData]): 

767 def __init__(self) -> None: 

768 super().__init__(_RemoteButlerCacheData())