Coverage for python/lsst/daf/butler/_storage_class.py: 94%
358 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 09:06 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 09:06 +0000
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/>.
28"""Support for Storage Classes."""
30from __future__ import annotations
32__all__ = ("StorageClass", "StorageClassConfig", "StorageClassFactory")
34import builtins
35import hashlib
36import itertools
37import logging
38import os
39from collections import ChainMap
40from collections.abc import Callable, Collection, Mapping, Sequence, Set
41from threading import RLock
42from typing import Any
44import pydantic
45import pydantic_core
47from lsst.utils import doImportType
48from lsst.utils.classes import Singleton
49from lsst.utils.introspection import get_full_type_name
51from ._config import Config, ConfigSubset
52from ._config_support import LookupKey
53from ._storage_class_delegate import StorageClassDelegate
55log = logging.getLogger(__name__)
58class StorageClassConfig(ConfigSubset):
59 """Configuration class for defining Storage Classes."""
61 component = "storageClasses"
62 defaultConfigFile = "storageClasses.yaml"
65# Cache of storage class definitions that have already been derived from a
66# given configuration, keyed by a hash of that configuration. Deriving them is
67# far more expensive than recognizing that we have seen the same definitions
68# before, and identical definitions are derived repeatedly because every
69# Butler applies the same defaults.
70#
71# Sharing instances is safe because a `StorageClass` describes a definition
72# rather than holding state: the only mutation it undergoes is memoization of
73# the Python type and of the converters whose types cannot be imported, both
74# of which are determined by the definition itself. The factory is a singleton
75# and already shares instances between every use of a given definition.
76_derived_cache: dict[str, dict[str, StorageClass]] = {}
78# The environment variable that alters which default configuration files are
79# found, and therefore what a given input configuration expands to.
80_CONFIG_PATH_ENV = "DAF_BUTLER_CONFIG_PATH"
83def _derived_cache_key(config: StorageClassConfig | Config | str) -> str | None:
84 """Return a key identifying the definitions this configuration produces.
86 Parameters
87 ----------
88 config : `StorageClassConfig`, `Config` or `str`
89 Storage class configuration, as passed to
90 `StorageClassFactory.addFromConfig`.
92 Returns
93 -------
94 key : `str` or `None`
95 A hash of the storage class definitions in ``config`` together with
96 the environment that determines which defaults are applied, or `None`
97 if a key cannot be derived cheaply.
99 Notes
100 -----
101 The key is computed from the supplied configuration without expanding it,
102 because expanding it is the cost this is trying to avoid.
104 Serialization does not sort keys, so two configurations holding the same
105 definitions in a different order produce different keys. That costs a
106 cache miss and the definitions are then derived as usual, which is
107 correct but slower; it never produces a wrong answer.
108 """
109 if isinstance(config, StorageClassConfig):
110 # Already a subset, so the data are the definitions themselves.
111 raw: Any = config._data
112 elif isinstance(config, Config):
113 # A configuration that may carry overrides under the component key.
114 # Its absence is meaningful: it means the defaults apply unmodified.
115 raw = config._data.get(StorageClassConfig.component, {})
116 else:
117 # A file path or URI would have to be read to be hashed, so leave it
118 # to the uncached path.
119 return None
121 try:
122 rendered = pydantic_core.to_json([raw, os.environ.get(_CONFIG_PATH_ENV)], fallback=str)
123 except (TypeError, ValueError):
124 return None
125 return hashlib.sha256(rendered).hexdigest()
128class _StorageClassModel(pydantic.BaseModel):
129 """Model class used to validate storage class configuration."""
131 pytype: str | None = None
132 inheritsFrom: str | None = None
133 components: dict[str, str] = pydantic.Field(default_factory=dict)
134 derivedComponents: dict[str, str] = pydantic.Field(default_factory=dict)
135 parameters: list[str] = pydantic.Field(default_factory=list)
136 delegate: str | None = None
137 converters: dict[str, str] = pydantic.Field(default_factory=dict)
140class StorageClass:
141 """Class describing how a label maps to a particular Python type.
143 Parameters
144 ----------
145 name : `str`
146 Name to use for this class.
147 pytype : `type` or `str`
148 Python type (or name of type) to associate with the `StorageClass`.
149 components : `dict`, optional
150 `dict` mapping name of a component to another `StorageClass`.
151 derivedComponents : `dict`, optional
152 `dict` mapping name of a derived component to another `StorageClass`.
153 parameters : `~collections.abc.Sequence` or `~collections.abc.Set`
154 Parameters understood by this `StorageClass` that can control
155 reading of data from datastores.
156 delegate : `str`, optional
157 Fully qualified name of class supporting assembly and disassembly
158 of a `pytype` instance.
159 converters : `dict` [`str`, `str`], optional
160 Mapping of python type to function that can be called to convert
161 that python type to the valid type of this storage class.
162 """
164 def __init__(
165 self,
166 name: str = "",
167 pytype: type | str | None = None,
168 components: dict[str, StorageClass] | None = None,
169 derivedComponents: dict[str, StorageClass] | None = None,
170 parameters: Sequence[str] | Set[str] | None = None,
171 delegate: str | None = None,
172 converters: dict[str, str] | None = None,
173 ):
174 # Merge converters with class defaults.
175 self._converters = {}
176 if converters:
177 self._converters.update(converters)
179 # Version of converters where the python types have been
180 # Do not try to import anything until needed.
181 self._converters_by_type: dict[type, Callable[[Any], Any]] | None = None
183 self.name = name
185 if pytype is None:
186 pytype = object
188 self._pytype: type | None
189 if not isinstance(pytype, str):
190 # Already have a type so store it and get the name
191 self._pytypeName = get_full_type_name(pytype)
192 self._pytype = pytype
193 else:
194 # Store the type name and defer loading of type
195 self._pytypeName = pytype
196 self._pytype = None
198 if components is not None:
199 if len(components) == 1:
200 raise ValueError(
201 f"Composite storage class {name} is not allowed to have"
202 f" only one component '{next(iter(components))}'."
203 " Did you mean it to be a derived component?"
204 )
205 self._components = components
206 else:
207 self._components = {}
208 self._derivedComponents = derivedComponents if derivedComponents is not None else {}
209 self._parameters = frozenset(parameters) if parameters is not None else frozenset()
210 # if the delegate is not None also set it and clear the default
211 # delegate
212 self._delegate: type | None
213 self._delegateClassName: str | None
214 if delegate is not None:
215 self._delegateClassName = delegate
216 self._delegate = None
217 elif components is not None:
218 # We set a default delegate for composites so that a class is
219 # guaranteed to support something if it is a composite.
220 log.debug("Setting default delegate for %s", self.name)
221 self._delegate = StorageClassDelegate
222 self._delegateClassName = get_full_type_name(self._delegate)
223 else:
224 self._delegate = None
225 self._delegateClassName = None
227 @property
228 def components(self) -> Mapping[str, StorageClass]:
229 """Return the components associated with this `StorageClass`."""
230 return self._components
232 @property
233 def derivedComponents(self) -> Mapping[str, StorageClass]:
234 """Return derived components associated with `StorageClass`."""
235 return self._derivedComponents
237 @property
238 def converters(self) -> Mapping[str, str]:
239 """Return the type converters supported by this `StorageClass`."""
240 return self._converters
242 def _get_converters_by_type(self) -> Mapping[type, Callable[[Any], Any]]:
243 """Return the type converters as python types."""
244 if self._converters_by_type is None:
245 self._converters_by_type = {}
247 # Loop over list because the dict can be edited in loop.
248 for candidate_type_str, converter_str in list(self.converters.items()):
249 if hasattr(builtins, candidate_type_str):
250 candidate_type = getattr(builtins, candidate_type_str)
251 else:
252 try:
253 candidate_type = doImportType(candidate_type_str)
254 except ImportError as e:
255 log.warning(
256 "Unable to import type %s associated with storage class %s (%s)",
257 candidate_type_str,
258 self.name,
259 e,
260 )
261 del self._converters[candidate_type_str]
262 continue
264 if hasattr(builtins, converter_str):
265 converter = getattr(builtins, converter_str)
266 else:
267 try:
268 converter = doImportType(converter_str)
269 except ImportError as e:
270 log.warning(
271 "Unable to import conversion function %s associated with storage class %s "
272 "required to convert type %s (%s)",
273 converter_str,
274 self.name,
275 candidate_type_str,
276 e,
277 )
278 del self._converters[candidate_type_str]
279 continue
280 if not callable(converter): 280 ↛ 286line 280 didn't jump to line 286 because the condition on line 280 was never true
281 # doImportType is annotated to return a Type but in actual
282 # fact it can return Any except ModuleType because package
283 # variables can be accessed. This make mypy believe it
284 # is impossible for the return value to not be a callable
285 # so we must ignore the warning.
286 log.warning( # type: ignore
287 "Conversion function %s associated with storage class "
288 "%s to convert type %s is not a callable.",
289 converter_str,
290 self.name,
291 candidate_type_str,
292 )
293 del self._converters[candidate_type_str]
294 continue
295 self._converters_by_type[candidate_type] = converter
296 return self._converters_by_type
298 @property
299 def parameters(self) -> set[str]:
300 """Return `set` of names of supported parameters."""
301 return set(self._parameters)
303 @property
304 def pytype(self) -> type:
305 """Return Python type associated with this `StorageClass`."""
306 if self._pytype is not None:
307 return self._pytype
309 if hasattr(builtins, self._pytypeName):
310 pytype = getattr(builtins, self._pytypeName)
311 else:
312 pytype = doImportType(self._pytypeName)
313 self._pytype = pytype
314 return self._pytype
316 @property
317 def delegateClass(self) -> type | None:
318 """Class to use to delegate type-specific actions."""
319 if self._delegate is not None:
320 return self._delegate
321 if self._delegateClassName is None:
322 return None
323 delegate_class = doImportType(self._delegateClassName)
324 self._delegate = delegate_class
325 return self._delegate
327 def allComponents(self) -> Mapping[str, StorageClass]:
328 """Return all defined components.
330 This mapping includes all the derived and read/write components
331 for the corresponding storage class.
333 Returns
334 -------
335 comp : `dict` of [`str`, `StorageClass`]
336 The component name to storage class mapping.
337 """
338 return ChainMap(self._components, self._derivedComponents)
340 def delegate(self) -> StorageClassDelegate:
341 """Return an instance of a storage class delegate.
343 Returns
344 -------
345 delegate : `StorageClassDelegate`
346 Instance of the delegate associated with this `StorageClass`.
347 The delegate is constructed with this `StorageClass`.
349 Raises
350 ------
351 TypeError
352 This StorageClass has no associated delegate.
353 """
354 cls = self.delegateClass
355 if cls is None:
356 raise TypeError(f"No delegate class is associated with StorageClass {self.name}")
357 return cls(storageClass=self)
359 def isComposite(self) -> bool:
360 """Return Boolean indicating whether this is a composite or not.
362 Returns
363 -------
364 isComposite : `bool`
365 `True` if this `StorageClass` is a composite, `False`
366 otherwise.
367 """
368 if self.components:
369 return True
370 return False
372 def _lookupNames(self) -> tuple[LookupKey, ...]:
373 """Keys to use when looking up this DatasetRef in a configuration.
375 The names are returned in order of priority.
377 Returns
378 -------
379 names : `tuple` of `LookupKey`
380 Tuple of a `LookupKey` using the `StorageClass` name.
381 """
382 return (LookupKey(name=self.name),)
384 def knownParameters(self) -> set[str]:
385 """Return set of all parameters known to this `StorageClass`.
387 The set includes parameters understood by components of a composite.
389 Returns
390 -------
391 known : `set`
392 All parameter keys of this `StorageClass` and the component
393 storage classes.
394 """
395 known = set(self._parameters)
396 for sc in self.components.values():
397 known.update(sc.knownParameters())
398 return known
400 def validateParameters(self, parameters: Collection | None = None) -> None:
401 """Check that the parameters are known to this `StorageClass`.
403 Does not check the values.
405 Parameters
406 ----------
407 parameters : `~collections.abc.Collection`, optional
408 Collection containing the parameters. Can be `dict`-like or
409 `set`-like. The parameter values are not checked.
410 If no parameters are supplied, always returns without error.
412 Raises
413 ------
414 KeyError
415 Some parameters are not understood by this `StorageClass`.
416 """
417 # No parameters is always okay
418 if not parameters:
419 return
421 # Extract the important information into a set. Works for dict and
422 # list.
423 external = set(parameters)
425 diff = external - self.knownParameters()
426 if diff:
427 s = "s" if len(diff) > 1 else ""
428 unknown = "', '".join(diff)
429 raise KeyError(f"Parameter{s} '{unknown}' not understood by StorageClass {self.name}")
431 def filterParameters(
432 self, parameters: Mapping[str, Any] | None, subset: Collection | None = None
433 ) -> Mapping[str, Any]:
434 """Filter out parameters that are not known to this `StorageClass`.
436 Parameters
437 ----------
438 parameters : `~collections.abc.Mapping`, optional
439 Candidate parameters. Can be `None` if no parameters have
440 been provided.
441 subset : `~collections.abc.Collection`, optional
442 Subset of supported parameters that the caller is interested
443 in using. The subset must be known to the `StorageClass`
444 if specified. If `None` the supplied parameters will all
445 be checked, else only the keys in this set will be checked.
447 Returns
448 -------
449 filtered : `~collections.abc.Mapping`
450 Valid parameters. Empty `dict` if none are suitable.
452 Raises
453 ------
454 ValueError
455 Raised if the provided subset is not a subset of the supported
456 parameters or if it is an empty set.
457 """
458 if not parameters:
459 return {}
461 known = self.knownParameters()
463 if subset is not None:
464 if not subset: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 raise ValueError("Specified a parameter subset but it was empty")
466 subset = set(subset)
467 if not subset.issubset(known): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 raise ValueError(f"Requested subset ({subset}) is not a subset of known parameters ({known})")
469 wanted = subset
470 else:
471 wanted = known
473 return {k: parameters[k] for k in wanted if k in parameters}
475 def validateInstance(self, instance: Any) -> bool:
476 """Check that the supplied Python object has the expected Python type.
478 Parameters
479 ----------
480 instance : `object`
481 Object to check.
483 Returns
484 -------
485 isOk : `bool`
486 True if the supplied instance object can be handled by this
487 `StorageClass`, False otherwise.
488 """
489 return isinstance(instance, self.pytype)
491 def is_type(self, other: type, compare_types: bool = False) -> bool:
492 """Return Boolean indicating whether the supplied type matches
493 the type in this `StorageClass`.
495 Parameters
496 ----------
497 other : `type`
498 The type to be checked.
499 compare_types : `bool`, optional
500 If `True` the python type will be used in the comparison
501 if the type names do not match. This may trigger an import
502 of code and so can be slower.
504 Returns
505 -------
506 match : `bool`
507 `True` if the types are equal.
509 Notes
510 -----
511 If this `StorageClass` has not yet imported the Python type the
512 check is done against the full type name, this prevents an attempt
513 to import the type when it will likely not match.
514 """
515 if self._pytype:
516 return self._pytype is other
518 other_name = get_full_type_name(other)
519 if self._pytypeName == other_name:
520 return True
522 if compare_types:
523 # Must protect against the import failing.
524 try:
525 return self.pytype is other
526 except Exception:
527 pass
529 return False
531 def can_convert(self, other: StorageClass) -> bool:
532 """Return `True` if this storage class can convert python types
533 in the other storage class.
535 Parameters
536 ----------
537 other : `StorageClass`
538 The storage class to check.
540 Returns
541 -------
542 can : `bool`
543 `True` if this storage class has a registered converter for
544 the python type associated with the other storage class. That
545 converter will convert the other python type to the one associated
546 with this storage class.
547 """
548 if other.name == self.name:
549 # Identical storage classes are compatible.
550 return True
552 # It may be that the storage class being compared is not
553 # available because the python type can't be imported. In that
554 # case conversion must be impossible.
555 try:
556 other_pytype = other.pytype
557 except Exception:
558 return False
560 # Or even this storage class itself can not have the type imported.
561 try:
562 self_pytype = self.pytype
563 except Exception:
564 return False
566 if issubclass(other_pytype, self_pytype):
567 # Storage classes have different names but the same python type.
568 return True
570 for candidate_type in self._get_converters_by_type():
571 if issubclass(other_pytype, candidate_type):
572 return True
573 return False
575 def coerce_type(self, incorrect: Any) -> Any:
576 """Coerce the supplied incorrect instance to the python type
577 associated with this `StorageClass`.
579 Parameters
580 ----------
581 incorrect : `object`
582 An object that might be the incorrect type.
584 Returns
585 -------
586 correct : `object`
587 An object that matches the python type of this `StorageClass`.
588 Can be the same object as given. If `None`, `None` will be
589 returned.
591 Raises
592 ------
593 TypeError
594 Raised if no conversion can be found.
595 """
596 if incorrect is None:
597 return None
599 # Possible this is the correct type already.
600 if self.validateInstance(incorrect):
601 return incorrect
603 # Check each registered converter.
604 for candidate_type, converter in self._get_converters_by_type().items():
605 if isinstance(incorrect, candidate_type):
606 try:
607 return converter(incorrect)
608 except Exception:
609 log.error(
610 "Converter %s failed to convert type %s",
611 get_full_type_name(converter),
612 get_full_type_name(incorrect),
613 )
614 raise
615 raise TypeError(
616 "Type does not match and no valid converter found to convert"
617 f" '{get_full_type_name(incorrect)}' to '{get_full_type_name(self.pytype)}'"
618 )
620 def __eq__(self, other: Any) -> bool:
621 """Equality checks name, pytype name, delegate name, and components."""
622 if not isinstance(other, StorageClass):
623 return NotImplemented
625 if self.name != other.name:
626 return False
628 # We must compare pytype and delegate by name since we do not want
629 # to trigger an import of external module code here
630 if self._delegateClassName != other._delegateClassName:
631 return False
632 if self._pytypeName != other._pytypeName:
633 return False
635 # Ensure we have the same component keys in each
636 if set(self.components.keys()) != set(other.components.keys()):
637 return False
639 # Same parameters
640 if self.parameters != other.parameters:
641 return False
643 # Ensure that all the components have the same type
644 return all(self.components[k] == other.components[k] for k in self.components)
646 def __hash__(self) -> int:
647 return hash(self.name)
649 def __repr__(self) -> str:
650 optionals: dict[str, Any] = {}
651 if self._pytypeName != "object": 651 ↛ 653line 651 didn't jump to line 653 because the condition on line 651 was always true
652 optionals["pytype"] = self._pytypeName
653 if self._delegateClassName is not None:
654 optionals["delegate"] = self._delegateClassName
655 if self._parameters: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 optionals["parameters"] = self._parameters
657 if self.components:
658 optionals["components"] = self.components
659 if self.converters:
660 optionals["converters"] = self.converters
662 # order is preserved in the dict
663 options = ", ".join(f"{k}={v!r}" for k, v in optionals.items())
665 # Start with mandatory fields
666 r = f"{self.__class__.__name__}({self.name!r}"
667 if options: 667 ↛ 669line 667 didn't jump to line 669 because the condition on line 667 was always true
668 r = r + ", " + options
669 r = r + ")"
670 return r
672 def __str__(self) -> str:
673 return self.name
676class StorageClassFactory(metaclass=Singleton):
677 """Factory for `StorageClass` instances.
679 This class is a singleton, with each instance sharing the pool of
680 StorageClasses. Since code can not know whether it is the first
681 time the instance has been created, the constructor takes no arguments.
682 To populate the factory with storage classes, a call to
683 `~StorageClassFactory.addFromConfig()` should be made.
685 Parameters
686 ----------
687 config : `StorageClassConfig` or `str`, optional
688 Load configuration. In a ButlerConfig` the relevant configuration
689 is located in the ``storageClasses`` section.
690 """
692 def __init__(self, config: StorageClassConfig | str | None = None):
693 self._storageClasses: dict[str, StorageClass] = {}
694 self._lock = RLock()
696 # Always seed with the default config
697 self.addFromConfig(StorageClassConfig())
699 if config is not None: 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true
700 self.addFromConfig(config)
702 def __str__(self) -> str:
703 """Return summary of factory.
705 Returns
706 -------
707 summary : `str`
708 Summary of the factory status.
709 """
710 with self._lock:
711 sep = "\n"
712 return f"""Number of registered StorageClasses: {len(self._storageClasses)}
714StorageClasses
715--------------
716{sep.join(f"{self._storageClasses[s]!r}" for s in sorted(self._storageClasses))}
717"""
719 def __contains__(self, storageClassOrName: object) -> bool:
720 with self._lock:
721 if isinstance(storageClassOrName, str):
722 return storageClassOrName in self._storageClasses
723 elif isinstance(storageClassOrName, StorageClass):
724 return storageClassOrName.name in self._storageClasses
725 return False
727 def addFromConfig(self, config: StorageClassConfig | Config | str) -> None:
728 """Add more `StorageClass` definitions from a config file.
730 Parameters
731 ----------
732 config : `StorageClassConfig`, `Config` or `str`
733 Storage class configuration. Can contain a ``storageClasses``
734 key if part of a global configuration.
736 Notes
737 -----
738 Definitions derived from a configuration that has been seen before are
739 reused. Registration itself, including the check that an existing
740 definition is compatible, is always performed.
741 """
742 cache_key = _derived_cache_key(config)
743 if cache_key is not None and (derived := _derived_cache.get(cache_key)) is not None:
744 with self._lock:
745 for storageClass in derived.values():
746 self.registerStorageClass(storageClass)
747 return
749 sconfig = StorageClassConfig(config)
751 # Since we can not assume that we will get definitions of
752 # components or parents before their classes are defined
753 # we have a helper function that we can call recursively
754 # to extract definitions from the configuration.
755 def processStorageClass(name: str, _sconfig: StorageClassConfig, msg: str = "") -> StorageClass:
756 # This might have already been processed through recursion, or
757 # already present in the factory.
758 if name not in _sconfig:
759 return self.getStorageClass(name)
760 try:
761 model = _StorageClassModel.model_validate(_sconfig.pop(name))
762 except Exception as err:
763 err.add_note(msg)
764 raise
765 components: dict[str, StorageClass] = {}
766 derivedComponents: dict[str, StorageClass] = {}
767 parameters: set[str] = set()
768 delegate: str | None = None
769 converters: dict[str, str] = {}
770 if model.inheritsFrom is not None:
771 base = processStorageClass(model.inheritsFrom, _sconfig, msg + f"; processing base of {name}")
772 pytype = base._pytypeName
773 components.update(base.components)
774 derivedComponents.update(base.derivedComponents)
775 parameters.update(base.parameters)
776 delegate = base._delegateClassName
777 converters.update(base.converters)
778 if model.pytype is not None:
779 pytype = model.pytype
780 for k, v in model.components.items():
781 components[k] = processStorageClass(
782 v, _sconfig, msg + f"; processing component {k} of {name}"
783 )
784 for k, v in model.derivedComponents.items():
785 derivedComponents[k] = processStorageClass(
786 v, _sconfig, msg + f"; processing derivedCmponent {k} of {name}"
787 )
788 parameters.update(model.parameters)
789 if model.delegate is not None:
790 delegate = model.delegate
791 converters.update(model.converters)
792 result = StorageClass(
793 name=name,
794 pytype=pytype,
795 components=components,
796 derivedComponents=derivedComponents,
797 parameters=parameters,
798 delegate=delegate,
799 converters=converters,
800 )
801 self.registerStorageClass(result, msg=msg)
802 return result
804 # In case there is a problem, construct a context message for any
805 # error reporting.
806 files = [str(f) for f in itertools.chain([sconfig.configFile], sconfig.filesRead) if f]
807 context = f"when adding definitions from {', '.join(files)}" if files else ""
808 log.debug("Adding definitions from config %s", ", ".join(files))
810 with self._lock:
811 # Processing consumes entries from sconfig, so record the names
812 # first in order to collect the results afterwards.
813 names = list(sconfig.keys())
814 for name in names:
815 processStorageClass(name, sconfig, context)
817 if cache_key is not None:
818 _derived_cache[cache_key] = {
819 name: self._storageClasses[name] for name in names if name in self._storageClasses
820 }
822 def getStorageClass(self, storageClassName: str) -> StorageClass:
823 """Get a StorageClass instance associated with the supplied name.
825 Parameters
826 ----------
827 storageClassName : `str`
828 Name of the storage class to retrieve.
830 Returns
831 -------
832 instance : `StorageClass`
833 Instance of the correct `StorageClass`.
835 Raises
836 ------
837 KeyError
838 The requested storage class name is not registered.
839 """
840 with self._lock:
841 return self._storageClasses[storageClassName]
843 def findStorageClass(self, pytype: type, compare_types: bool = False) -> StorageClass:
844 """Find the storage class associated with this python type.
846 Parameters
847 ----------
848 pytype : `type`
849 The Python type to be matched.
850 compare_types : `bool`, optional
851 If `False`, the type will be checked against name of the python
852 type. This comparison is always done first. If `True` and the
853 string comparison failed, each candidate storage class will be
854 forced to have its type imported. This can be significantly slower.
856 Returns
857 -------
858 storageClass : `StorageClass`
859 The matching storage class.
861 Raises
862 ------
863 KeyError
864 Raised if no match could be found.
866 Notes
867 -----
868 It is possible for a python type to be associated with multiple
869 storage classes. This method will currently return the first that
870 matches.
871 """
872 with self._lock:
873 result = self._find_storage_class(pytype, False)
874 if result:
875 return result
877 if compare_types: 877 ↛ 884line 877 didn't jump to line 884 because the condition on line 877 was always true
878 # The fast comparison failed and we were asked to try the
879 # variant that might involve code imports.
880 result = self._find_storage_class(pytype, True)
881 if result: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true
882 return result
884 raise KeyError(
885 f"Unable to find a StorageClass associated with type {get_full_type_name(pytype)!r}"
886 )
888 def _find_storage_class(self, pytype: type, compare_types: bool) -> StorageClass | None:
889 """Iterate through all storage classes to find a match.
891 Parameters
892 ----------
893 pytype : `type`
894 The Python type to be matched.
895 compare_types : `bool`, optional
896 Whether to use type name matching or explicit type matching.
897 The latter can be slower.
899 Returns
900 -------
901 storageClass : `StorageClass` or `None`
902 The matching storage class, or `None` if no match was found.
904 Notes
905 -----
906 Helper method for ``findStorageClass``.
907 """
908 with self._lock:
909 for storageClass in self._storageClasses.values():
910 if storageClass.is_type(pytype, compare_types=compare_types):
911 return storageClass
912 return None
914 def registerStorageClass(self, storageClass: StorageClass, msg: str | None = None) -> None:
915 """Store the `StorageClass` in the factory.
917 Will be indexed by `StorageClass.name` and will return instances
918 of the supplied `StorageClass`.
920 Parameters
921 ----------
922 storageClass : `StorageClass`
923 Type of the Python `StorageClass` to register.
924 msg : `str`, optional
925 Additional message string to be included in any error message.
927 Raises
928 ------
929 ValueError
930 If a storage class has already been registered with
931 that storage class name and the previous definition differs.
932 """
933 with self._lock:
934 if storageClass.name in self._storageClasses:
935 existing = self.getStorageClass(storageClass.name)
936 if existing != storageClass:
937 errmsg = f" {msg}" if msg else ""
938 raise ValueError(
939 f"New definition for StorageClass {storageClass.name} ({storageClass!r}) "
940 f"differs from current definition ({existing!r}){errmsg}"
941 )
942 if type(existing) is StorageClass and type(storageClass) is not StorageClass: 942 ↛ 944line 942 didn't jump to line 944 because the condition on line 942 was never true
943 # Replace generic with specialist subclass equivalent.
944 self._storageClasses[storageClass.name] = storageClass
945 else:
946 self._storageClasses[storageClass.name] = storageClass
948 def _unregisterStorageClass(self, storageClassName: str) -> None:
949 """Remove the named StorageClass from the factory.
951 Parameters
952 ----------
953 storageClassName : `str`
954 Name of storage class to remove.
956 Raises
957 ------
958 KeyError
959 The named storage class is not registered.
961 Notes
962 -----
963 This method is intended to simplify testing of StorageClassFactory
964 functionality and it is not expected to be required for normal usage.
965 """
966 with self._lock:
967 del self._storageClasses[storageClassName]
969 def reset(self) -> None:
970 """Remove all storage class entries from factory and reset to
971 initial state.
973 This is useful for test code where a known start state is useful.
974 """
975 with self._lock:
976 self._storageClasses.clear()
977 # Seed with the default config.
978 self.addFromConfig(StorageClassConfig())