dkist-header-validator 5.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dkist_header_validator/__init__.py +12 -0
- dkist_header_validator/api/__init__.py +0 -0
- dkist_header_validator/api/validate.py +66 -0
- dkist_header_validator/base_validator.py +751 -0
- dkist_header_validator/exceptions.py +51 -0
- dkist_header_validator/spec_validators.py +54 -0
- dkist_header_validator/tests/__init__.py +0 -0
- dkist_header_validator/tests/conftest.py +639 -0
- dkist_header_validator/tests/test_base_validator.py +494 -0
- dkist_header_validator/tests/test_spec122_translation.py +233 -0
- dkist_header_validator/tests/test_spec122_validation+.py +144 -0
- dkist_header_validator/tests/test_spec122_validation-.py +118 -0
- dkist_header_validator/tests/test_spec214_validation+.py +402 -0
- dkist_header_validator/tests/test_spec214_validation-.py +211 -0
- dkist_header_validator/tests/test_translator.py +114 -0
- dkist_header_validator/translator.py +251 -0
- dkist_header_validator/utils/__init__.py +0 -0
- dkist_header_validator/utils/expansions.py +18 -0
- dkist_header_validator/version.py +8 -0
- dkist_header_validator-5.2.0.dist-info/METADATA +151 -0
- dkist_header_validator-5.2.0.dist-info/RECORD +24 -0
- dkist_header_validator-5.2.0.dist-info/WHEEL +5 -0
- dkist_header_validator-5.2.0.dist-info/entry_points.txt +2 -0
- dkist_header_validator-5.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Definition of the base objects for the creation of a spec validator
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from numbers import Integral
|
|
9
|
+
from numbers import Real
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Callable
|
|
13
|
+
from typing import IO
|
|
14
|
+
from typing import Optional
|
|
15
|
+
from typing import Type
|
|
16
|
+
|
|
17
|
+
import astropy.time as t
|
|
18
|
+
import astropy.units as u
|
|
19
|
+
import numpy as np
|
|
20
|
+
import voluptuous as vol
|
|
21
|
+
from astropy.io import fits
|
|
22
|
+
from astropy.io.fits.hdu.hdulist import HDUList
|
|
23
|
+
from dkist_fits_specifications import spec214
|
|
24
|
+
from dkist_fits_specifications.utils import schema_type_hint
|
|
25
|
+
from voluptuous.error import Invalid
|
|
26
|
+
|
|
27
|
+
from dkist_header_validator.exceptions import ReturnTypeException
|
|
28
|
+
from dkist_header_validator.exceptions import SpecSchemaDefinitionException
|
|
29
|
+
from dkist_header_validator.exceptions import SpecValidationException
|
|
30
|
+
from dkist_header_validator.exceptions import TranslationException
|
|
31
|
+
from dkist_header_validator.exceptions import ValidationException
|
|
32
|
+
from dkist_header_validator.translator import translate_spec122_to_spec214_l0
|
|
33
|
+
from dkist_header_validator.utils.expansions import expand_naxis
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
__all__ = ["SpecValidator", "SpecSchema"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FormatInvalid(Invalid):
|
|
41
|
+
"""
|
|
42
|
+
An value which does not match the schema format.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FormatValidator:
|
|
47
|
+
"""
|
|
48
|
+
Validate that the format is valid.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
supported_formats = ["isot", "unit"]
|
|
52
|
+
|
|
53
|
+
def __init__(self, format_value):
|
|
54
|
+
if format_value not in self.supported_formats:
|
|
55
|
+
raise SpecSchemaDefinitionException(f"{format_value} is an unknown format to validate.")
|
|
56
|
+
self.format_value = format_value
|
|
57
|
+
|
|
58
|
+
def __call__(self, value):
|
|
59
|
+
if self.format_value == "unit":
|
|
60
|
+
try:
|
|
61
|
+
return u.Unit(value, format="fits")
|
|
62
|
+
except Exception:
|
|
63
|
+
raise FormatInvalid(f"{value} is not a valid FITS unit")
|
|
64
|
+
|
|
65
|
+
if self.format_value == "isot":
|
|
66
|
+
try:
|
|
67
|
+
return t.Time(value, format="fits")
|
|
68
|
+
except Exception:
|
|
69
|
+
raise FormatInvalid(f"{value} is not a valid FITS time")
|
|
70
|
+
|
|
71
|
+
def __repr__(self):
|
|
72
|
+
return f"FormatValidator(format='{self.format_value}')"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FITSFloatInvalid(Invalid):
|
|
76
|
+
"""
|
|
77
|
+
Validates a header value is a valid FITS float.
|
|
78
|
+
|
|
79
|
+
This means it's not NaN or ±Inf.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FITSFloatValidator:
|
|
84
|
+
"""
|
|
85
|
+
Validate a FITS floating point value.
|
|
86
|
+
|
|
87
|
+
This validator assumes the type has also been validated independently.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __call__(self, value):
|
|
91
|
+
if not np.isfinite(value):
|
|
92
|
+
raise FITSFloatInvalid(
|
|
93
|
+
f"{value} is not finite, floats in a FITS header must be finite."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SpecSchema:
|
|
100
|
+
"""
|
|
101
|
+
Define a schema that is used to validate FITS headers for a spec based upon structured definitions
|
|
102
|
+
in YAML or dicts.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
spec_schema_definitions
|
|
107
|
+
Definition of the spec's schema in one of the following forms:
|
|
108
|
+
- Dict definition of the spec schema
|
|
109
|
+
- List of Dict definitions of the spec schema
|
|
110
|
+
- Path to a YAML file defining the spec schema
|
|
111
|
+
- Path to a directory containing YAML files defining spec schema
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# Schemas defined for a spec have the following structure per key
|
|
115
|
+
definition_schema_definition = {
|
|
116
|
+
vol.Required("required"): vol.Any(True, False),
|
|
117
|
+
vol.Required("type"): vol.Any("int", "float", "str", "bool"),
|
|
118
|
+
"values": list,
|
|
119
|
+
"values_range": list,
|
|
120
|
+
"expected": vol.Any(True, False),
|
|
121
|
+
"expand": vol.Any(True, False),
|
|
122
|
+
"format": vol.Any("unit", "isot"),
|
|
123
|
+
}
|
|
124
|
+
# Voluptuous schema instance used to validate the definitions
|
|
125
|
+
definition_schema = vol.Schema(definition_schema_definition, extra=vol.ALLOW_EXTRA)
|
|
126
|
+
|
|
127
|
+
def __init__(self, spec_schema_definitions: schema_type_hint):
|
|
128
|
+
# convert spec schema definitions to a list of dicts
|
|
129
|
+
self.spec_schema_definitions = self._parse_spec_schema_definitions(spec_schema_definitions)
|
|
130
|
+
# validate the dict definition of the spec schema
|
|
131
|
+
self._validate_spec_schema_definitions()
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def _parse_spec_schema_definitions(cls, spec_schema: schema_type_hint) -> dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Convert from a list of dicts multiple formats to a dict
|
|
137
|
+
:param spec_schema: Definition(s) of the spec's schema
|
|
138
|
+
:return: Dictionary capturing the spec schema definition
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# Test for proper types and non-emptiness
|
|
142
|
+
if not spec_schema:
|
|
143
|
+
raise SpecSchemaDefinitionException("spec_schema is empty or invalid.")
|
|
144
|
+
|
|
145
|
+
if not isinstance(spec_schema, Mapping):
|
|
146
|
+
raise SpecSchemaDefinitionException(
|
|
147
|
+
f"spec_schema is not a Mapping it is {type(spec_schema)}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
schema = {}
|
|
151
|
+
for section_name, section_schema in spec_schema.items():
|
|
152
|
+
if not section_schema:
|
|
153
|
+
raise SpecSchemaDefinitionException(
|
|
154
|
+
f"The {section_name} section has an empty schema: {section_schema!r}"
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(section_schema, Mapping):
|
|
157
|
+
raise SpecSchemaDefinitionException(f"{type(section_schema)} is not a Mapping")
|
|
158
|
+
schema.update({key: dict(key_schema) for key, key_schema in section_schema.items()})
|
|
159
|
+
return schema
|
|
160
|
+
|
|
161
|
+
def _validate_spec_schema_definitions(self) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Validate the spec schema definitions against the class schema for
|
|
164
|
+
spec schema definitions and raise a SpecSchemaDefinitionException
|
|
165
|
+
on failure
|
|
166
|
+
:return: None
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
for key, spec_schema in self.spec_schema_definitions.items():
|
|
170
|
+
schema_errors = {}
|
|
171
|
+
try:
|
|
172
|
+
self.definition_schema(spec_schema)
|
|
173
|
+
except vol.MultipleInvalid as e:
|
|
174
|
+
schema_errors = {error.path[0]: error.msg for error in e.errors}
|
|
175
|
+
if schema_errors:
|
|
176
|
+
logger.debug(
|
|
177
|
+
f"Errors during schema definition validation. key={key} errors={schema_errors}"
|
|
178
|
+
)
|
|
179
|
+
raise SpecSchemaDefinitionException(
|
|
180
|
+
f"Errors during schema definition validation. key={key}",
|
|
181
|
+
errors=schema_errors,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def expand_schema(self, headers) -> dict:
|
|
185
|
+
"""
|
|
186
|
+
Expand schema indices using information from fits headers
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
headers
|
|
191
|
+
Fits file headers whose values are to be used to know how to expand spec keys
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
schema dictionary
|
|
196
|
+
"""
|
|
197
|
+
# 214 Expansion
|
|
198
|
+
if {"DAAXES", "DEAXES", "DNAXIS"}.issubset(headers.keys()):
|
|
199
|
+
# This function includes the expansion for NAXIS
|
|
200
|
+
return spec214.expand_214_schema(self.spec_schema_definitions, **headers)
|
|
201
|
+
|
|
202
|
+
# 122 Only expands "n" with NAXIS
|
|
203
|
+
return expand_naxis(headers["NAXIS"], self.spec_schema_definitions)
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def generate_schema_for_key(key_schema):
|
|
207
|
+
"""
|
|
208
|
+
Generate voluptuous schema for a key
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
key_schema
|
|
213
|
+
Spec schema (from yml) used to generate voluptuous schema
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
Voluptuous schema for a key
|
|
217
|
+
"""
|
|
218
|
+
type_map = {"int": Integral, "float": Real, "str": str, "bool": bool}
|
|
219
|
+
|
|
220
|
+
checks = []
|
|
221
|
+
# Always check type
|
|
222
|
+
checks.append(type_map[key_schema.get("type")])
|
|
223
|
+
|
|
224
|
+
def case_insenstive_values(value):
|
|
225
|
+
allowed_values = key_schema.get("values")
|
|
226
|
+
if key_schema.get("type") == "str":
|
|
227
|
+
value = value.lower()
|
|
228
|
+
allowed_values = [v.lower() for v in allowed_values]
|
|
229
|
+
|
|
230
|
+
return vol.Any(*allowed_values)(value)
|
|
231
|
+
|
|
232
|
+
def expand_values_range(value):
|
|
233
|
+
min_value = key_schema.get("values_range")[0]
|
|
234
|
+
try:
|
|
235
|
+
max_value = key_schema.get("values_range")[1]
|
|
236
|
+
except IndexError:
|
|
237
|
+
max_value = None
|
|
238
|
+
|
|
239
|
+
return vol.Range(min=min_value, max=max_value)(value)
|
|
240
|
+
|
|
241
|
+
if key_schema.get("values"):
|
|
242
|
+
checks.append(case_insenstive_values)
|
|
243
|
+
|
|
244
|
+
if key_schema.get("values_range"):
|
|
245
|
+
checks.append(expand_values_range)
|
|
246
|
+
|
|
247
|
+
if "format" in key_schema:
|
|
248
|
+
checks.append(FormatValidator(key_schema["format"]))
|
|
249
|
+
|
|
250
|
+
if key_schema.get("type") == "float":
|
|
251
|
+
checks.append(FITSFloatValidator())
|
|
252
|
+
|
|
253
|
+
return vol.All(*checks)
|
|
254
|
+
|
|
255
|
+
def _add_keys_to_schema(self, schema: dict) -> dict:
|
|
256
|
+
schema_keys = {} # keys to be added to voluptuous schema
|
|
257
|
+
required_keys = {
|
|
258
|
+
vol.Required(k): self.generate_schema_for_key(v)
|
|
259
|
+
for k, v in schema.items()
|
|
260
|
+
if v.get("required")
|
|
261
|
+
}
|
|
262
|
+
schema_keys.update(required_keys)
|
|
263
|
+
remaining_keys = {
|
|
264
|
+
k: self.generate_schema_for_key(v) for k, v in schema.items() if not v.get("required")
|
|
265
|
+
}
|
|
266
|
+
schema_keys.update(remaining_keys)
|
|
267
|
+
return schema_keys
|
|
268
|
+
|
|
269
|
+
def _check_for_expansion(self, headers):
|
|
270
|
+
for value in self.spec_schema_definitions.values():
|
|
271
|
+
# check the whole schema to see if there is an expand = True keyword
|
|
272
|
+
if value["expand"]:
|
|
273
|
+
# if there is an 'expand' keyword set to true in the schema, expand the whole spec schema
|
|
274
|
+
expanded_schema = self.expand_schema(headers)
|
|
275
|
+
return expanded_schema
|
|
276
|
+
return self.spec_schema_definitions
|
|
277
|
+
|
|
278
|
+
def _create_spec_schema(self, headers, extra) -> vol.Schema:
|
|
279
|
+
"""
|
|
280
|
+
A voluptuous.spec_validator object to validate headers against.
|
|
281
|
+
Constructed from Spec keywords.
|
|
282
|
+
:param headers: Fits file headers
|
|
283
|
+
:return: Voluptuous schema
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
spec_schema = {}
|
|
287
|
+
|
|
288
|
+
schema = self._check_for_expansion(headers)
|
|
289
|
+
spec_schema.update(self._add_keys_to_schema(schema))
|
|
290
|
+
if extra:
|
|
291
|
+
return vol.Schema(spec_schema, extra=vol.ALLOW_EXTRA)
|
|
292
|
+
return vol.Schema(spec_schema)
|
|
293
|
+
|
|
294
|
+
def __call__(self, headers: dict, extra) -> vol.Schema:
|
|
295
|
+
"""
|
|
296
|
+
Validate headers against the instance spec schema
|
|
297
|
+
raising voluptuous errors on failure
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
headers
|
|
302
|
+
header dict to validate
|
|
303
|
+
extra
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
vol.Schema
|
|
308
|
+
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
spec_schema = self._create_spec_schema(headers, extra)
|
|
312
|
+
return spec_schema(headers)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class SpecValidator:
|
|
316
|
+
"""
|
|
317
|
+
Validates FITS Headers against a schema
|
|
318
|
+
|
|
319
|
+
Parameters
|
|
320
|
+
----------
|
|
321
|
+
spec_schema
|
|
322
|
+
Definition of the spec's schema in one of the following forms:
|
|
323
|
+
- SpecSchema instance
|
|
324
|
+
- A dict of sections of key schemas as returned by the fits specification repo
|
|
325
|
+
- None. Used only for `ProcessedSpecValidator` where the actual schema will be updated dynamically
|
|
326
|
+
|
|
327
|
+
SchemaValidationException
|
|
328
|
+
SpecValidationException or subclass of SpecValidationException
|
|
329
|
+
to raise if spec_validator validation fails
|
|
330
|
+
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
spec_schema: schema_type_hint | SpecSchema | None,
|
|
336
|
+
SchemaValidationException: Type[SpecValidationException] = SpecValidationException,
|
|
337
|
+
):
|
|
338
|
+
# `spec_schema` is the callable for validating a dict against the defined spec_validator
|
|
339
|
+
if isinstance(spec_schema, SpecSchema) or spec_schema is None:
|
|
340
|
+
self.spec_schema = spec_schema
|
|
341
|
+
else:
|
|
342
|
+
self.spec_schema = SpecSchema(spec_schema)
|
|
343
|
+
|
|
344
|
+
# Exception raised when spec validation fails
|
|
345
|
+
self.SchemaValidationException = SchemaValidationException
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _headers_to_dict(headers: HDUList | dict | fits.header.Header | str | IO) -> dict:
|
|
349
|
+
"""
|
|
350
|
+
Convert headers from multiple types to a dict
|
|
351
|
+
:param headers: Headers to convert to a dict
|
|
352
|
+
:return: Dict of the headers
|
|
353
|
+
"""
|
|
354
|
+
if isinstance(headers, dict):
|
|
355
|
+
return headers
|
|
356
|
+
if isinstance(headers, fits.header.Header):
|
|
357
|
+
return dict(headers)
|
|
358
|
+
if isinstance(headers, HDUList):
|
|
359
|
+
if len(headers) > 1:
|
|
360
|
+
return dict(headers[1].header)
|
|
361
|
+
return dict(headers[0].header)
|
|
362
|
+
|
|
363
|
+
def verify_headers(self, headers, extra) -> dict:
|
|
364
|
+
"""
|
|
365
|
+
Validates file headers against the instance spec_validator
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
headers
|
|
370
|
+
file headers
|
|
371
|
+
extra
|
|
372
|
+
switch for validation to allow extra keys in schema
|
|
373
|
+
Returns
|
|
374
|
+
-------
|
|
375
|
+
dict of headers
|
|
376
|
+
|
|
377
|
+
Raises
|
|
378
|
+
------
|
|
379
|
+
SchemaValidationException
|
|
380
|
+
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
validation_errors = {}
|
|
384
|
+
try:
|
|
385
|
+
self.spec_schema(headers, extra)
|
|
386
|
+
except vol.MultipleInvalid as e:
|
|
387
|
+
for error in e.errors:
|
|
388
|
+
value_str = "Required keyword not present"
|
|
389
|
+
if error.path[0] in headers:
|
|
390
|
+
value_str = (
|
|
391
|
+
f"Actual value: {headers[error.path[0]]!r} {type(headers[error.path[0]])}"
|
|
392
|
+
)
|
|
393
|
+
validation_errors[error.path[0]] = f"{error.msg}. {value_str}"
|
|
394
|
+
# Raise exception if we have errors
|
|
395
|
+
if validation_errors:
|
|
396
|
+
missing_list = []
|
|
397
|
+
badtype_list = []
|
|
398
|
+
other_list = []
|
|
399
|
+
|
|
400
|
+
for key in validation_errors.keys():
|
|
401
|
+
if "required key not provided" in validation_errors[key]:
|
|
402
|
+
missing_list.append(key)
|
|
403
|
+
elif "expected" in validation_errors[key]:
|
|
404
|
+
badtype_list.append(key)
|
|
405
|
+
else:
|
|
406
|
+
other_list.append(key)
|
|
407
|
+
# Log the bad keys
|
|
408
|
+
for sublist, message in zip(
|
|
409
|
+
[missing_list, badtype_list, other_list],
|
|
410
|
+
[
|
|
411
|
+
"The following keys are missing:",
|
|
412
|
+
"The following keys have bad types:",
|
|
413
|
+
"The following keys have other errors:",
|
|
414
|
+
],
|
|
415
|
+
):
|
|
416
|
+
if len(sublist) > 0:
|
|
417
|
+
logger.debug(f"\n{message}")
|
|
418
|
+
for k in sorted(sublist):
|
|
419
|
+
logger.debug(f"{str(k):<10}:\t{validation_errors[k]}")
|
|
420
|
+
|
|
421
|
+
raise self.SchemaValidationException(errors=validation_errors)
|
|
422
|
+
logger.debug("Schema validation succeeded")
|
|
423
|
+
|
|
424
|
+
return headers
|
|
425
|
+
|
|
426
|
+
def _validate_headers(
|
|
427
|
+
self, input_headers: HDUList | dict | fits.header.Header, extra
|
|
428
|
+
) -> tuple[dict, dict]:
|
|
429
|
+
"""
|
|
430
|
+
Validates open input headers against the instance spec_schema
|
|
431
|
+
:param input_headers: The input headers to validate in the following formats:
|
|
432
|
+
- HDUList object
|
|
433
|
+
- fits.header.Header object
|
|
434
|
+
- Dictionary of header keys and values
|
|
435
|
+
:param extra: switch for validation to allow extra keys in schema
|
|
436
|
+
:return: dictionary of verified headers to be used later
|
|
437
|
+
"""
|
|
438
|
+
if isinstance(input_headers, HDUList):
|
|
439
|
+
if len(input_headers) > 2:
|
|
440
|
+
raise ValidationException(
|
|
441
|
+
"Too many HDUs in your HDUList! May only have two HDUs at most."
|
|
442
|
+
)
|
|
443
|
+
headers = self._headers_to_dict(input_headers)
|
|
444
|
+
fits_cards = self._capture_fits_cards(headers)
|
|
445
|
+
verified_headers = self.verify_headers(headers, extra)
|
|
446
|
+
return verified_headers, fits_cards
|
|
447
|
+
|
|
448
|
+
def _validate_file(self, input_headers: str | IO, extra) -> tuple[dict, dict, np.ndarray]:
|
|
449
|
+
"""
|
|
450
|
+
Validates files against the astropy and then instance spec_schema
|
|
451
|
+
:param input_headers: The input headers to validate in the following formats:
|
|
452
|
+
- string file path
|
|
453
|
+
- File like object
|
|
454
|
+
:param extra: switch for validation to allow extra keys in schema
|
|
455
|
+
:return: dictionary of verified headers to be used later
|
|
456
|
+
"""
|
|
457
|
+
try:
|
|
458
|
+
with fits.open(input_headers) as hdul:
|
|
459
|
+
# verify fits headers with astropy verify library
|
|
460
|
+
hdul.verify("exception")
|
|
461
|
+
# normalize headers into a dict
|
|
462
|
+
try:
|
|
463
|
+
data = hdul[1].data
|
|
464
|
+
except IndexError: # non-compressed
|
|
465
|
+
data = hdul[0].data
|
|
466
|
+
verified_headers, fits_cards = self._validate_headers(hdul, extra)
|
|
467
|
+
return verified_headers, fits_cards, data
|
|
468
|
+
except (ValueError, FileNotFoundError, OSError, IndexError) as exc:
|
|
469
|
+
logger.debug(f"Cannot parse headers: detail = {exc}")
|
|
470
|
+
raise ValidationException("Cannot parse headers", errors={type(exc): str(exc)})
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _return_HDU(validated_headers, data, fits_cards):
|
|
474
|
+
"""
|
|
475
|
+
Returns validated headers as an HDU
|
|
476
|
+
:param validated_headers: Already validated/translated headers to be written out
|
|
477
|
+
into a FITS file
|
|
478
|
+
:param data: original data
|
|
479
|
+
:param fits_cards: Any special cards to be included in the FITS file
|
|
480
|
+
:return: HDU
|
|
481
|
+
"""
|
|
482
|
+
new_hdu = fits.PrimaryHDU(data, header=fits.Header())
|
|
483
|
+
for (key, value) in validated_headers.items():
|
|
484
|
+
new_hdu.header[key] = value
|
|
485
|
+
for key in fits_cards:
|
|
486
|
+
try:
|
|
487
|
+
if key == "HISTORY":
|
|
488
|
+
for line in fits_cards["HISTORY"].splitlines():
|
|
489
|
+
new_hdu.header.add_history(line)
|
|
490
|
+
elif key == "COMMENT":
|
|
491
|
+
for line in fits_cards["COMMENT"].splitlines():
|
|
492
|
+
new_hdu.header.add_comment(line)
|
|
493
|
+
else:
|
|
494
|
+
new_hdu.header[key] = str(fits_cards[key])
|
|
495
|
+
except ValueError as e:
|
|
496
|
+
raise TranslationException("Error writing new header. Invalid header value") from e
|
|
497
|
+
|
|
498
|
+
return new_hdu
|
|
499
|
+
|
|
500
|
+
@classmethod
|
|
501
|
+
def _return_hdulist(cls, validated_headers, fits_cards) -> HDUList:
|
|
502
|
+
"""
|
|
503
|
+
Returns validated headers as an HDUList
|
|
504
|
+
:param validated_headers: Already validated/translated headers to be written out into
|
|
505
|
+
an HDUList
|
|
506
|
+
:param fits_cards: Any special cards to be included in the HDUList
|
|
507
|
+
:return: HDUList
|
|
508
|
+
"""
|
|
509
|
+
if isinstance(validated_headers, HDUList):
|
|
510
|
+
return validated_headers
|
|
511
|
+
temp_array: np.ndarray = np.ones((1, 1, 1), dtype=np.int16)
|
|
512
|
+
new_hdu = cls._return_HDU(validated_headers, data=temp_array, fits_cards=fits_cards)
|
|
513
|
+
new_hdu_list = fits.HDUList([new_hdu])
|
|
514
|
+
return new_hdu_list
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _return_dictionary(validated_headers, fits_cards) -> dict:
|
|
518
|
+
"""
|
|
519
|
+
Returns validated headers as a dictionary
|
|
520
|
+
:param validated_headers: Already validated/translated headers to be written out into
|
|
521
|
+
a dictionary
|
|
522
|
+
:param fits_cards: Any special cards to be included in the dictionary
|
|
523
|
+
:return: dictionary
|
|
524
|
+
"""
|
|
525
|
+
for key in fits_cards:
|
|
526
|
+
validated_headers[key] = str(fits_cards[key])
|
|
527
|
+
return validated_headers
|
|
528
|
+
|
|
529
|
+
@classmethod
|
|
530
|
+
def _return_BytesIO(cls, validated_headers, input_headers, data, fits_cards) -> BytesIO:
|
|
531
|
+
"""
|
|
532
|
+
Returns validated headers as a BytesIO object
|
|
533
|
+
:param validated_headers: Already validated/translated headers to be written out into
|
|
534
|
+
the BytesIO object
|
|
535
|
+
:param input_headers: original filepath or BytesIO object
|
|
536
|
+
:param data: original data
|
|
537
|
+
:param fits_cards: Any special cards to be included in the BytesIO object
|
|
538
|
+
:return: BytesIO object
|
|
539
|
+
"""
|
|
540
|
+
new_hdu = cls._return_HDU(validated_headers, data, fits_cards)
|
|
541
|
+
new_hdu_list = fits.HDUList([new_hdu])
|
|
542
|
+
return BytesIO(
|
|
543
|
+
new_hdu_list.writeto(
|
|
544
|
+
str(os.path.basename(input_headers)),
|
|
545
|
+
overwrite=True,
|
|
546
|
+
output_verify="exception",
|
|
547
|
+
checksum=True,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
@classmethod
|
|
552
|
+
def _return_file(cls, validated_headers, input_headers, data, fits_cards) -> (str, IO):
|
|
553
|
+
"""
|
|
554
|
+
Returns validated headers as a FITS file
|
|
555
|
+
:param validated_headers: Already validated/translated headers to be written out
|
|
556
|
+
into a FITS file
|
|
557
|
+
:param input_headers: original filepath or BytesIO object
|
|
558
|
+
:param data: original data
|
|
559
|
+
:param fits_cards: Any special cards to be included in the FITS file
|
|
560
|
+
:return: FITS file
|
|
561
|
+
"""
|
|
562
|
+
new_hdu = cls._return_HDU(validated_headers, data, fits_cards)
|
|
563
|
+
new_hdu_list = fits.HDUList([new_hdu])
|
|
564
|
+
new_hdu_list.writeto(
|
|
565
|
+
str(os.path.basename(input_headers)),
|
|
566
|
+
overwrite=True,
|
|
567
|
+
output_verify="exception",
|
|
568
|
+
checksum=True,
|
|
569
|
+
)
|
|
570
|
+
return Path(os.path.basename(input_headers))
|
|
571
|
+
|
|
572
|
+
def _format_output(
|
|
573
|
+
self, return_type, validated_headers, input_headers=None, data=None, fits_cards=None
|
|
574
|
+
):
|
|
575
|
+
fits_cards = fits_cards or {}
|
|
576
|
+
if return_type == Path:
|
|
577
|
+
if data is None:
|
|
578
|
+
raise ReturnTypeException("No data. Cannot write file.")
|
|
579
|
+
return self._return_file(validated_headers, input_headers, data, fits_cards)
|
|
580
|
+
if return_type == BytesIO:
|
|
581
|
+
if data is None:
|
|
582
|
+
raise ReturnTypeException("No data. Cannot write BytesIO object.")
|
|
583
|
+
return self._return_BytesIO(validated_headers, input_headers, data, fits_cards)
|
|
584
|
+
if return_type == dict:
|
|
585
|
+
return self._return_dictionary(validated_headers, fits_cards)
|
|
586
|
+
if return_type == HDUList:
|
|
587
|
+
return self._return_hdulist(validated_headers, fits_cards)
|
|
588
|
+
if return_type == fits.header.Header:
|
|
589
|
+
return self._return_HDU(validated_headers, data, fits_cards).header
|
|
590
|
+
if return_type == fits.PrimaryHDU:
|
|
591
|
+
if data is None:
|
|
592
|
+
raise ReturnTypeException("No data. Cannot write PrimaryHDU.")
|
|
593
|
+
return self._return_HDU(validated_headers, data, fits_cards)
|
|
594
|
+
|
|
595
|
+
def _capture_fits_cards(self, validated_headers) -> dict:
|
|
596
|
+
"""
|
|
597
|
+
Pull special fits cards out of validated_headers dict.
|
|
598
|
+
This is necessary for astropy header formatting.
|
|
599
|
+
|
|
600
|
+
:param validated_headers: validated headers
|
|
601
|
+
:return: fits_cards: dictionary containing special fits header keys and values
|
|
602
|
+
"""
|
|
603
|
+
fits_cards = {}
|
|
604
|
+
if "HISTORY" in validated_headers:
|
|
605
|
+
fits_cards["HISTORY"] = str(validated_headers["HISTORY"])
|
|
606
|
+
validated_headers.pop("HISTORY")
|
|
607
|
+
if "COMMENT" in validated_headers:
|
|
608
|
+
fits_cards["COMMENT"] = str(validated_headers["COMMENT"])
|
|
609
|
+
validated_headers.pop("COMMENT")
|
|
610
|
+
# Remove any blank cards
|
|
611
|
+
if "" in validated_headers:
|
|
612
|
+
validated_headers.pop("")
|
|
613
|
+
return fits_cards
|
|
614
|
+
|
|
615
|
+
def validate(self, input_headers, return_type=HDUList, extra=True):
|
|
616
|
+
"""
|
|
617
|
+
Validates against the instance spec_schema
|
|
618
|
+
|
|
619
|
+
Parameters
|
|
620
|
+
----------
|
|
621
|
+
input_headers
|
|
622
|
+
The headers to validate in the following formats:
|
|
623
|
+
- string file path
|
|
624
|
+
- File like object
|
|
625
|
+
- HDUList object
|
|
626
|
+
- fits.header.Header object
|
|
627
|
+
- Dictionary of header keys and values
|
|
628
|
+
|
|
629
|
+
return_type
|
|
630
|
+
Determines return type. Default is HDUList. May be one of:
|
|
631
|
+
- dict
|
|
632
|
+
- BytesIO
|
|
633
|
+
- fits.header.Header
|
|
634
|
+
- Path (file)
|
|
635
|
+
- HDUList
|
|
636
|
+
- fits.PrimaryHDU
|
|
637
|
+
extra
|
|
638
|
+
Switch for validation to allow extra keys in schema. Default is true, which will
|
|
639
|
+
allow extra keys. Ingest validation should allow extra keys.
|
|
640
|
+
|
|
641
|
+
Returns
|
|
642
|
+
-------
|
|
643
|
+
Formatted headers
|
|
644
|
+
|
|
645
|
+
Raises
|
|
646
|
+
------
|
|
647
|
+
dkist_header_validator.SpecValidationException or subclass
|
|
648
|
+
"""
|
|
649
|
+
if isinstance(input_headers, (dict, fits.header.Header, HDUList)):
|
|
650
|
+
validated_headers, fits_cards = self._validate_headers(input_headers, extra)
|
|
651
|
+
return self._format_output(
|
|
652
|
+
return_type, validated_headers, input_headers, None, fits_cards
|
|
653
|
+
)
|
|
654
|
+
validated_headers, fits_cards, data = self._validate_file(input_headers, extra)
|
|
655
|
+
return self._format_output(return_type, validated_headers, input_headers, data, fits_cards)
|
|
656
|
+
|
|
657
|
+
def validate_and_translate_to_214_l0(self, input_headers, return_type=HDUList, extra=True):
|
|
658
|
+
"""
|
|
659
|
+
Validates against the instance spec_schema and then translates to the spec214_l0 schema
|
|
660
|
+
|
|
661
|
+
Parameters
|
|
662
|
+
----------
|
|
663
|
+
input_headers
|
|
664
|
+
The headers to validate in the following formats:
|
|
665
|
+
- string file path
|
|
666
|
+
- File like object
|
|
667
|
+
- HDUList object
|
|
668
|
+
- fits.header.Header object
|
|
669
|
+
- Dictionary of header keys and values
|
|
670
|
+
|
|
671
|
+
return_type
|
|
672
|
+
Determines return type. Default is HDUList. May be one of:
|
|
673
|
+
- dict
|
|
674
|
+
- BytesIO
|
|
675
|
+
- fits.header.Header
|
|
676
|
+
- Path (file)
|
|
677
|
+
- HDUList
|
|
678
|
+
- fits.PrimaryHDU
|
|
679
|
+
extra
|
|
680
|
+
Switch for validation to allow extra keys in schema. Default is true, which will
|
|
681
|
+
allow extra keys. Ingest validation should allow extra keys.
|
|
682
|
+
|
|
683
|
+
Returns
|
|
684
|
+
-------
|
|
685
|
+
Formatted 214 l0 headers
|
|
686
|
+
|
|
687
|
+
Raises
|
|
688
|
+
------
|
|
689
|
+
dkist_header_validator.SpecValidationException or subclass
|
|
690
|
+
"""
|
|
691
|
+
if isinstance(input_headers, (dict, fits.header.Header, HDUList)):
|
|
692
|
+
validated_headers, fits_cards = self._validate_headers(input_headers, extra)
|
|
693
|
+
translated_headers = translate_spec122_to_spec214_l0(validated_headers)
|
|
694
|
+
return self._format_output(return_type, translated_headers, None, None, fits_cards)
|
|
695
|
+
else:
|
|
696
|
+
validated_headers, fits_cards, data = self._validate_file(input_headers, extra)
|
|
697
|
+
translated_headers = translate_spec122_to_spec214_l0(validated_headers)
|
|
698
|
+
return self._format_output(
|
|
699
|
+
return_type, translated_headers, input_headers, data, fits_cards
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class ProcessedSpecValidator(SpecValidator):
|
|
704
|
+
"""
|
|
705
|
+
Validates FITS Headers against a schema that is updated based on the actual headers.
|
|
706
|
+
|
|
707
|
+
The two current examples of a "processed" spec are keys that are updated based on expansion or on conditional
|
|
708
|
+
requiredness.
|
|
709
|
+
|
|
710
|
+
Parameters
|
|
711
|
+
----------
|
|
712
|
+
spec_processor_function
|
|
713
|
+
A function that can process a spec based on an input header. Probably `load_processed_spec214`.
|
|
714
|
+
|
|
715
|
+
SchemaValidationException
|
|
716
|
+
SpecValidationException or subclass of SpecValidationException
|
|
717
|
+
to raise if spec_validator validation fails
|
|
718
|
+
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def __init__(
|
|
722
|
+
self,
|
|
723
|
+
spec_processor_function: Callable,
|
|
724
|
+
SchemaValidationException: Type[SpecValidationException] = SpecValidationException,
|
|
725
|
+
):
|
|
726
|
+
self.spec_processor_function = spec_processor_function
|
|
727
|
+
|
|
728
|
+
# Initializing with `spec_schema=None` is done to avoid logging spam from the fits-spec.
|
|
729
|
+
# The actual `spec_schema` will be updated when a header is verified.
|
|
730
|
+
super().__init__(spec_schema=None, SchemaValidationException=SchemaValidationException)
|
|
731
|
+
|
|
732
|
+
def verify_headers(self, headers, extra) -> dict:
|
|
733
|
+
"""
|
|
734
|
+
Validates file headers against the instance spec_validator
|
|
735
|
+
|
|
736
|
+
Parameters
|
|
737
|
+
----------
|
|
738
|
+
headers
|
|
739
|
+
file headers
|
|
740
|
+
extra
|
|
741
|
+
switch for validation to allow extra keys in schema
|
|
742
|
+
Returns
|
|
743
|
+
-------
|
|
744
|
+
dict of headers
|
|
745
|
+
|
|
746
|
+
Raises
|
|
747
|
+
------
|
|
748
|
+
SchemaValidationException
|
|
749
|
+
"""
|
|
750
|
+
self.spec_schema = SpecSchema(self.spec_processor_function(**headers))
|
|
751
|
+
return super().verify_headers(headers, extra)
|