Coverage for python/lsst/verify/specset.py: 77%

350 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:22 +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'] 

22 

23from collections import OrderedDict 

24import copy 

25import os 

26import re 

27 

28from astropy.table import Table 

29 

30from lsst.resources import ResourcePath 

31from lsst.utils import getPackageDir 

32 

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 

40 

41 

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+)$') 

46 

47 

48class SpecificationSet(JsonSerializationMixin): 

49 r"""A collection of `Specification`\ s. 

50 

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 """ 

59 

60 def __init__(self, specifications=None, partials=None): 

61 # Specifications, keyed by Name (a specification name) 

62 self._specs = {} 

63 

64 # SpecificationPartial instances, keyed by the fully-qualified 

65 # name: ``package_name:yaml_id#name``. 

66 self._partials = {} 

67 

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)) 

73 

74 self._specs[spec.name] = spec 

75 

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)) 

81 

82 self._partials[partial.name] = partial 

83 

84 @classmethod 

85 def deserialize(cls, specifications=None): 

86 """Deserialize a specification set from a JSON serialization. 

87 

88 Parameters 

89 ---------- 

90 specifications : `list`, optional 

91 List of specification JSON objects. 

92 

93 Returns 

94 ------- 

95 spec_set : `SpecificationSet` 

96 `SpecificationSet` instance. 

97 """ 

98 instance = cls() 

99 

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) 

112 

113 return instance 

114 

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. 

120 

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`. 

134 

135 Returns 

136 ------- 

137 spec_set : `SpecificationSet` 

138 A `SpecificationSet` containing `Specification` instances. 

139 

140 See also 

141 -------- 

142 lsst.verify.SpecificationSet.load_single_package 

143 

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'``. 

149 

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. 

153 

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) 

170 

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)) 

175 

176 instance = cls() 

177 

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()) 

184 

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) 

191 

192 return instance 

193 

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. 

198 

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. 

205 

206 Returns 

207 ------- 

208 spec_set : `SpecificationSet` 

209 A `SpecificationSet` containing `Specification` instances. 

210 

211 See also 

212 -------- 

213 lsst.verify.SpecificationSet.load_metrics_package 

214 

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. 

221 

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) 

228 

229 return instance 

230 

231 def _load_package_dir(self, package_specs_dirname): 

232 package_specs_dirname = ResourcePath(package_specs_dirname, 

233 forceDirectory=True, 

234 forceAbsolute=True) 

235 

236 all_docs = [] 

237 

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) 

246 

247 # resolve inheritance and Specification* instances when possible 

248 while len(all_docs) > 0: 

249 redo_queue = [] 

250 

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 

258 

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)) 

269 

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) 

277 

278 name = spec.name 

279 

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) 

285 

286 self._specs[name] = spec 

287 

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)) 

292 

293 all_docs = redo_queue 

294 

295 @staticmethod 

296 def _load_yaml_file(yaml_file_path, package_dirname): 

297 r"""Ingest specifications and partials from a single YAML file. 

298 

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. 

305 

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). 

313 

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: 

320 

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) 

338 

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)) 

345 

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] 

351 

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] 

357 

358 spec_docs = [] 

359 partial_docs = [] 

360 with yaml_file_path.open() as stream: 

361 parsed_docs = load_all_ordered_yaml(stream) 

362 

363 for doc in parsed_docs: 

364 doc['package'] = package_name 

365 

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) 

371 

372 else: 

373 # Must be a specification 

374 doc = SpecificationSet._process_specification_yaml_doc( 

375 doc, yaml_id) 

376 spec_docs.append(doc) 

377 

378 return spec_docs, partial_docs 

379 

380 @staticmethod 

381 def _process_specification_yaml_doc(doc, yaml_id): 

382 """Process a specification yaml document. 

383 

384 Principle functionality is: 

385 

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) 

393 

394 try: 

395 doc['name'] = SpecificationSet._normalize_spec_name( 

396 doc['name'], metric=metric, package=package) 

397 

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 

405 

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 

411 

412 return doc 

413 

414 @staticmethod 

415 def _process_partial_yaml_doc(doc, yaml_id): 

416 """Process a specification yaml document. 

417 

418 Principle functionality is: 

419 

420 1. Make `id` fully specified. 

421 2. Make bases fully specified. 

422 """ 

423 package = doc['package'] 

424 

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) 

430 

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 

436 

437 return doc 

438 

439 @staticmethod 

440 def _process_bases(bases, package_name, yaml_id): 

441 if not isinstance(bases, list): 

442 bases = [bases] 

443 

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) 

457 

458 processed_bases.append(base_name) 

459 

460 return processed_bases 

461 

462 @staticmethod 

463 def _normalize_partial_name(name, current_yaml_id=None, package=None): 

464 """Normalize a partial's identifier. 

465 

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 

477 

478 matches = PARTIAL_PATTERN.search(name) 

479 

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') 

489 

490 # Create the fully-specified name 

491 fmt = '{package}:{path}#{name}' 

492 return fmt.format(package=_package, 

493 path=_path, 

494 name=partial_name) 

495 

496 @staticmethod 

497 def _normalize_spec_name(name, metric=None, package=None): 

498 """Normalize a specification name to a fully-qualified specification 

499 name. 

500 

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 

507 

508 @property 

509 def json(self): 

510 doc = JsonSerializationMixin._jsonify_list( 

511 [spec for name, spec in self.items()] 

512 ) 

513 return doc 

514 

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) 

524 

525 def __len__(self): 

526 """Number of `Specifications` in the set.""" 

527 return len(self._specs) 

528 

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 

534 

535 else: 

536 # must be a specification. 

537 if not isinstance(name, Name): 

538 name = Name(spec=name) 

539 

540 return name in self._specs 

541 

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] 

547 

548 else: 

549 # must be a specification. 

550 if not isinstance(name, Name): 

551 name = Name(spec=name) 

552 

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)) 

556 

557 return self._specs[name] 

558 

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)) 

566 

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 

573 

574 else: 

575 # must be a specification. 

576 if not isinstance(key, Name): 

577 key = Name(spec=key) 

578 

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)) 

582 

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)) 

587 

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)) 

593 

594 self._specs[key] = value 

595 

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] 

600 

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) 

605 

606 del self._specs[key] 

607 

608 def __iter__(self): 

609 for key in self._specs: 

610 yield key 

611 

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 

615 

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 

622 

623 return True 

624 

625 def __ne__(self, other): 

626 return not self.__eq__(other) 

627 

628 def __iadd__(self, other): 

629 """Merge another `SpecificationSet` into this one. 

630 

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. 

637 

638 Returns 

639 ------- 

640 self : `SpecificationSet` 

641 This `SpecificationSet`. 

642 

643 Notes 

644 ----- 

645 Equivalent to `update`. 

646 """ 

647 self.update(other) 

648 return self 

649 

650 def keys(self): 

651 """Get a sequence of specification names, which are keys to the set. 

652 

653 Returns 

654 ------- 

655 keys : sequence of `Name` 

656 Keys to the specification set. 

657 """ 

658 return self._specs.keys() 

659 

660 def items(self): 

661 """Iterate over name, specification pairs. 

662 

663 Yields 

664 ------ 

665 item : `tuple` 

666 Tuple containing: 

667 

668 - `Name` of the specification. 

669 - `Specification`-type object. 

670 """ 

671 for name, spec in self._specs.items(): 

672 yield name, spec 

673 

674 def insert(self, spec): 

675 """Insert a `Specification` into the set. 

676 

677 A pre-existing specification with the same name is replaced. 

678 

679 Parameters 

680 ---------- 

681 spec : `Specification`-type 

682 A specification. 

683 """ 

684 key = spec.name 

685 self[key] = spec 

686 

687 def update(self, other): 

688 r"""Merge another `SpecificationSet` into this one. 

689 

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) 

699 

700 def resolve_document(self, spec_doc): 

701 """Resolve inherited properties in a specification document using 

702 specifications available in the repo. 

703 

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. 

711 

712 Returns 

713 ------- 

714 spec_doc : `OrderedDict` 

715 The specification document is returned with bases resolved. 

716 

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) 

726 

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']] 

734 

735 built_doc = OrderedDict() 

736 

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] 

742 

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 

751 

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 

765 

766 # Merge this spec_doc onto the base document using 

767 # our inheritance algorithm 

768 built_doc = merge_documents(built_doc, base_spec.json) 

769 

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 

778 

779 # Remove this base spec from the queue 

780 del spec_doc['base'][0] 

781 

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'] 

785 

786 # Merge this spec_doc onto the base document using 

787 # our inheritance algorithm 

788 built_doc = merge_documents(built_doc, spec_doc) 

789 

790 return built_doc 

791 

792 else: 

793 # No inheritance to resolve 

794 return spec_doc 

795 

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. 

800 

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. 

838 

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``. 

846 

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) 

855 

856 all_partials = [partial 

857 for partial_name, partial in self._partials.items()] 

858 

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) 

863 

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) 

867 

868 specs = [spec for spec_name, spec in self._specs.items() 

869 if spec_name in name] 

870 

871 spec_subset = SpecificationSet(specifications=specs, 

872 partials=all_partials) 

873 else: 

874 spec_subset = self 

875 

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)] 

880 

881 spec_subset = SpecificationSet(specifications=specs, 

882 partials=all_partials) 

883 

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)] 

889 

890 spec_subset = SpecificationSet(specifications=specs, 

891 partials=all_partials) 

892 

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] 

898 

899 spec_subset = SpecificationSet(specifications=specs, 

900 partials=all_partials) 

901 

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] 

907 

908 return spec_subset 

909 

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. 

914 

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. 

941 

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`. 

947 

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) 

956 

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 = [] 

964 

965 names = list(self.keys()) 

966 names.sort() 

967 

968 for name in names: 

969 spec = self[name] 

970 

971 name_col.append(str(name)) 

972 

973 test_col.append(spec._repr_latex_()) 

974 

975 tags = list(spec.tags) 

976 tags.sort() 

977 tags_col.append(', '.join(tags)) 

978 

979 table = Table([name_col, test_col, tags_col], 

980 names=['Name', 'Test', 'Tags']) 

981 return table._repr_html_() 

982 

983 

984class SpecificationPartial(object): 

985 """A specification definition partial, used when parsing specification 

986 YAML repositories. 

987 """ 

988 

989 def __init__(self, yaml_doc): 

990 self.yaml_doc = yaml_doc 

991 self.name = self.yaml_doc.pop('id') 

992 

993 def __str__(self): 

994 return self.name 

995 

996 def __hash__(self): 

997 return hash(self.name) 

998 

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