Coverage for python/astro_metadata_translator/bin/translate.py: 14%

110 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:26 -0700

1# This file is part of astro_metadata_translator. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the LICENSE file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12"""Implementation of the ``astrometadata translate`` command-line. 

13 

14Read file metadata from the specified files and report the translated content. 

15""" 

16 

17from __future__ import annotations 

18 

19__all__ = ("translate_or_dump_headers",) 

20 

21import logging 

22import math 

23import traceback 

24from collections import defaultdict 

25from collections.abc import Sequence 

26from typing import IO, Any 

27 

28import astropy.time 

29import astropy.units as u 

30import yaml 

31from astropy.table import Column, MaskedColumn, QTable 

32 

33from astro_metadata_translator import MetadataTranslator, ObservationInfo, fix_header 

34 

35from ..file_helpers import find_files, read_basic_metadata_from_file 

36from ..properties import PROPERTIES 

37 

38log = logging.getLogger(__name__) 

39 

40# Output mode choices 

41OUTPUT_MODES = ("auto", "verbose", "table", "yaml", "fixed", "yamlnative", "fixednative", "none") 

42 

43# Number of rows per table page. 

44# This is a minimum given that DECam data files can include multiple headers. 

45MAX_TABLE_PAGE_SIZE = 500 

46 

47# Definitions for table columns 

48TABLE_COLUMNS = ( 

49 {"format": "<", "attr": "observation_id", "label": "ObsId"}, 

50 { 

51 "format": "<", 

52 "attr": "observation_type", 

53 "label": "ImgType", 

54 }, 

55 { 

56 "format": "<", 

57 "attr": "object", 

58 "label": "Object", 

59 }, 

60 { 

61 "format": "<", 

62 "attr": "physical_filter", 

63 "label": "Filter", 

64 }, 

65 {"format": ">8.8s", "attr": "detector_unique_name", "label": "Detector"}, 

66 { 

67 "format": None, 

68 "attr": "exposure_time", 

69 "label": "ExpTime", 

70 "bad": math.nan * u.s, 

71 }, 

72 {"attr": "datetime_begin", "label": "Observation Date", "bad": astropy.time.Time(0.0, format="jd")}, 

73) 

74 

75 

76def read_file( 

77 file: str, 

78 hdrnum: int, 

79 print_trace: bool, 

80 output_columns: defaultdict[str, list], 

81 outstream: IO | None = None, 

82 output_mode: str = "verbose", 

83 write_heading: bool = False, 

84) -> bool: 

85 """Read the specified file and process it. 

86 

87 Parameters 

88 ---------- 

89 file : `str` 

90 The file from which the header is to be read. 

91 hdrnum : `int` 

92 The HDU number to read. The primary header is always read and 

93 merged with the header from this HDU. 

94 print_trace : `bool` 

95 If there is an error reading the file and this parameter is `True`, 

96 a full traceback of the exception will be reported. If `False` prints 

97 a one line summary of the error condition. 

98 output_columns : `collections.defaultdict` [ `str`, `list` ] 

99 For table mode, a place to store the columns. 

100 outstream : `io.StringIO`, optional 

101 Output stream to use for standard messages. Defaults to `None` which 

102 uses the default output stream. 

103 output_mode : `str`, optional 

104 Output mode to use. Must be one of "verbose", "none", "table", 

105 "yaml", or "fixed". "yaml" and "fixed" can be modified with a 

106 "native" suffix to indicate that the output should be a representation 

107 of the native object type representing the header (which can be 

108 PropertyList or an Astropy header). Without this modify headers 

109 will be dumped as simple `dict` form. 

110 "auto" is used to indicate that a single file has been specified 

111 but the output will depend on whether the file is a multi-extension 

112 FITS file or not. 

113 write_heading : `bool`, optional 

114 If `True` and in table mode, write a table heading out before writing 

115 the content. 

116 

117 Returns 

118 ------- 

119 success : `bool` 

120 `True` if the file was handled successfully, `False` if the file 

121 could not be processed. 

122 """ 

123 if output_mode not in OUTPUT_MODES: 

124 raise ValueError(f"Output mode of '{output_mode}' is not understood.") 

125 

126 # This gets in the way in tabular mode 

127 if output_mode != "table": 

128 log.info("Analyzing %s...", file) 

129 

130 try: 

131 md = read_basic_metadata_from_file(file, hdrnum, can_raise=True) 

132 if md is None: 

133 raise RuntimeError(f"Failed to read file {file} HDU={hdrnum}") 

134 

135 if output_mode.endswith("native"): 

136 # Strip native and don't change type of md 

137 output_mode = output_mode[: -len("native")] 

138 else: 

139 # Rewrite md as simple dict for output 

140 md = dict(md.items()) 

141 

142 if output_mode in ("yaml", "fixed"): 

143 if output_mode == "fixed": 

144 fix_header(md, filename=file) 

145 

146 # The header should be written out in the insertion order 

147 print(yaml.dump(md, sort_keys=False), file=outstream) 

148 return True 

149 

150 # Try to work out a translator class. 

151 translator_class = MetadataTranslator.determine_translator(md, filename=file) 

152 

153 # Work out which headers to translate, assuming the default if 

154 # we have a YAML test file. 

155 if file.endswith(".yaml"): 

156 headers = [md] 

157 else: 

158 headers = list(translator_class.determine_translatable_headers(file, md)) 

159 if output_mode == "auto": 

160 output_mode = "table" if len(headers) > 1 else "verbose" 

161 

162 if output_mode == "table" and output_columns is None: 

163 raise ValueError("Table mode requires output columns") 

164 

165 for md in headers: 

166 obs_info = ObservationInfo(md, pedantic=False, filename=file) 

167 if output_mode == "table": 

168 for c in TABLE_COLUMNS: 

169 output_columns[c["label"]].append(getattr(obs_info, c["attr"])) 

170 elif output_mode == "verbose": 

171 print(f"{obs_info}", file=outstream) 

172 elif output_mode == "none": 

173 pass 

174 else: 

175 raise RuntimeError(f"Output mode of '{output_mode}' not recognized but should be known.") 

176 

177 except Exception as e: 

178 if print_trace: 

179 traceback.print_exc(file=outstream) 

180 else: 

181 print(f"Failure processing {file}: {e}", file=outstream) 

182 return False 

183 return True 

184 

185 

186def _fill_bad_values(value: Any, fillvalue: Any) -> Any: 

187 """Convert None values to the fill value. 

188 

189 Parameters 

190 ---------- 

191 value : `Any` 

192 Value to check. 

193 fillvalue : `Any` 

194 Value to use if `None`. 

195 

196 Returns 

197 ------- 

198 filled : `Any` 

199 Original value or the fill value. 

200 """ 

201 return value if value is not None else fillvalue 

202 

203 

204def _dump_columns(output_columns: dict[str, list], outstream: IO | None = None) -> None: 

205 """Write columns to output stream as a table. 

206 

207 Parameters 

208 ---------- 

209 output_columns : `dict` [`str`, `list`] 

210 The columns to be written, indexed by column name. 

211 outstream : `io.StringIO` or `None`, optional 

212 Output stream to use for standard messages. Defaults to `None` which 

213 uses the default output stream. 

214 """ 

215 if not output_columns: 

216 return 

217 

218 qt = QTable() 

219 for c in TABLE_COLUMNS: 

220 data = output_columns[c["label"]] 

221 # If the column has no content, assume no columns have content and 

222 # abandon table output. 

223 if not len(data): 

224 return 

225 

226 need_mask = False 

227 mask = [] 

228 for v in data: 

229 is_none = v is None 

230 if is_none: 

231 need_mask = True 

232 mask.append(is_none) 

233 col_format = c.get("format") 

234 

235 if need_mask: 

236 data = [_fill_bad_values(v, c.get("bad", "-")) for v in output_columns[c["label"]]] 

237 

238 # Quantity have to be handled in special way since they need 

239 # to be merged into a single entity before they can be stored 

240 # in a column. 

241 if issubclass(PROPERTIES[c["attr"]].py_type, u.Quantity): 

242 data = u.Quantity(data) 

243 elif issubclass(PROPERTIES[c["attr"]].py_type, astropy.time.Time): 

244 # Force to ISO string. 

245 data = astropy.time.Time(data).isot 

246 

247 if need_mask: 

248 data = MaskedColumn(name=c["label"], data=data, mask=mask, format=col_format) 

249 else: 

250 data = Column(data=data, name=c["label"], format=col_format) 

251 qt[c["label"]] = data 

252 

253 print("\n".join(qt.pformat(max_lines=-1, max_width=-1)), file=outstream) 

254 

255 

256def translate_or_dump_headers( 

257 files: Sequence[str], 

258 regex: str, 

259 hdrnum: int, 

260 print_trace: bool, 

261 outstream: IO | None = None, 

262 output_mode: str = "auto", 

263) -> tuple[list[str], list[str]]: 

264 """Read and translate metadata from the specified files. 

265 

266 Parameters 

267 ---------- 

268 files : iterable of `str` 

269 The files or directories from which the headers are to be read. 

270 regex : `str` 

271 Regular expression string used to filter files when a directory is 

272 scanned. 

273 hdrnum : `int` 

274 The HDU number to read. The primary header is always read and 

275 merged with the header from this HDU. 

276 print_trace : `bool` 

277 If there is an error reading the file and this parameter is `True`, 

278 a full traceback of the exception will be reported. If `False` prints 

279 a one line summary of the error condition. 

280 outstream : `io.StringIO` or `None`, optional 

281 Output stream to use for standard messages. Defaults to `None` which 

282 uses the default output stream. 

283 output_mode : `str`, optional 

284 Output mode to use for the translated information. 

285 "auto" switches based on how many files are found. 

286 

287 Returns 

288 ------- 

289 okay : `list` of `str` 

290 All the files that were processed successfully. 

291 failed : `list` of `str` 

292 All the files that could not be processed. 

293 """ 

294 found_files = find_files(files, regex) 

295 

296 # Convert "auto" to correct mode but for a single file keep it 

297 # auto in case that file has multiple headers 

298 if output_mode == "auto": 

299 if len(found_files) > 1: 

300 output_mode = "table" 

301 

302 # Process each file 

303 failed = [] 

304 okay = [] 

305 heading = True 

306 output_columns: defaultdict[str, list] = defaultdict(list) 

307 for path in sorted(found_files): 

308 isok = read_file(path, hdrnum, print_trace, output_columns, outstream, output_mode, heading) 

309 heading = False 

310 if isok: 

311 okay.append(path) 

312 else: 

313 failed.append(path) 

314 

315 # Check if we have exceeded the page size and should dump the table. 

316 if output_mode == "table" and len(output_columns[TABLE_COLUMNS[0]["label"]]) >= MAX_TABLE_PAGE_SIZE: 

317 _dump_columns(output_columns, outstream) 

318 output_columns = defaultdict(list) 

319 

320 if output_columns: 

321 _dump_columns(output_columns, outstream) 

322 

323 return okay, failed