Coverage for python / lsst / daf / butler / transfers / _context.py: 12%
113 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-13 01:20 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-13 01:20 -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/>.
28from __future__ import annotations
30__all__ = ["RepoExportContext"]
32from collections import defaultdict
33from collections.abc import Callable, Iterable, Set
34from typing import TYPE_CHECKING
36from .._collection_type import CollectionType
37from .._dataset_association import DatasetAssociation
38from .._dataset_ref import DatasetId, DatasetRef
39from .._dataset_type import DatasetType
40from .._file_dataset import FileDataset
41from ..dimensions import DataCoordinate, DimensionElement, DimensionRecord
42from ..registry.interfaces import ChainedCollectionRecord, CollectionRecord
44if TYPE_CHECKING:
45 from lsst.resources import ResourcePathExpression
47 from ..direct_butler import DirectButler
48 from ._interfaces import RepoExportBackend
51class RepoExportContext:
52 """Public interface for exporting a subset of a data repository.
54 Instances of this class are obtained by calling `Butler.export` as the
55 value returned by that context manager::
57 with butler.export(filename="export.yaml") as export:
58 export.saveDataIds(...)
59 export.saveDatasets(...)
61 Parameters
62 ----------
63 butler : `lsst.daf.butler.direct_butler.DirectButler`
64 Butler to export from.
65 backend : `RepoExportBackend`
66 Implementation class for a particular export file format.
67 directory : `~lsst.resources.ResourcePathExpression`, optional
68 Directory to pass to `Datastore.export`. Can be `None` to use
69 the current working directory.
70 transfer : `str`, optional
71 Transfer mode to pass to `Datastore.export`.
72 """
74 def __init__(
75 self,
76 butler: DirectButler, # Requires butler._registry to work for now.
77 backend: RepoExportBackend,
78 *,
79 directory: ResourcePathExpression | None = None,
80 transfer: str | None = None,
81 ):
82 self._butler = butler
83 self._backend = backend
84 self._directory = directory
85 self._transfer = transfer
86 self._records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]] = defaultdict(dict)
87 self._dataset_ids: set[DatasetId] = set()
88 self._datasets: dict[DatasetType, dict[str, list[FileDataset]]] = defaultdict(
89 lambda: defaultdict(list)
90 )
91 self._collections: dict[str, CollectionRecord] = {}
93 def saveCollection(self, name: str) -> None:
94 """Export the given collection.
96 Parameters
97 ----------
98 name : `str`
99 Name of the collection.
101 Notes
102 -----
103 `~CollectionType.RUN` collections are also exported automatically when
104 any dataset referencing them is exported. They may also be explicitly
105 exported this method to export the collection with no datasets.
106 Duplicate exports of collections are ignored.
108 Exporting a `~CollectionType.TAGGED` or `~CollectionType.CALIBRATION`
109 collection will cause its associations with exported datasets to also
110 be exported, but it does not export those datasets automatically.
112 Exporting a `~CollectionType.CHAINED` collection does not automatically
113 export its child collections; these must be explicitly exported or
114 already be present in the repository they are being imported into.
115 """
116 self._collections[name] = self._butler._registry.get_collection_record(name)
118 def saveDimensionData(
119 self, element: str | DimensionElement, records: Iterable[dict | DimensionRecord]
120 ) -> None:
121 """Export the given dimension records associated with one or more data
122 IDs.
124 Parameters
125 ----------
126 element : `str` or `DimensionElement`
127 `DimensionElement` or `str` indicating the logical table these
128 records are from.
129 records : `~collections.abc.Iterable` [ `DimensionRecord` or `dict` ]
130 Records to export, as an iterable containing `DimensionRecord` or
131 `dict` instances.
132 """
133 if not isinstance(element, DimensionElement):
134 element = self._butler.dimensions[element]
135 for record in records:
136 if not isinstance(record, DimensionRecord):
137 record = element.RecordClass(**record)
138 elif record.definition != element:
139 raise ValueError(
140 f"Mismatch between element={element.name} and "
141 f"dimension record with definition={record.definition.name}."
142 )
143 self._records[element].setdefault(record.dataId, record)
145 def saveDataIds(
146 self,
147 dataIds: Iterable[DataCoordinate],
148 *,
149 elements: Iterable[str | DimensionElement] | None = None,
150 ) -> None:
151 """Export the dimension records associated with one or more data IDs.
153 Parameters
154 ----------
155 dataIds : iterable of `DataCoordinate`
156 Data IDs to export. For large numbers of data IDs obtained by
157 calls to `Registry.queryDataIds`, it will be much more efficient if
158 these are expanded to include records (i.e.
159 `DataCoordinate.hasRecords` returns `True`) prior to the call to
160 `saveDataIds` via e.g. ``Registry.queryDataIds(...).expanded()``.
161 elements : iterable of `DimensionElement` or `str`, optional
162 Dimension elements whose records should be exported. If `None`,
163 records for all dimensions will be exported.
164 """
165 standardized_elements: Set[DimensionElement]
166 if elements is None:
167 standardized_elements = frozenset(
168 element for element in self._butler.dimensions.elements if element.has_own_table
169 )
170 else:
171 standardized_elements = set()
172 for element in elements:
173 if not isinstance(element, DimensionElement):
174 element = self._butler.dimensions[element]
175 if element.has_own_table:
176 standardized_elements.add(element)
178 expanded_data_ids = self._butler._registry.expand_data_ids(dataIds)
179 for dataId in expanded_data_ids:
180 for element_name in dataId.dimensions.elements:
181 record = dataId.records[element_name]
182 if record is not None and record.definition in standardized_elements:
183 self._records[record.definition].setdefault(record.dataId, record)
185 def saveDatasets(
186 self,
187 refs: Iterable[DatasetRef],
188 *,
189 elements: Iterable[str | DimensionElement] | None = None,
190 rewrite: Callable[[FileDataset], FileDataset] | None = None,
191 ) -> None:
192 """Export one or more datasets.
194 This automatically exports any `DatasetType`, `~CollectionType.RUN`
195 collections, and dimension records associated with the datasets.
197 Parameters
198 ----------
199 refs : iterable of `DatasetRef`
200 References to the datasets to export. Their `DatasetRef.id`
201 attributes must not be `None`. Duplicates are automatically
202 ignored. Nested data IDs must have `DataCoordinate.hasRecords`
203 return `True`. If any reference is to a component dataset, the
204 parent will be exported instead.
205 elements : iterable of `DimensionElement` or `str`, optional
206 Dimension elements whose records should be exported; this is
207 forwarded to `saveDataIds` when exporting the data IDs of the
208 given datasets.
209 rewrite : callable, optional
210 A callable that takes a single `FileDataset` argument and returns
211 a modified `FileDataset`. This is typically used to rewrite the
212 path generated by the datastore. If `None`, the `FileDataset`
213 returned by `Datastore.export` will be used directly.
215 Notes
216 -----
217 At present, this only associates datasets with `~CollectionType.RUN`
218 collections. Other collections will be included in the export in the
219 future (once `Registry` provides a way to look up that information).
220 """
221 data_ids = set()
222 refs_to_export = {}
223 for ref in sorted(refs):
224 dataset_id = ref.id
225 # The query interfaces that are often used to generate the refs
226 # passed here often don't remove duplicates, so do that here for
227 # convenience.
228 if dataset_id in self._dataset_ids or dataset_id in refs_to_export:
229 continue
230 # Also convert components to composites.
231 if ref.isComponent():
232 ref = ref.makeCompositeRef()
233 data_ids.add(ref.dataId)
234 refs_to_export[dataset_id] = ref
235 # Do a vectorized datastore export, which might be a lot faster than
236 # one-by-one.
237 exports = self._butler._datastore.export(
238 refs_to_export.values(),
239 directory=self._directory,
240 transfer=self._transfer,
241 )
242 # Export associated data IDs.
243 self.saveDataIds(data_ids, elements=elements)
244 # Rewrite export filenames if desired, and then save them to the
245 # data structure we'll write in `_finish`.
246 # If a single exported FileDataset has multiple DatasetRefs, we save
247 # it with each of them.
248 for file_dataset in exports:
249 if rewrite is not None:
250 file_dataset = rewrite(file_dataset)
251 for ref in file_dataset.refs:
252 assert ref.run is not None
253 self._datasets[ref.datasetType][ref.run].append(file_dataset)
254 self._dataset_ids.update(refs_to_export.keys())
256 def _finish(self) -> None:
257 """Delegate to the backend to finish the export process.
259 For use by `Butler.export` only.
260 """
261 for element in self._butler.dimensions.sorted(self._records.keys()):
262 # To make export deterministic sort the DataCoordinate instances.
263 r = self._records[element]
264 self._backend.saveDimensionData(element, *[r[dataId] for dataId in sorted(r.keys())])
265 for datasetsByRun in self._datasets.values():
266 for run in datasetsByRun:
267 self._collections[run] = self._butler._registry.get_collection_record(run)
268 for collectionName in self._computeSortedCollections():
269 doc = self._butler.registry.getCollectionDocumentation(collectionName)
270 self._backend.saveCollection(self._collections[collectionName], doc)
271 # Sort the dataset types and runs before exporting to ensure
272 # reproducible order in export file.
273 for datasetType in sorted(self._datasets.keys()):
274 for run in sorted(self._datasets[datasetType].keys()):
275 # Sort the FileDataset
276 records = sorted(self._datasets[datasetType][run])
277 self._backend.saveDatasets(datasetType, run, *records)
278 # Export associations between datasets and collections. These need to
279 # be sorted (at two levels; they're dicts) or created more
280 # deterministically, too, which probably involves more data ID sorting.
281 datasetAssociations = self._computeDatasetAssociations()
282 for collection in sorted(datasetAssociations):
283 self._backend.saveDatasetAssociations(
284 collection, self._collections[collection].type, sorted(datasetAssociations[collection])
285 )
286 self._backend.finish()
288 def _computeSortedCollections(self) -> list[str]:
289 """Sort collections in a way that is both deterministic and safe
290 for registering them in a new repo in the presence of nested chains.
292 This method is intended for internal use by `RepoExportContext` only.
294 Returns
295 -------
296 names: `List` [ `str` ]
297 Ordered list of collection names.
298 """
299 # Split collections into CHAINED and everything else, and just
300 # sort "everything else" lexicographically since there are no
301 # dependencies.
302 chains: dict[str, list[str]] = {}
303 result: list[str] = []
304 for record in self._collections.values():
305 if record.type is CollectionType.CHAINED:
306 assert isinstance(record, ChainedCollectionRecord)
307 chains[record.name] = list(record.children)
308 else:
309 result.append(record.name)
310 result.sort()
311 # Sort all chains topologically, breaking ties lexicographically.
312 # Append these to 'result' and remove them from 'chains' as we go.
313 while chains:
314 unblocked = {
315 parent
316 for parent, children in chains.items()
317 if not any(child in chains for child in children)
318 }
319 if not unblocked:
320 raise RuntimeError(
321 f"Apparent cycle in CHAINED collection dependencies involving {unblocked}."
322 )
323 result.extend(sorted(unblocked))
324 for name in unblocked:
325 del chains[name]
326 return result
328 def _computeDatasetAssociations(self) -> dict[str, list[DatasetAssociation]]:
329 """Return datasets-collection associations, grouped by association.
331 This queries for all associations between exported datasets and
332 exported TAGGED or CALIBRATION collections and is intended to be run
333 only by `_finish`, as this ensures all collections and all datasets
334 have already been exported and hence the order in which they are
335 exported does not matter.
337 Returns
338 -------
339 associations : `dict` [ `str`, `list` [ `DatasetAssociation` ] ]
340 Dictionary keyed by collection name, with values lists of structs
341 representing an association between that collection and a dataset.
342 """
343 results = defaultdict(list)
344 for datasetType in self._datasets:
345 # We query for _all_ datasets of each dataset type we export, in
346 # the specific collections we are exporting. The worst-case
347 # efficiency of this is _awful_ (i.e. big repo, exporting a tiny
348 # subset). But we don't have any better options right now; we need
349 # a way to query for a _lot_ of explicitly given dataset_ids, and
350 # the only way to make that scale up is to either upload them to a
351 # temporary table or recognize when they are already in one because
352 # the user passed us a QueryResult object. That's blocked by (at
353 # least) DM-26692.
354 collectionTypes = {CollectionType.TAGGED}
355 if datasetType.isCalibration():
356 collectionTypes.add(CollectionType.CALIBRATION)
357 for association in self._butler.registry.queryDatasetAssociations(
358 datasetType, self._collections.keys(), collectionTypes=collectionTypes, flattenChains=False
359 ):
360 if association.ref.id in self._dataset_ids:
361 results[association.collection].append(association)
362 return results