Coverage for python / lsst / daf / butler / datastores / file_datastore / retrieve_artifacts.py: 28%

136 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-08 16:37 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28from __future__ import annotations 

29 

30__all__ = ("ZipIndex", "determine_destination_for_retrieved_artifact", "retrieve_and_zip", "unpack_zips") 

31 

32import logging 

33import tempfile 

34import uuid 

35import zipfile 

36from collections.abc import Iterable 

37from typing import ClassVar, Literal, Protocol, Self 

38 

39from pydantic import BaseModel 

40 

41from lsst.daf.butler import ( 

42 DatasetIdFactory, 

43 DatasetRef, 

44 SerializedDatasetRefContainers, 

45 SerializedDatasetRefContainerV1, 

46) 

47from lsst.daf.butler.datastore.stored_file_info import SerializedStoredFileInfo 

48from lsst.resources import ResourcePath, ResourcePathExpression 

49 

50_LOG = logging.getLogger(__name__) 

51 

52 

53class ArtifactIndexInfo(BaseModel): 

54 """Information related to an artifact in an index.""" 

55 

56 info: SerializedStoredFileInfo 

57 """Datastore record information for this file artifact.""" 

58 

59 ids: set[uuid.UUID] 

60 """Dataset IDs for this artifact.""" 

61 

62 def append(self, id_: uuid.UUID) -> None: 

63 """Add an additional dataset ID. 

64 

65 Parameters 

66 ---------- 

67 id_ : `uuid.UUID` 

68 Additional dataset ID to associate with this artifact. 

69 """ 

70 self.ids.add(id_) 

71 

72 @classmethod 

73 def from_single(cls, info: SerializedStoredFileInfo, id_: uuid.UUID) -> Self: 

74 """Create a mapping from a single ID and info. 

75 

76 Parameters 

77 ---------- 

78 info : `SerializedStoredFileInfo` 

79 Datastore record for this artifact. 

80 id_ : `uuid.UUID` 

81 First dataset ID to associate with this artifact. 

82 """ 

83 return cls(info=info, ids=[id_]) 

84 

85 def subset(self, ids: Iterable[uuid.UUID]) -> Self: 

86 """Replace the IDs with a subset of the IDs and return a new instance. 

87 

88 Parameters 

89 ---------- 

90 ids : `~collections.abc.Iterable` [ `uuid.UUID` ] 

91 Subset of IDs to keep. 

92 

93 Returns 

94 ------- 

95 subsetted : `ArtifactIndexInfo` 

96 New instance with the requested subset. 

97 

98 Raises 

99 ------ 

100 ValueError 

101 Raised if the given IDs is not a subset of the current IDs. 

102 """ 

103 subset = set(ids) 

104 if subset - self.ids: 

105 raise ValueError(f"Given subset of {subset} is not a subset of {self.ids}") 

106 return type(self)(ids=subset, info=self.info.model_copy()) 

107 

108 

109class ZipIndex(BaseModel): 

110 """Index of a Zip file of Butler datasets. 

111 

112 A file can be associated with multiple butler datasets and a single 

113 butler dataset can be associated with multiple files. This model 

114 provides the necessary information for ingesting back into a Butler 

115 file datastore. 

116 """ 

117 

118 index_version: Literal["V1"] = "V1" 

119 

120 refs: SerializedDatasetRefContainers 

121 """Deduplicated information for all the `DatasetRef` in the index.""" 

122 

123 artifact_map: dict[str, ArtifactIndexInfo] 

124 """Mapping of each Zip member to associated lookup information.""" 

125 

126 index_name: ClassVar[str] = "_butler_zip_index.json" 

127 """Name to use when saving the index to a file.""" 

128 

129 def generate_uuid5(self) -> uuid.UUID: 

130 """Create a UUID based on the Zip index. 

131 

132 Returns 

133 ------- 

134 id_ : `uuid.UUID` 

135 A UUID5 created from the paths inside the Zip file. Guarantees 

136 that if the Zip file is regenerated with exactly the same file 

137 paths the same answer will be returned. 

138 """ 

139 # Options are: 

140 # - uuid5 based on file paths in zip 

141 # - uuid5 based on ref uuids. 

142 # - checksum derived from the above. 

143 # - uuid5 from file paths and dataset refs. 

144 # Do not attempt to include file contents in UUID. 

145 # Start with uuid5 from file paths. 

146 data = ",".join(sorted(self.artifact_map.keys())) 

147 # No need to come up with a different namespace. 

148 return uuid.uuid5(DatasetIdFactory.NS_UUID, data) 

149 

150 def __len__(self) -> int: 

151 """Return the number of files in the Zip.""" 

152 return len(self.artifact_map) 

153 

154 def calculate_zip_file_name(self) -> str: 

155 """Calculate the default name for the Zip file based on the index 

156 contents. 

157 

158 Returns 

159 ------- 

160 name : `str` 

161 Name of the zip file based on index. 

162 """ 

163 return f"{self.generate_uuid5()}.zip" 

164 

165 def calculate_zip_file_path_in_store(self) -> str: 

166 """Calculate the relative path inside a datastore that should be 

167 used for this Zip file. 

168 

169 Returns 

170 ------- 

171 path_in_store : `str` 

172 Relative path to use for Zip file in datastore. 

173 """ 

174 zip_name = self.calculate_zip_file_name() 

175 return f"zips/{zip_name[:4]}/{zip_name}" 

176 

177 def write_index(self, dir: ResourcePath) -> ResourcePath: 

178 """Write the index to the specified directory. 

179 

180 Parameters 

181 ---------- 

182 dir : `~lsst.resources.ResourcePath` 

183 Directory to write the index file to. 

184 

185 Returns 

186 ------- 

187 index_path : `~lsst.resources.ResourcePath` 

188 Path to the index file that was written. 

189 """ 

190 index_path = dir.join(self.index_name, forceDirectory=False) 

191 with index_path.open("w") as fd: 

192 # Need to include unset/default values so that the version 

193 # discriminator field for refs container appears in the 

194 # serialization. 

195 print(self.model_dump_json(), file=fd) 

196 return index_path 

197 

198 @classmethod 

199 def calc_relative_paths( 

200 cls, root: ResourcePath, paths: Iterable[ResourcePath] 

201 ) -> dict[ResourcePath, str]: 

202 """Calculate the path to use inside the Zip file from the full path. 

203 

204 Parameters 

205 ---------- 

206 root : `lsst.resources.ResourcePath` 

207 The reference root directory. 

208 paths : `~collections.abc.Iterable` [ `lsst.resources.ResourcePath` ] 

209 The paths to the files that should be included in the Zip file. 

210 

211 Returns 

212 ------- 

213 abs_to_rel : `dict` [ `~lsst.resources.ResourcePath`, `str` ] 

214 Mapping of the original file path to the relative path to use 

215 in Zip file. 

216 """ 

217 file_to_relative: dict[ResourcePath, str] = {} 

218 for p in paths: 

219 # It is an error if there is no relative path. 

220 rel = p.relative_to(root) 

221 if rel is None: 

222 raise RuntimeError(f"Unexepectedly unable to calculate relative path of {p} to {root}.") 

223 file_to_relative[p] = rel 

224 return file_to_relative 

225 

226 @classmethod 

227 def from_artifact_map( 

228 cls, 

229 refs: Iterable[DatasetRef], 

230 artifact_map: dict[ResourcePath, ArtifactIndexInfo], 

231 root: ResourcePath, 

232 ) -> Self: 

233 """Create an index from the mappings returned from 

234 `Datastore.retrieveArtifacts`. 

235 

236 Parameters 

237 ---------- 

238 refs : `~collections.abc.Iterable` [ `DatasetRef` ] 

239 Datasets present in the index. 

240 artifact_map : `dict` [ `lsst.resources.ResourcePath`, `ArtifactMap` ] 

241 Mapping of artifact path to information linking it to the 

242 associated refs and datastore information. 

243 root : `lsst.resources.ResourcePath` 

244 Root path to be removed from all the paths before creating the 

245 index. 

246 """ 

247 if not refs: 

248 return cls( 

249 refs=SerializedDatasetRefContainerV1.from_refs(refs), 

250 artifact_map={}, 

251 ) 

252 

253 # Calculate the paths relative to the given root since the Zip file 

254 # uses relative paths. 

255 file_to_relative = cls.calc_relative_paths(root, artifact_map.keys()) 

256 

257 simplified_refs = SerializedDatasetRefContainerV1.from_refs(refs) 

258 

259 # Convert the artifact mapping to relative path. 

260 relative_artifact_map = {file_to_relative[path]: info for path, info in artifact_map.items()} 

261 

262 return cls( 

263 refs=simplified_refs, 

264 artifact_map=relative_artifact_map, 

265 ) 

266 

267 @classmethod 

268 def from_zip_file(cls, zip_path: ResourcePath) -> Self: 

269 """Given a path to a Zip file return the index. 

270 

271 Parameters 

272 ---------- 

273 zip_path : `lsst.resources.ResourcePath` 

274 Path to the Zip file. 

275 """ 

276 with zip_path.open("rb") as fd, zipfile.ZipFile(fd) as zf: 

277 return cls.from_open_zip(zf) 

278 

279 @classmethod 

280 def from_open_zip(cls, zf: zipfile.ZipFile) -> Self: 

281 json_data = zf.read(cls.index_name) 

282 return cls.model_validate_json(json_data) 

283 

284 

285def determine_destination_for_retrieved_artifact( 

286 destination_directory: ResourcePath, source_path: ResourcePath, preserve_path: bool, prefix: str = "" 

287) -> ResourcePath: 

288 """Determine destination path for an artifact retrieved from a datastore. 

289 

290 Parameters 

291 ---------- 

292 destination_directory : `ResourcePath` 

293 Path to the output directory where file will be stored. 

294 source_path : `ResourcePath` 

295 Path to the source file to be transferred. This may be relative to the 

296 datastore root, or an absolute path. 

297 preserve_path : `bool` 

298 If `True` the full path of the artifact within the datastore 

299 is preserved. If `False` the final file component of the path 

300 is used. 

301 prefix : `str`, optional 

302 Prefix to add to the file name if ``preserve_path`` is `False`. 

303 

304 Returns 

305 ------- 

306 destination_uri : `~lsst.resources.ResourcePath` 

307 Absolute path to the output destination. 

308 """ 

309 destination_directory = destination_directory.abspath() 

310 

311 target_path: ResourcePathExpression 

312 if preserve_path: 

313 target_path = source_path 

314 if target_path.isabs(): 

315 # This is an absolute path to an external file. 

316 # Use the full path. 

317 target_path = target_path.relativeToPathRoot 

318 else: 

319 target_path = source_path.basename() 

320 if prefix: 

321 target_path = prefix + target_path 

322 

323 target_uri = destination_directory.join(target_path).abspath() 

324 if target_uri.relative_to(destination_directory) is None: 

325 raise ValueError(f"File path attempts to escape destination directory: '{source_path}'") 

326 return target_uri 

327 

328 

329class RetrievalCallable(Protocol): 

330 def __call__( 330 ↛ exitline 330 didn't return from function '__call__' because

331 self, 

332 refs: Iterable[DatasetRef], 

333 destination: ResourcePath, 

334 transfer: str, 

335 preserve_path: bool, 

336 overwrite: bool, 

337 write_index: bool, 

338 add_prefix: bool, 

339 ) -> dict[ResourcePath, ArtifactIndexInfo]: ... 

340 

341 

342def retrieve_and_zip( 

343 refs: Iterable[DatasetRef], 

344 destination: ResourcePathExpression, 

345 retrieval_callback: RetrievalCallable, 

346 overwrite: bool = True, 

347) -> ResourcePath: 

348 """Retrieve artifacts from a Butler and place in ZIP file. 

349 

350 Parameters 

351 ---------- 

352 refs : `collections.abc.Iterable` [ `DatasetRef` ] 

353 The datasets to be included in the zip file. Must all be from 

354 the same dataset type. 

355 destination : `lsst.resources.ResourcePath` 

356 Directory to write the new ZIP file. This directory will 

357 also be used as a staging area for the datasets being downloaded 

358 from the datastore. 

359 retrieval_callback : `~collections.abc.Callable` 

360 Bound method for a function that can retrieve the artifacts and 

361 return the metadata necessary for creating the zip index. For example 

362 `lsst.daf.butler.datastore.Datastore.retrieveArtifacts`. 

363 overwrite : `bool`, optional 

364 If `False` the output Zip will not be written if a file of the 

365 same name is already present in ``destination``. 

366 

367 Returns 

368 ------- 

369 zip_file : `lsst.resources.ResourcePath` 

370 The path to the new ZIP file. 

371 

372 Raises 

373 ------ 

374 ValueError 

375 Raised if there are no refs to retrieve. 

376 """ 

377 if not refs: 

378 raise ValueError("Requested Zip file with no contents.") 

379 

380 outdir = ResourcePath(destination, forceDirectory=True) 

381 if not outdir.isdir(): 

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

383 

384 if not outdir.exists(): 

385 outdir.mkdir() 

386 

387 # Simplest approach: 

388 # - create temp dir in destination 

389 # - Run retrieveArtifacts to that temp dir 

390 # - Create zip file with unique name. 

391 # - Delete temp dir 

392 # - Add index file to ZIP. 

393 # - Return name of zip file. 

394 with tempfile.TemporaryDirectory(dir=outdir.ospath, ignore_cleanup_errors=True) as tmpdir: 

395 tmpdir_path = ResourcePath(tmpdir, forceDirectory=True) 

396 # Retrieve the artifacts and write the index file. Strip paths. 

397 artifact_map = retrieval_callback( 

398 refs=refs, 

399 destination=tmpdir_path, 

400 transfer="auto", 

401 preserve_path=False, 

402 overwrite=False, 

403 write_index=True, 

404 add_prefix=True, 

405 ) 

406 

407 # Read the index to construct file name. 

408 index_path = tmpdir_path.join(ZipIndex.index_name, forceDirectory=False) 

409 index_json = index_path.read() 

410 index = ZipIndex.model_validate_json(index_json) 

411 

412 # Use unique name based on files in Zip. 

413 zip_file_name = index.calculate_zip_file_name() 

414 zip_path = outdir.join(zip_file_name, forceDirectory=False) 

415 if not overwrite and zip_path.exists(): 

416 raise FileExistsError(f"Output Zip at {zip_path} already exists but cannot overwrite.") 

417 with zipfile.ZipFile(zip_path.ospath, "w") as zip: 

418 zip.write(index_path.ospath, index_path.basename(), compress_type=zipfile.ZIP_DEFLATED) 

419 for path, name in index.calc_relative_paths(tmpdir_path, list(artifact_map)).items(): 

420 zip.write(path.ospath, name) 

421 

422 return zip_path 

423 

424 

425def unpack_zips( 

426 zips_to_transfer: Iterable[ResourcePath], 

427 allowed_ids: set[uuid.UUID], 

428 destination: ResourcePath, 

429 preserve_path: bool, 

430 overwrite: bool, 

431) -> dict[ResourcePath, ArtifactIndexInfo]: 

432 """Transfer the Zip files and unpack them in the destination directory. 

433 

434 Parameters 

435 ---------- 

436 zips_to_transfer : `~collections.abc.Iterable` \ 

437 [ `~lsst.resources.ResourcePath` ] 

438 Paths to Zip files to unpack. These must be Zip files that include 

439 the index information and were created by the Butler. 

440 allowed_ids : `set` [ `uuid.UUID` ] 

441 All the possible dataset IDs for which artifacts should be extracted 

442 from the Zip file. If an ID in the Zip file is not present in this 

443 list the artifact will not be extracted from the Zip. 

444 destination : `~lsst.resources.ResourcePath` 

445 Output destination for the Zip contents. 

446 preserve_path : `bool` 

447 Whether to include subdirectories during extraction. If `True` a 

448 directory will be made per Zip. 

449 overwrite : `bool`, optional 

450 If `True` allow transfers to overwrite existing files at the 

451 destination. 

452 

453 Returns 

454 ------- 

455 artifact_map : `dict` \ 

456 [ `~lsst.resources.ResourcePath`, `ArtifactIndexInfo` ] 

457 Path linking Zip contents location to associated artifact information. 

458 """ 

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

460 for source_uri in zips_to_transfer: 

461 _LOG.debug("Unpacking zip file %s", source_uri) 

462 # Assume that downloading to temporary location is more efficient 

463 # than trying to read the contents remotely. 

464 with ResourcePath.temporary_uri(suffix=".zip") as temp: 

465 temp.transfer_from(source_uri, transfer="auto") 

466 index = ZipIndex.from_zip_file(temp) 

467 

468 if preserve_path: 

469 subdir = ResourcePath( 

470 index.calculate_zip_file_path_in_store(), forceDirectory=False, forceAbsolute=False 

471 ).dirname() 

472 outdir = destination.join(subdir) 

473 else: 

474 outdir = destination 

475 outdir.mkdir() 

476 with temp.open("rb") as fd, zipfile.ZipFile(fd) as zf: 

477 for path_in_zip, artifact_info in index.artifact_map.items(): 

478 # Skip if this specific dataset ref is not requested. 

479 included_ids = artifact_info.ids & allowed_ids 

480 if included_ids: 

481 # Do not apply a new prefix since the zip file 

482 # should already have a prefix. 

483 output_path = outdir.join(path_in_zip, forceDirectory=False) 

484 if not overwrite and output_path.exists(): 

485 raise FileExistsError( 

486 f"Destination path '{output_path}' already exists. " 

487 "Extraction from Zip cannot be completed." 

488 ) 

489 zf.extract(path_in_zip, path=outdir.ospath) 

490 artifact_map[output_path] = artifact_info.subset(included_ids) 

491 return artifact_map