Coverage for python/lsst/verify/specset.py: 77%
350 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-10 09:03 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-10 09:03 +0000
1# This file is part of verify.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21__all__ = ['SpecificationSet']
23from collections import OrderedDict
24import copy
25import os
26import re
28from astropy.table import Table
30from lsst.resources import ResourcePath
31from lsst.utils import getPackageDir
33from .errors import SpecificationResolutionError
34from .jsonmixin import JsonSerializationMixin
35from .naming import Name
36from .spec.base import Specification
37from .spec.threshold import ThresholdSpecification
38from .yamlutils import merge_documents, load_all_ordered_yaml
39from .report import Report
42# Pattern for SpecificationPartial names
43# package:path#name
44PARTIAL_PATTERN = re.compile(r'^(?:(?P<package>\S+):)'
45 r'?(?P<path>\S+)?#(?P<name>\S+)$')
48class SpecificationSet(JsonSerializationMixin):
49 r"""A collection of `Specification`\ s.
51 Parameters
52 ----------
53 specifications : `list` or `tuple` of `Specification` instances
54 A sequence of `Specification`-type instances.
55 partials : `list` or `tuple` of `SpecificationPartial` instances
56 A sequence of `SpecificationPartial` instances. These partials
57 can be used as bases for specification definitions.
58 """
60 def __init__(self, specifications=None, partials=None):
61 # Specifications, keyed by Name (a specification name)
62 self._specs = {}
64 # SpecificationPartial instances, keyed by the fully-qualified
65 # name: ``package_name:yaml_id#name``.
66 self._partials = {}
68 if specifications is not None:
69 for spec in specifications:
70 if not isinstance(spec, Specification): 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 message = '{0!r} must be a Specification type'
72 raise TypeError(message.format(spec))
74 self._specs[spec.name] = spec
76 if partials is not None:
77 for partial in partials:
78 if not isinstance(partial, SpecificationPartial): 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 message = '{0!r} must be a SpecificationPartial type'
80 raise TypeError(message.format(partial))
82 self._partials[partial.name] = partial
84 @classmethod
85 def deserialize(cls, specifications=None):
86 """Deserialize a specification set from a JSON serialization.
88 Parameters
89 ----------
90 specifications : `list`, optional
91 List of specification JSON objects.
93 Returns
94 -------
95 spec_set : `SpecificationSet`
96 `SpecificationSet` instance.
97 """
98 instance = cls()
100 if specifications is not None: 100 ↛ 113line 100 didn't jump to line 113 because the condition on line 100 was always true
101 for spec_doc in specifications:
102 # FIXME DM-8477 Need a registry to support multiple types
103 # check type
104 if 'threshold' in spec_doc: 104 ↛ 107line 104 didn't jump to line 107 because the condition on line 104 was always true
105 spec = ThresholdSpecification.deserialize(**spec_doc)
106 else:
107 message = ("We only support threshold-type "
108 "specifications\n"
109 "{0!r}".format(spec_doc))
110 raise NotImplementedError(message)
111 instance.insert(spec)
113 return instance
115 @classmethod
116 def load_metrics_package(cls, package_name_or_path='verify_metrics',
117 subset=None):
118 """Create a `SpecificationSet` from an Verification Framework metrics
119 package.
121 Parameters
122 ----------
123 package_name_or_path : `str`, optional
124 Name of an EUPS package that hosts metric and specification
125 definition YAML files **or** the file path to a metrics package.
126 ``verify_metrics`` is the default package, and is where metrics
127 and specifications are defined for most packages.
128 subset : `str`, optional
129 If set, only specifications defined for this package are loaded.
130 For example, if ``subset='validate_drp'``, only ``validate_drp``
131 specifications are included in the SpecificationSet. This argument
132 is equivalent to the `SpecificationSet.subset` method. Default
133 is `None`.
135 Returns
136 -------
137 spec_set : `SpecificationSet`
138 A `SpecificationSet` containing `Specification` instances.
140 See also
141 --------
142 lsst.verify.SpecificationSet.load_single_package
144 Notes
145 -----
146 EUPS packages that host metrics and specification definitions for the
147 Verification Framework have top-level directories named ``'metrics'``
148 and ``'specs'``.
150 Within ``'specs/'``, directories are named after *packages* that
151 have defined metrics. Contained within these directories are YAML files
152 defining specifications for those metrics.
154 To make a `SpecificationSet` from a single package's YAML definition
155 directory that **is not** contained in a metrics package, use
156 `load_single_package` instead.
157 """
158 try:
159 # Try an EUPS package name
160 getPackageDir(package_name_or_path)
161 except LookupError:
162 # Try as a filesystem path (or URI) instead
163 package_dir = ResourcePath(package_name_or_path,
164 forceDirectory=True,
165 forceAbsolute=True)
166 else:
167 package_dir = ResourcePath(
168 f"eups://{package_name_or_path}/",
169 forceDirectory=True)
171 specs_dirname = package_dir.join('specs', forceDirectory=True)
172 if not specs_dirname.exists(): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 message = 'Specifications directory {0} not found'
174 raise OSError(message.format(specs_dirname))
176 instance = cls()
178 if subset is not None: 178 ↛ 180line 178 didn't jump to line 180 because the condition on line 178 was never true
179 # Load specifications only for the package given by `subset`
180 package_names = [subset]
181 else:
182 # Load specifications for each 'package' within specs/
183 _, package_names, _ = next(specs_dirname.walk())
185 for name in package_names:
186 package_specs_dirname = specs_dirname.join(name,
187 forceDirectory=True)
188 if not package_specs_dirname.exists(): 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 continue
190 instance._load_package_dir(package_specs_dirname)
192 return instance
194 @classmethod
195 def load_single_package(cls, package_specs_dirname):
196 """Create a `SpecificationSet` from a filesystem directory containing
197 specification YAML files for a single package.
199 Parameters
200 ----------
201 package_specs_dirname : `str`
202 Directory containing specification definition YAML files for
203 metrics of a single package. The name of this directory (final
204 path component) is taken as the name of the package.
206 Returns
207 -------
208 spec_set : `SpecificationSet`
209 A `SpecificationSet` containing `Specification` instances.
211 See also
212 --------
213 lsst.verify.SpecificationSet.load_metrics_package
215 Notes
216 -----
217 This SpecificationSet constructor is useful for loading specifications
218 from a directory containing specification definitions for a single
219 package. The directory name is interpreted as a package name
220 for fully-qualified metric and specification names.
222 To load a Verification Framework metrics package, like
223 ``verify_metrics``, with specifications for multple packages,
224 use `load_metrics_packge` instead.
225 """
226 instance = cls()
227 instance._load_package_dir(package_specs_dirname)
229 return instance
231 def _load_package_dir(self, package_specs_dirname):
232 package_specs_dirname = ResourcePath(package_specs_dirname,
233 forceDirectory=True,
234 forceAbsolute=True)
236 all_docs = []
238 yaml_files = ResourcePath.findFileResources(
239 [package_specs_dirname], file_filter=r'.*\.(yaml|yml)$')
240 for filename in yaml_files:
241 spec_docs, partial_docs = SpecificationSet._load_yaml_file(
242 filename,
243 package_specs_dirname)
244 all_docs.extend(partial_docs)
245 all_docs.extend(spec_docs)
247 # resolve inheritance and Specification* instances when possible
248 while len(all_docs) > 0:
249 redo_queue = []
251 for doc in all_docs:
252 try:
253 doc = self.resolve_document(doc)
254 except SpecificationResolutionError:
255 # try again later
256 redo_queue.append(doc)
257 continue
259 if 'id' in doc:
260 partial = SpecificationPartial(doc)
261 self._partials[partial.name] = partial
262 else:
263 # Make sure the name is fully qualified
264 # since _process_specification_yaml_doc may not have
265 # finished this yet
266 doc['name'] = SpecificationSet._normalize_spec_name(
267 doc['name'], metric=doc.get('metric', None),
268 package=doc.get('package', None))
270 # FIXME DM-8477 Need a registry to support multiple types
271 if 'threshold' not in doc: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 message = ("We only support threshold-type "
273 "specifications\n"
274 "{0!r}".format(doc))
275 raise NotImplementedError(message)
276 spec = ThresholdSpecification.deserialize(**doc)
278 name = spec.name
280 if not name.is_fq: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 message = (
282 'Fully-qualified name not resolved for'
283 '{0!s}'.format(spec))
284 raise SpecificationResolutionError(message)
286 self._specs[name] = spec
288 if len(redo_queue) == len(all_docs): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 message = ("There are unresolved specification "
290 "documents: {0!r}")
291 raise SpecificationResolutionError(message.format(redo_queue))
293 all_docs = redo_queue
295 @staticmethod
296 def _load_yaml_file(yaml_file_path, package_dirname):
297 r"""Ingest specifications and partials from a single YAML file.
299 Parameters
300 ----------
301 yaml_file_path : `str`
302 File path of the specification YAML file.
303 package_dirname : `str`
304 Path of the root directory for a package's specifications.
306 Returns
307 -------
308 spec_docs : `list`
309 Specification YAML documents (`~collections.OrderedDict`\ s).
310 partial_docs : `list`
311 Specificaton partial YAML documents
312 (`~collections.OrderedDict`\ s).
314 Notes
315 -----
316 As it loads specification and specification partial documents from
317 YAML, it normalizes and enriches the documents with context necessary
318 for constructing Specification and SpecificationPartial instances
319 in other methods:
321 - A ``'package`` field is added.
322 - A ``'metric'`` field is added, if possible.
323 - Specification names are made fully-qualified with the
324 format ``package.metric.spec_name`` if possible (as `str`).
325 - Partial IDs are fully-qualified with the format
326 ``package:relative_yaml_path_without_extension#id``, for example
327 ``validate_drp:custom/gri#base``.
328 - The ``base`` field is processed so that each partial or specification
329 name is fully-qualified.
330 """
331 # Ensure paths are absolute so we can make relative paths and
332 # determine the package name from the last directory component of
333 # the package_dirname.
334 package_dirname = ResourcePath(package_dirname,
335 forceDirectory=True,
336 forceAbsolute=True)
337 yaml_file_path = ResourcePath(yaml_file_path, forceAbsolute=True)
339 if not package_dirname.exists(): 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 message = 'Specification package directory {0!r} not found.'
341 raise OSError(message.format(package_dirname))
342 if not yaml_file_path.exists(): 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 message = 'Specification YAML file {0!r} not found.'
344 raise OSError(message.format(yaml_file_path))
346 # Name of the stack package these specifcation belong to, based
347 # on our metrics/specification package directory structure. This is
348 # the final directory component of the package specs directory.
349 package_name = os.path.split(
350 package_dirname.path.removesuffix('/'))[-1]
352 # path identifier used in names for partials does not have an
353 # extension, and uses '/' directory separators (as produced by
354 # ResourcePath.relative_to).
355 yaml_id = yaml_file_path.relative_to(package_dirname)
356 yaml_id = os.path.splitext(yaml_id)[0]
358 spec_docs = []
359 partial_docs = []
360 with yaml_file_path.open() as stream:
361 parsed_docs = load_all_ordered_yaml(stream)
363 for doc in parsed_docs:
364 doc['package'] = package_name
366 if 'id' in doc:
367 # Must be a partial
368 doc = SpecificationSet._process_partial_yaml_doc(
369 doc, yaml_id)
370 partial_docs.append(doc)
372 else:
373 # Must be a specification
374 doc = SpecificationSet._process_specification_yaml_doc(
375 doc, yaml_id)
376 spec_docs.append(doc)
378 return spec_docs, partial_docs
380 @staticmethod
381 def _process_specification_yaml_doc(doc, yaml_id):
382 """Process a specification yaml document.
384 Principle functionality is:
386 1. Make ``name`` fully qualified (if possible).
387 2. Add ``metric`` field (if possible).
388 3. Add ``package`` field (if possible).
389 """
390 # Ensure name is fully specified
391 metric = doc.get('metric', None)
392 package = doc.get('package', None)
394 try:
395 doc['name'] = SpecificationSet._normalize_spec_name(
396 doc['name'], metric=metric, package=package)
398 _name = Name(doc['name'])
399 doc['metric'] = _name.metric
400 doc['package'] - _name.package
401 except TypeError:
402 # Can't resolve the fully-qualified specification
403 # name until inheritance is resolved. No big deal.
404 pass
406 # Make all bases fully-specified
407 if 'base' in doc:
408 processed_bases = SpecificationSet._process_bases(
409 doc['base'], doc['package'], yaml_id)
410 doc['base'] = processed_bases
412 return doc
414 @staticmethod
415 def _process_partial_yaml_doc(doc, yaml_id):
416 """Process a specification yaml document.
418 Principle functionality is:
420 1. Make `id` fully specified.
421 2. Make bases fully specified.
422 """
423 package = doc['package']
425 # Ensure the id is fully specified
426 doc['id'] = SpecificationSet._normalize_partial_name(
427 doc['id'],
428 current_yaml_id=yaml_id,
429 package=package)
431 # Make all bases fully-specified
432 if 'base' in doc:
433 processed_bases = SpecificationSet._process_bases(
434 doc['base'], doc['package'], yaml_id)
435 doc['base'] = processed_bases
437 return doc
439 @staticmethod
440 def _process_bases(bases, package_name, yaml_id):
441 if not isinstance(bases, list):
442 bases = [bases]
444 processed_bases = []
445 for base_name in bases:
446 if '#' in base_name:
447 # Base name points is a partial
448 base_name = SpecificationSet._normalize_partial_name(
449 base_name,
450 current_yaml_id=yaml_id,
451 package=package_name)
452 else:
453 # Base name points to a specification
454 base_name = SpecificationSet._normalize_spec_name(
455 base_name,
456 package=package_name)
458 processed_bases.append(base_name)
460 return processed_bases
462 @staticmethod
463 def _normalize_partial_name(name, current_yaml_id=None, package=None):
464 """Normalize a partial's identifier.
466 >>> SpecificationSet._normalize_partial_name(
467 ... '#base',
468 ... current_yaml_id='custom/bases',
469 ... package='validate_drp')
470 'validate_drp:custom/bases#base'
471 """
472 if '#' not in name:
473 # Name is probably coming from a partial's own `id` field
474 # which just has the post-# part of a specification's fully
475 # qualified name.
476 name = '#' + name
478 matches = PARTIAL_PATTERN.search(name)
480 # Use info from user arguments if not given directly.
481 # Thus a user can't override info already in the name
482 _package = matches.group('package')
483 if _package is None: 483 ↛ 485line 483 didn't jump to line 485 because the condition on line 483 was always true
484 _package = package
485 _path = matches.group('path')
486 if _path is None:
487 _path = current_yaml_id
488 partial_name = matches.group('name')
490 # Create the fully-specified name
491 fmt = '{package}:{path}#{name}'
492 return fmt.format(package=_package,
493 path=_path,
494 name=partial_name)
496 @staticmethod
497 def _normalize_spec_name(name, metric=None, package=None):
498 """Normalize a specification name to a fully-qualified specification
499 name.
501 >>> SpecificationSet._normalize_spec_name('PA1.design',
502 ... package='validate_drp')
503 'validate_drp.PA1.design'
504 """
505 name = Name(package=package, metric=metric, spec=name)
506 return name.fqn
508 @property
509 def json(self):
510 doc = JsonSerializationMixin._jsonify_list(
511 [spec for name, spec in self.items()]
512 )
513 return doc
515 def __str__(self):
516 count = len(self)
517 if count == 0:
518 count_str = 'empty'
519 elif count == 1:
520 count_str = '1 Specification'
521 else:
522 count_str = '{count:d} Specifications'.format(count=count)
523 return '<SpecificationSet: {0}>'.format(count_str)
525 def __len__(self):
526 """Number of `Specifications` in the set."""
527 return len(self._specs)
529 def __contains__(self, name):
530 """Check if the set contains a `Specification` by name."""
531 if isinstance(name, str) and '#' in name:
532 # must be a partial's name
533 return name in self._partials
535 else:
536 # must be a specification.
537 if not isinstance(name, Name):
538 name = Name(spec=name)
540 return name in self._specs
542 def __getitem__(self, name):
543 """Retrive a Specification or a SpecificationPartial."""
544 if isinstance(name, str) and '#' in name: 544 ↛ 546line 544 didn't jump to line 546 because the condition on line 544 was never true
545 # must be a partial's name
546 return self._partials[name]
548 else:
549 # must be a specification.
550 if not isinstance(name, Name):
551 name = Name(spec=name)
553 if not name.is_spec: 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 message = 'Expected key {0!r} to resolve a specification'
555 raise KeyError(message.format(name))
557 return self._specs[name]
559 def __setitem__(self, key, value):
560 if isinstance(key, str) and '#' in key: 560 ↛ 562line 560 didn't jump to line 562 because the condition on line 560 was never true
561 # must be a partial's name
562 if not isinstance(value, SpecificationPartial):
563 message = ('Expected {0!s}={1!r} to be a '
564 'SpecificationPartial-type')
565 raise TypeError(message.format(key, value))
567 # Ensure key and value.name are consistent
568 if key != value.name:
569 message = ("Key {0!s} does not match the "
570 "SpecificationPartial's name {1!s})")
571 raise KeyError(message.format(key, value.name))
572 self._partials[key] = value
574 else:
575 # must be a specification.
576 if not isinstance(key, Name):
577 key = Name(spec=key)
579 if not key.is_spec: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true
580 message = 'Expected key {0!r} to resolve a specification'
581 raise KeyError(message.format(key))
583 if not isinstance(value, Specification):
584 message = ('Expected {0!s}={1!r} to be a '
585 'Specification-type')
586 raise TypeError(message.format(key, value))
588 # Ensure key and value.name are consistent
589 if key != value.name:
590 message = ("Key {0!s} does not match the "
591 "Specification's name {1!s})")
592 raise KeyError(message.format(key, value.name))
594 self._specs[key] = value
596 def __delitem__(self, key):
597 if isinstance(key, str) and '#' in key: 597 ↛ 599line 597 didn't jump to line 599 because the condition on line 597 was never true
598 # must be a partial's name
599 del self._partials[key]
601 else:
602 # must be a specification
603 if not isinstance(key, Name): 603 ↛ 606line 603 didn't jump to line 606 because the condition on line 603 was always true
604 key = Name(spec=key)
606 del self._specs[key]
608 def __iter__(self):
609 for key in self._specs:
610 yield key
612 def __eq__(self, other):
613 if len(self) != len(other): 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 return False
616 for name, spec in self.items():
617 try:
618 if spec != other[name]: 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true
619 return False
620 except KeyError:
621 return False
623 return True
625 def __ne__(self, other):
626 return not self.__eq__(other)
628 def __iadd__(self, other):
629 """Merge another `SpecificationSet` into this one.
631 Parameters
632 ---------
633 other : `SpecificationSet`
634 Another `SpecificationSet`. Specification in ``other`` that do
635 exist in this set are added to this one. Specification in ``other``
636 replace specifications of the same name in this one.
638 Returns
639 -------
640 self : `SpecificationSet`
641 This `SpecificationSet`.
643 Notes
644 -----
645 Equivalent to `update`.
646 """
647 self.update(other)
648 return self
650 def keys(self):
651 """Get a sequence of specification names, which are keys to the set.
653 Returns
654 -------
655 keys : sequence of `Name`
656 Keys to the specification set.
657 """
658 return self._specs.keys()
660 def items(self):
661 """Iterate over name, specification pairs.
663 Yields
664 ------
665 item : `tuple`
666 Tuple containing:
668 - `Name` of the specification.
669 - `Specification`-type object.
670 """
671 for name, spec in self._specs.items():
672 yield name, spec
674 def insert(self, spec):
675 """Insert a `Specification` into the set.
677 A pre-existing specification with the same name is replaced.
679 Parameters
680 ----------
681 spec : `Specification`-type
682 A specification.
683 """
684 key = spec.name
685 self[key] = spec
687 def update(self, other):
688 r"""Merge another `SpecificationSet` into this one.
690 Parameters
691 ----------
692 other : `SpecificationSet`
693 Another `SpecificationSet`. `Specification`\ s in ``other`` that do
694 not exist in this set are added to this one. `Specification`\ s in
695 ``other`` replace specifications of the same name in this one.
696 """
697 for _, spec in other.items():
698 self.insert(spec)
700 def resolve_document(self, spec_doc):
701 """Resolve inherited properties in a specification document using
702 specifications available in the repo.
704 Parameters
705 ----------
706 spec_doc : `dict`
707 A specification document. A document is typically either a YAML
708 document, where the specification is defined, or a JSON object
709 that was serialized from a `~lsst.validate.base.Specification`
710 instance.
712 Returns
713 -------
714 spec_doc : `OrderedDict`
715 The specification document is returned with bases resolved.
717 Raises
718 ------
719 SpecificationResolutionError
720 Raised when a document's bases cannot be resolved (an inherited
721 `~lsst.validate.base.Specification` cannot be found in the repo).
722 """
723 # Create a copy of the spec_doc so that if the resolution is aborted
724 # we haven't modified the original document
725 spec_doc = copy.deepcopy(spec_doc)
727 # Goal is to process all specifications and partials mentioned in
728 # the 'base' field (first in, first out) and merge their information
729 # to the spec_doc.
730 if 'base' in spec_doc:
731 # Coerce 'base' field into a list for consistency
732 if isinstance(spec_doc['base'], str):
733 spec_doc['base'] = [spec_doc['base']]
735 built_doc = OrderedDict()
737 # Process all base dependencies into the specification
738 # document until all are merged
739 while len(spec_doc['base']) > 0:
740 # Select first base (first in, first out queue)
741 base_name = spec_doc['base'][0]
743 # Get the base: it's either another specification or a partial
744 if '#' in base_name:
745 # We make base names fully qualifed when loading them
746 try:
747 base_spec = self._partials[base_name]
748 except KeyError:
749 # Abort because this base is not available yet
750 raise SpecificationResolutionError
752 else:
753 # Must be a specification.
754 # Resolve its name (use package info from present doc since
755 # they're consistent).
756 base_name = Name(package=spec_doc['package'],
757 spec=base_name)
758 # Try getting the specification from the repo
759 try:
760 base_spec = self[base_name]
761 except KeyError:
762 # Abort because this base is not resolved
763 # or not yet available
764 raise SpecificationResolutionError
766 # Merge this spec_doc onto the base document using
767 # our inheritance algorithm
768 built_doc = merge_documents(built_doc, base_spec.json)
770 # Mix in metric information if available. This is useful
771 # because a specification may only assume its metric
772 # identity from inheritance.
773 try:
774 built_doc['metric'] = base_spec.name.metric
775 except AttributeError:
776 # base spec must be a partial
777 pass
779 # Remove this base spec from the queue
780 del spec_doc['base'][0]
782 # if base list is empty remove it so we don't loop over it again
783 if len(spec_doc['base']) == 0: 783 ↛ 788line 783 didn't jump to line 788 because the condition on line 783 was always true
784 del spec_doc['base']
786 # Merge this spec_doc onto the base document using
787 # our inheritance algorithm
788 built_doc = merge_documents(built_doc, spec_doc)
790 return built_doc
792 else:
793 # No inheritance to resolve
794 return spec_doc
796 def subset(self, name=None, meta=None, required_meta=None,
797 spec_tags=None, metric_tags=None, metrics=None):
798 """Create a new `SpecificationSet` with specifications belonging to
799 a single package or metric, and that apply to the given metadata.
801 Parameters
802 ----------
803 name : `str` or `lsst.verify.Name`, optional
804 Name to subset specifications by. If this is the name of a package,
805 then all specifications for that package are included in the
806 subset. If this is a metric name, then only specifications
807 for that metric are included in the subset. The metric name
808 must be fully-qualified (that is, it includes a package component).
809 meta : `lsst.verify.Metadata`, optional
810 If supplied, only specifications that apply to the given metadata
811 are included in the subset. Metadata is usually obtained from
812 the `Job.meta` attribute of a `Job` instance. By default,
813 specifications are selected as long as the ``meta`` argument
814 as at least all the terms defined in a specification's metadata
815 query and their term values do not conflict.
816 required_metadata : `dict` or `lsst.verify.Metadata`, optional
817 If supplied, only specifications that have **all** the terms in
818 ``required_metadata`` (and their term values match) are selected.
819 This is opposite to the logic of the ``meta`` argument where a
820 specification with an empty metadata query is always selected,
821 for example. This query is performed with the ``arg_driven=True``
822 mode of `lsst.verify.MetadataQuery`.
823 spec_tags : sequence of `str`, optional
824 A set of specification tag strings. when given, only
825 specifications that have all the given tags are included in the
826 report. For example, ``spec_tags=['LPM-17', 'minimum']``.
827 metric_tags : sequence of `str`, optional
828 A set of metric tag strings. When given, only specifications
829 belonging to metrics that posess **all** given tags are included
830 in the report. For example,
831 ``metric_tags=['LPM-17', 'photometry']`` selects sepifications
832 that have both the ``'LPM-17'`` and ``'photometry'`` tags. If
833 set, also provide a `lsst.verify.MetricSet` with the ``metrics``
834 argument.
835 metrics : `lsst.verify.MetricSet`
836 `~lsst.verify.MetricSet` with metric definitions. This is only
837 needed if a ``metric_tags`` argument is provided.
839 Returns
840 -------
841 spec_subset : `SpecificationSet`
842 Subset of this `SpecificationSet` containing only specifications
843 belonging to the indicated package or metric, and/or that are
844 compatible with the job metadata. Any partials in
845 the SpecificationSet are also included in ``spec_subset``.
847 See also
848 --------
849 lsst.very.MetadataQuery
850 """
851 if metric_tags is not None and metrics is None: 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true
852 message = ('A MetricSet must be provided through the metrics '
853 'argument when subsetting ith metric_tags.')
854 raise ValueError(message)
856 all_partials = [partial
857 for partial_name, partial in self._partials.items()]
859 # Filter by package or metric name
860 if name is not None:
861 if not isinstance(name, Name): 861 ↛ 864line 861 didn't jump to line 864 because the condition on line 861 was always true
862 name = Name(name)
864 if not name.is_fq: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true
865 message = '{0!s} is not a fully-qualified name'.format(name)
866 raise RuntimeError(message)
868 specs = [spec for spec_name, spec in self._specs.items()
869 if spec_name in name]
871 spec_subset = SpecificationSet(specifications=specs,
872 partials=all_partials)
873 else:
874 spec_subset = self
876 # Filter by metadata
877 if meta is not None:
878 specs = [spec for spec_name, spec in spec_subset.items()
879 if spec.query_metadata(meta)]
881 spec_subset = SpecificationSet(specifications=specs,
882 partials=all_partials)
884 # Filter by required metadata terms
885 if required_meta is not None:
886 specs = [spec for spec_name, spec in spec_subset.items()
887 if spec.query_metadata(required_meta,
888 arg_driven=True)]
890 spec_subset = SpecificationSet(specifications=specs,
891 partials=all_partials)
893 # Filter by specifiation tags
894 if spec_tags is not None: 894 ↛ 895line 894 didn't jump to line 895 because the condition on line 894 was never true
895 spec_tags = set(spec_tags)
896 specs = [spec for spec_name, spec in spec_subset.items()
897 if spec_tags <= spec.tags]
899 spec_subset = SpecificationSet(specifications=specs,
900 partials=all_partials)
902 # Filter by metric tags
903 if metric_tags is not None: 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true
904 metric_tags = set(metric_tags)
905 specs = [spec for spec_name, spec in spec_subset.items()
906 if metric_tags <= metrics[spec.metric_name].tags]
908 return spec_subset
910 def report(self, measurements, name=None, meta=None, spec_tags=None,
911 metric_tags=None, metrics=None):
912 """Create a report that details specification tests against the given
913 measurements.
915 Parameters
916 ----------
917 measurements : `lsst.verify.MeasurementSet`
918 Measurements to test.
919 name : `str` or `lsst.verify.Name`, optional
920 A package or metric name to subset specifications by. When set,
921 only measurement and specification combinations belonging to that
922 package or metric are included in the report.
923 meta : `lsst.verifify.Metadata`, optional
924 Job metadata to ensure the specifications are relevant to the
925 measurements. Typically accessed as `Job.meta`.
926 spec_tags : sequence of `str`, optional
927 A set of specification tag strings. when given, only
928 specifications that have all the given tags are included in the
929 report. For example, ``spec_tags=['LPM-17', 'minimum']``.
930 metric_tags : sequence of `str`, optional
931 A set of metric tag strings. When given, only specifications
932 belonging to metrics that posess **all** given tags are included
933 in the report. For example,
934 ``metric_tags=['LPM-17', 'photometry']`` selects sepifications
935 that have both the ``'LPM-17'`` and ``'photometry'`` tags. If
936 set, also provide a `lsst.verify.MetricSet` with the ``metrics``
937 argument.
938 metrics : `lsst.verify.MetricSet`
939 `~lsst.verify.MetricSet` with metric definitions. This is only
940 needed if a ``metric_tags`` argument is provided.
942 Returns
943 -------
944 report : `lsst.verify.Report`
945 Report instance. In a Jupyter notebook, you can view the report
946 by calling `Report.show`.
948 See also
949 --------
950 lsst.verify.Job.report
951 """
952 spec_subset = self.subset(name=name, meta=meta,
953 spec_tags=spec_tags,
954 metric_tags=metric_tags, metrics=metrics)
955 return Report(measurements, spec_subset)
957 def _repr_html_(self):
958 """Make an HTML representation of the SpecificationSet for Jupyter
959 notebooks.
960 """
961 name_col = []
962 tags_col = []
963 test_col = []
965 names = list(self.keys())
966 names.sort()
968 for name in names:
969 spec = self[name]
971 name_col.append(str(name))
973 test_col.append(spec._repr_latex_())
975 tags = list(spec.tags)
976 tags.sort()
977 tags_col.append(', '.join(tags))
979 table = Table([name_col, test_col, tags_col],
980 names=['Name', 'Test', 'Tags'])
981 return table._repr_html_()
984class SpecificationPartial(object):
985 """A specification definition partial, used when parsing specification
986 YAML repositories.
987 """
989 def __init__(self, yaml_doc):
990 self.yaml_doc = yaml_doc
991 self.name = self.yaml_doc.pop('id')
993 def __str__(self):
994 return self.name
996 def __hash__(self):
997 return hash(self.name)
999 @property
1000 def json(self):
1001 """JSON-serializable representation of the partial."""
1002 # This API is for compatibility with Specification classes
1003 return self.yaml_doc