astro-metadata-translator 29.2025.4200__py3-none-any.whl → 29.2025.4400__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.
@@ -23,18 +23,22 @@ import math
23
23
  import traceback
24
24
  from collections import defaultdict
25
25
  from collections.abc import Sequence
26
- from typing import IO, Any
26
+ from typing import IO, TYPE_CHECKING, Any
27
27
 
28
28
  import astropy.time
29
29
  import astropy.units as u
30
30
  import yaml
31
31
  from astropy.table import Column, MaskedColumn, QTable
32
+ from lsst.resources import ResourcePath
32
33
 
33
34
  from astro_metadata_translator import MetadataTranslator, ObservationInfo, fix_header
34
35
 
35
36
  from ..file_helpers import find_files, read_basic_metadata_from_file
36
37
  from ..properties import PROPERTIES
37
38
 
39
+ if TYPE_CHECKING:
40
+ from lsst.resources import ResourcePathExpression
41
+
38
42
  log = logging.getLogger(__name__)
39
43
 
40
44
  # Output mode choices
@@ -74,7 +78,7 @@ TABLE_COLUMNS = (
74
78
 
75
79
 
76
80
  def read_file(
77
- file: str,
81
+ file: ResourcePathExpression,
78
82
  hdrnum: int,
79
83
  print_trace: bool,
80
84
  output_columns: defaultdict[str, list],
@@ -86,7 +90,7 @@ def read_file(
86
90
 
87
91
  Parameters
88
92
  ----------
89
- file : `str`
93
+ file : `str` or `lsst.resources.ResourcePathExpression`
90
94
  The file from which the header is to be read.
91
95
  hdrnum : `int`
92
96
  The HDU number to read. The primary header is always read and
@@ -127,10 +131,11 @@ def read_file(
127
131
  if output_mode != "table":
128
132
  log.info("Analyzing %s...", file)
129
133
 
134
+ uri = ResourcePath(file, forceDirectory=False)
130
135
  try:
131
- md = read_basic_metadata_from_file(file, hdrnum, can_raise=True)
136
+ md = read_basic_metadata_from_file(uri, hdrnum, can_raise=True)
132
137
  if md is None:
133
- raise RuntimeError(f"Failed to read file {file} HDU={hdrnum}")
138
+ raise RuntimeError(f"Failed to read file {uri} HDU={hdrnum}")
134
139
 
135
140
  if output_mode.endswith("native"):
136
141
  # Strip native and don't change type of md
@@ -141,21 +146,21 @@ def read_file(
141
146
 
142
147
  if output_mode in ("yaml", "fixed"):
143
148
  if output_mode == "fixed":
144
- fix_header(md, filename=file)
149
+ fix_header(md, filename=str(uri))
145
150
 
146
151
  # The header should be written out in the insertion order
147
152
  print(yaml.dump(md, sort_keys=False), file=outstream)
148
153
  return True
149
154
 
150
155
  # Try to work out a translator class.
151
- translator_class = MetadataTranslator.determine_translator(md, filename=file)
156
+ translator_class = MetadataTranslator.determine_translator(md, filename=str(uri))
152
157
 
153
158
  # Work out which headers to translate, assuming the default if
154
159
  # we have a YAML test file.
155
- if file.endswith(".yaml"):
160
+ if uri.getExtension() == ".yaml":
156
161
  headers = [md]
157
162
  else:
158
- headers = list(translator_class.determine_translatable_headers(file, md))
163
+ headers = list(translator_class.determine_translatable_headers(uri, md))
159
164
  if output_mode == "auto":
160
165
  output_mode = "table" if len(headers) > 1 else "verbose"
161
166
 
@@ -163,7 +168,7 @@ def read_file(
163
168
  raise ValueError("Table mode requires output columns")
164
169
 
165
170
  for md in headers:
166
- obs_info = ObservationInfo(md, pedantic=False, filename=file)
171
+ obs_info = ObservationInfo(md, pedantic=False, filename=str(uri))
167
172
  if output_mode == "table":
168
173
  for c in TABLE_COLUMNS:
169
174
  output_columns[c["label"]].append(getattr(obs_info, c["attr"]))
@@ -178,7 +183,7 @@ def read_file(
178
183
  if print_trace:
179
184
  traceback.print_exc(file=outstream)
180
185
  else:
181
- print(f"Failure processing {file}: {e}", file=outstream)
186
+ print(f"Failure processing {uri}: {e}", file=outstream)
182
187
  return False
183
188
  return True
184
189
 
@@ -308,9 +313,9 @@ def translate_or_dump_headers(
308
313
  isok = read_file(path, hdrnum, print_trace, output_columns, outstream, output_mode, heading)
309
314
  heading = False
310
315
  if isok:
311
- okay.append(path)
316
+ okay.append(str(path))
312
317
  else:
313
- failed.append(path)
318
+ failed.append(str(path))
314
319
 
315
320
  # Check if we have exceeded the page size and should dump the table.
316
321
  if output_mode == "table" and len(output_columns[TABLE_COLUMNS[0]["label"]]) >= MAX_TABLE_PAGE_SIZE:
@@ -19,6 +19,8 @@ import os
19
19
  from collections.abc import MutableMapping, Sequence
20
20
  from typing import IO
21
21
 
22
+ from lsst.resources import ResourcePath
23
+
22
24
  from ..file_helpers import find_files
23
25
  from ..indexing import index_files
24
26
 
@@ -87,15 +89,19 @@ def write_index_files(
87
89
 
88
90
  failed = []
89
91
  okay = []
90
- files_per_directory: MutableMapping[str, list[str]] = {}
92
+ files_per_directory: MutableMapping[ResourcePath, list[ResourcePath]] = {}
91
93
 
92
94
  # Group each file by directory if no explicit output path
93
95
  if outpath is None:
94
96
  for path in found_files:
95
- head, tail = os.path.split(path)
96
- files_per_directory.setdefault(head, []).append(tail)
97
+ head, tail = path.split()
98
+ files_per_directory.setdefault(head, []).append(ResourcePath(tail, forceAbsolute=False))
97
99
  else:
98
- files_per_directory["."] = list(found_files)
100
+ # We want the requested files to be paths relative to the current
101
+ # directory. For now this assumes that all the input files are
102
+ # local -- we are not trying to discover a shared root directory.
103
+ cwd = ResourcePath(".", forceAbsolute=True, forceDirectory=True)
104
+ files_per_directory[cwd] = list(found_files)
99
105
 
100
106
  # Extract translated metadata for each file in each directory
101
107
  for directory, files_in_dir in files_per_directory.items():
@@ -113,11 +119,10 @@ def write_index_files(
113
119
 
114
120
  # Write the index file
115
121
  if outpath is None:
116
- index_file = os.path.join(directory, "_index.json")
122
+ index_file = directory.join("_index.json")
117
123
  else:
118
- index_file = outpath
119
- with open(index_file, "w") as fd:
120
- print(json.dumps(output), file=fd)
121
- log.info("Wrote index file to %s", index_file)
124
+ index_file = ResourcePath(outpath, forceAbsolute=False)
125
+ index_file.write(json.dumps(output).encode())
126
+ log.info("Wrote index file to %s", index_file)
122
127
 
123
128
  return okay, failed
@@ -14,45 +14,22 @@ from __future__ import annotations
14
14
  __all__ = ("write_sidecar_file", "write_sidecar_files")
15
15
 
16
16
  import logging
17
- import os
18
17
  import traceback
19
18
  from collections.abc import Sequence
20
- from typing import IO
19
+ from typing import IO, TYPE_CHECKING
21
20
 
22
- from ..file_helpers import find_files, read_file_info
23
-
24
- log = logging.getLogger(__name__)
25
-
26
-
27
- def _split_ext(file: str) -> tuple[str, str]:
28
- """Split the extension from the file name and return it and the root.
29
-
30
- Parameters
31
- ----------
32
- file : `str`
33
- The file name to examine.
21
+ from lsst.resources import ResourcePath
34
22
 
35
- Returns
36
- -------
37
- root : `str`
38
- The root of the file name.
39
- ext : `str`
40
- The file extension. There is special case handling of .gz and other
41
- compression extensions.
42
- """
43
- special = {".gz", ".bz2", ".xz", ".fz"}
44
-
45
- root, ext = os.path.splitext(file)
23
+ from ..file_helpers import find_files, read_file_info
46
24
 
47
- if ext in special:
48
- root, second_ext = os.path.splitext(root)
49
- ext = second_ext + ext
25
+ if TYPE_CHECKING:
26
+ from lsst.resources import ResourcePathExpression
50
27
 
51
- return root, ext
28
+ log = logging.getLogger(__name__)
52
29
 
53
30
 
54
31
  def write_sidecar_file(
55
- file: str,
32
+ file: ResourcePathExpression,
56
33
  hdrnum: int,
57
34
  content_mode: str,
58
35
  print_trace: bool,
@@ -62,7 +39,7 @@ def write_sidecar_file(
62
39
 
63
40
  Parameters
64
41
  ----------
65
- file : `str`
42
+ file : `str` or `lsst.resources.ResourcePathExpression`
66
43
  The file from which the header is to be read.
67
44
  hdrnum : `int`
68
45
  The HDU number to read. The primary header is always read and
@@ -103,12 +80,11 @@ def write_sidecar_file(
103
80
  return False
104
81
 
105
82
  # Calculate sidecar file name derived from this file.
106
- # Match the ButlerURI behavior in that .fits.gz should be replaced
83
+ # .fits.gz should be replaced
107
84
  # with .json, and not resulting in .fits.json.
108
- root, ext = _split_ext(file)
109
- newfile = root + ".json"
110
- with open(newfile, "w") as fd:
111
- print(json_str, file=fd)
85
+ uri = ResourcePath(file)
86
+ newfile = uri.updatedExtension(".json")
87
+ newfile.write(str(json_str).encode())
112
88
  log.debug("Writing sidecar file %s", newfile)
113
89
 
114
90
  except Exception as e:
@@ -169,8 +145,8 @@ def write_sidecar_files(
169
145
  for path in sorted(found_files):
170
146
  isok = write_sidecar_file(path, hdrnum, content_mode, print_trace, outstream)
171
147
  if isok:
172
- okay.append(path)
148
+ okay.append(str(path))
173
149
  else:
174
- failed.append(path)
150
+ failed.append(str(path))
175
151
 
176
152
  return okay, failed
@@ -20,16 +20,21 @@ __all__ = ("find_files", "read_basic_metadata_from_file", "read_file_info")
20
20
 
21
21
  import json
22
22
  import logging
23
- import os
24
23
  import re
25
24
  import traceback
26
25
  from collections.abc import Iterable, MutableMapping
27
- from typing import IO, Any
26
+ from typing import IO, TYPE_CHECKING, Any
27
+
28
+ from astropy.io import fits
29
+ from lsst.resources import ResourcePath
28
30
 
29
31
  from .headers import merge_headers
30
32
  from .observationInfo import ObservationInfo
31
33
  from .tests import read_test_file
32
34
 
35
+ if TYPE_CHECKING:
36
+ from lsst.resources import ResourcePathExpression
37
+
33
38
  log = logging.getLogger(__name__)
34
39
 
35
40
  # Prefer afw over Astropy
@@ -37,30 +42,15 @@ try:
37
42
  import lsst.daf.base # noqa: F401 need PropertyBase for readMetadata
38
43
  from lsst.afw.fits import FitsError, readMetadata
39
44
 
40
- def _read_fits_metadata(file: str, hdu: int, can_raise: bool = False) -> MutableMapping[str, Any] | None:
41
- """Read a FITS header using afw.
42
-
43
- Parameters
44
- ----------
45
- file : `str`
46
- The file to read.
47
- hdu : `int`
48
- The header number to read.
49
- can_raise : `bool`, optional
50
- Indicate whether the function can raise and exception (default)
51
- or should return `None` on error. Can still raise if an unexpected
52
- error is encountered.
53
-
54
- Returns
55
- -------
56
- md : `dict`
57
- The requested header. `None` if it could not be read and
58
- ``can_raise`` is `False`.
59
-
60
- Notes
61
- -----
62
- Tries to catch a FitsError 104 and convert to `FileNotFoundError`.
63
- """
45
+ have_afw = True
46
+
47
+ def _read_fits_metadata_afw(
48
+ file: str, hdu: int, can_raise: bool = False
49
+ ) -> MutableMapping[str, Any] | None:
50
+ # Only works with local files.
51
+ # Tries to catch a FitsError 104 and convert to `FileNotFoundError`.
52
+ # For detailed docstrings see the _read_fits_metadata implementation
53
+ # below.
64
54
  try:
65
55
  return readMetadata(file, hdu=hdu)
66
56
  except FitsError as e:
@@ -72,31 +62,84 @@ try:
72
62
  return None
73
63
 
74
64
  except ImportError:
75
- from astropy.io import fits
65
+ have_afw = False
66
+
67
+
68
+ def _read_fits_metadata_astropy(
69
+ file: ResourcePathExpression, hdu: int, can_raise: bool = False
70
+ ) -> MutableMapping[str, Any] | None:
71
+ """Read a FITS header using astropy.
72
+
73
+ Parameters
74
+ ----------
75
+ file : `str` or `lsst.resources.ResourcePathExpression`
76
+ The file to read.
77
+ hdu : `int`
78
+ The header number to read.
79
+ can_raise : `bool`, optional
80
+ Indicate whether the function can raise and exception (default)
81
+ or should return `None` on error. Can still raise if an unexpected
82
+ error is encountered.
83
+
84
+ Returns
85
+ -------
86
+ md : `dict`
87
+ The requested header. `None` if it could not be read and
88
+ ``can_raise`` is `False`.
89
+ """
90
+ header = None
91
+ uri = ResourcePath(file, forceDirectory=False)
92
+ try:
93
+ fs, fspath = uri.to_fsspec()
94
+ with fs.open(fspath) as f, fits.open(f) as fits_file:
95
+ try:
96
+ # Copy forces a download of the remote resource.
97
+ header = fits_file[hdu].header.copy()
98
+ except IndexError as e:
99
+ if can_raise:
100
+ raise e
101
+ except Exception as e:
102
+ if can_raise:
103
+ raise e
104
+ return header
76
105
 
77
- def _read_fits_metadata(file: str, hdu: int, can_raise: bool = False) -> MutableMapping[str, Any] | None:
78
- """Read a FITS header using astropy."""
79
- # For detailed docstrings see the afw implementation above
80
- header = None
81
- try:
82
- with fits.open(file) as fits_file:
83
- try:
84
- header = fits_file[hdu].header
85
- except IndexError as e:
86
- if can_raise:
87
- raise e
88
- except Exception as e:
89
- if can_raise:
90
- raise e
91
- return header
92
106
 
107
+ def _read_fits_metadata(
108
+ file: ResourcePathExpression, hdu: int, can_raise: bool = False
109
+ ) -> MutableMapping[str, Any] | None:
110
+ """Read a FITS header using afw or astropy.
111
+
112
+ Prefer afw for local reads if available.
93
113
 
94
- def find_files(files: Iterable[str], regex: str) -> list[str]:
114
+ Parameters
115
+ ----------
116
+ file : `str` or `lsst.resources.ResourcePathExpression`
117
+ The file to read.
118
+ hdu : `int`
119
+ The header number to read.
120
+ can_raise : `bool`, optional
121
+ Indicate whether the function can raise and exception (default)
122
+ or should return `None` on error. Can still raise if an unexpected
123
+ error is encountered.
124
+
125
+ Returns
126
+ -------
127
+ md : `dict`
128
+ The requested header. `None` if it could not be read and
129
+ ``can_raise`` is `False`.
130
+ """
131
+ uri = ResourcePath(file, forceAbsolute=False)
132
+ if have_afw and uri.isLocal:
133
+ return _read_fits_metadata_afw(uri.ospath, hdu, can_raise=can_raise)
134
+ return _read_fits_metadata_astropy(uri, hdu, can_raise=can_raise)
135
+
136
+
137
+ def find_files(files: Iterable[ResourcePathExpression], regex: str) -> list[ResourcePath]:
95
138
  """Find files for processing.
96
139
 
97
140
  Parameters
98
141
  ----------
99
- files : iterable of `str`
142
+ files : iterable of `lsst.resources.ResourcePathExpression`
100
143
  The files or directories from which the headers are to be read.
101
144
  regex : `str`
102
145
  Regular expression string used to filter files when a directory is
@@ -104,34 +147,31 @@ def find_files(files: Iterable[str], regex: str) -> list[str]:
104
147
 
105
148
  Returns
106
149
  -------
107
- found_files : `list` of `str`
150
+ found_files : `list` of `lsst.resources.ResourcePath`
108
151
  The files that were found.
109
152
  """
110
153
  file_regex = re.compile(regex)
111
- found_files = []
154
+ found_files: list[ResourcePath] = []
112
155
 
113
156
  # Find all the files of interest
114
- for file in files:
115
- if os.path.isdir(file):
116
- for root, _, files in os.walk(file):
117
- for name in files:
118
- path = os.path.join(root, name)
119
- if os.path.isfile(path) and file_regex.search(name):
120
- found_files.append(path)
157
+ for candidate in files:
158
+ uri = ResourcePath(candidate, forceAbsolute=False)
159
+ if uri.isdir():
160
+ found_files.extend(ResourcePath.findFileResources([uri], file_filter=file_regex, grouped=False))
121
161
  else:
122
- found_files.append(file)
162
+ found_files.append(uri)
123
163
 
124
164
  return found_files
125
165
 
126
166
 
127
167
  def read_basic_metadata_from_file(
128
- file: str, hdrnum: int, can_raise: bool = True
168
+ file: ResourcePathExpression, hdrnum: int, can_raise: bool = True
129
169
  ) -> MutableMapping[str, Any] | None:
130
170
  """Read a raw header from a file, merging if necessary.
131
171
 
132
172
  Parameters
133
173
  ----------
134
- file : `str`
174
+ file : `str` or `lsst.resources.ResourcePathExpression`
135
175
  Name of file to read. Can be FITS, YAML or JSON. YAML or JSON must be
136
176
  a simple top-level dict.
137
177
  hdrnum : `int`
@@ -151,10 +191,11 @@ def read_basic_metadata_from_file(
151
191
  The header as a dict. Can be `None` if there was a problem reading
152
192
  the file.
153
193
  """
154
- if file.endswith(".yaml") or file.endswith(".json"):
194
+ uri = ResourcePath(file, forceAbsolute=False)
195
+ if uri.getExtension() in (".yaml", ".json"):
155
196
  try:
156
197
  md = read_test_file(
157
- file,
198
+ uri,
158
199
  )
159
200
  except Exception as e:
160
201
  if not can_raise:
@@ -165,7 +206,7 @@ def read_basic_metadata_from_file(
165
206
  # YAML can't have HDUs so skip merging below
166
207
  hdrnum = 0
167
208
  else:
168
- md = _read_fits_metadata(file, 0, can_raise=can_raise)
209
+ md = _read_fits_metadata(uri, 0, can_raise=can_raise)
169
210
  if md is None:
170
211
  log.warning("Unable to open file %s", file)
171
212
  return None
@@ -174,19 +215,19 @@ def read_basic_metadata_from_file(
174
215
  hdrnum = 1
175
216
  if hdrnum > 0:
176
217
  # Allow this to fail
177
- mdn = _read_fits_metadata(file, int(hdrnum), can_raise=False)
218
+ mdn = _read_fits_metadata(uri, int(hdrnum), can_raise=False)
178
219
  # Astropy does not allow append mode since it does not
179
220
  # convert lists to multiple cards. Overwrite for now
180
221
  if mdn is not None:
181
222
  md = merge_headers([md, mdn], mode="overwrite")
182
223
  else:
183
- log.warning("HDU %d was not found in file %s. Ignoring request.", hdrnum, file)
224
+ log.warning("HDU %d was not found in file %s. Ignoring request.", hdrnum, uri)
184
225
 
185
226
  return md
186
227
 
187
228
 
188
229
  def read_file_info(
189
- file: str,
230
+ file: ResourcePathExpression,
190
231
  hdrnum: int,
191
232
  print_trace: bool | None = None,
192
233
  content_mode: str = "translated",
@@ -232,9 +273,10 @@ def read_file_info(
232
273
  if content_type not in ("native", "simple", "json"):
233
274
  raise ValueError(f"Unrecognized content type request {content_type}")
234
275
 
276
+ uri = ResourcePath(file, forceAbsolute=False)
235
277
  try:
236
278
  # Calculate the JSON from the file
237
- md = read_basic_metadata_from_file(file, hdrnum, can_raise=True if print_trace is None else False)
279
+ md = read_basic_metadata_from_file(uri, hdrnum, can_raise=True if print_trace is None else False)
238
280
  if md is None:
239
281
  return None
240
282
  if content_mode == "metadata":
@@ -249,7 +291,7 @@ def read_file_info(
249
291
  json_str = json.dumps(dict(md))
250
292
  return json_str
251
293
  return md
252
- obs_info = ObservationInfo(md, pedantic=True, filename=file)
294
+ obs_info = ObservationInfo(md, pedantic=True, filename=str(uri))
253
295
  if content_type == "native":
254
296
  return obs_info
255
297
  simple = obs_info.to_simple()
@@ -22,19 +22,22 @@ __all__ = (
22
22
  "read_sidecar",
23
23
  )
24
24
 
25
- import collections.abc
26
25
  import json
27
26
  import logging
28
- import os
29
27
  from collections.abc import MutableMapping, Sequence
30
28
  from copy import deepcopy
31
- from typing import IO, Any, Literal, overload
29
+ from typing import IO, TYPE_CHECKING, Any, Literal, overload
30
+
31
+ from lsst.resources import ResourcePath
32
32
 
33
33
  from .file_helpers import read_file_info
34
34
  from .headers import merge_headers
35
35
  from .observationGroup import ObservationGroup
36
36
  from .observationInfo import ObservationInfo
37
37
 
38
+ if TYPE_CHECKING:
39
+ from lsst.resources import ResourcePathExpression
40
+
38
41
  log = logging.getLogger(__name__)
39
42
 
40
43
  COMMON_KEY = "__COMMON__"
@@ -42,8 +45,8 @@ CONTENT_KEY = "__CONTENT__"
42
45
 
43
46
 
44
47
  def index_files(
45
- files: Sequence[str],
46
- root: str | None,
48
+ files: Sequence[ResourcePathExpression],
49
+ root: ResourcePathExpression | None,
47
50
  hdrnum: int,
48
51
  print_trace: bool,
49
52
  content: str,
@@ -56,7 +59,7 @@ def index_files(
56
59
 
57
60
  Parameters
58
61
  ----------
59
- files : iterable of `str`
62
+ files : iterable of `lsst.resources.ResourcePathExpression`
60
63
  Paths to the files to be indexed. They do not have to all be
61
64
  in a single directory but all content will be indexed into a single
62
65
  index.
@@ -99,19 +102,22 @@ def index_files(
99
102
 
100
103
  failed: list[str] = []
101
104
  okay: list[str] = []
105
+ root_uri = ResourcePath(root, forceDirectory=True) if root else None
102
106
 
103
107
  content_by_file: MutableMapping[str, MutableMapping[str, Any]] = {} # Mapping of path to file content
104
108
  for file in sorted(files):
105
- if root is not None:
106
- path = os.path.join(root, file)
109
+ uri = ResourcePath(file, forceAbsolute=False, forceDirectory=False)
110
+ if root_uri is not None:
111
+ path = root_uri.join(uri)
107
112
  else:
108
- path = file
113
+ path = uri
109
114
  simple = read_file_info(path, hdrnum, print_trace, content, "simple", outstream)
115
+ path_key = path.ospath if path.isLocal else str(path)
110
116
  if simple is None:
111
- failed.append(path)
117
+ failed.append(path_key)
112
118
  continue
113
119
  else:
114
- okay.append(path)
120
+ okay.append(path_key)
115
121
 
116
122
  # Store the information indexed by the filename within dir
117
123
  # We may get a PropertyList here and can therefore not just
@@ -119,7 +125,13 @@ def index_files(
119
125
  # other 2 options, which we were enforcing with the "simple" parameter
120
126
  # in the call to read_file_info.
121
127
  assert not isinstance(simple, str | ObservationInfo)
122
- content_by_file[file] = simple
128
+ # Force string as key since this is required to be a relative path.
129
+ # Make it relative to the given directory, else it might be absolute.
130
+ if root_uri is not None:
131
+ relative = path.relative_to(root_uri)
132
+ if relative is not None:
133
+ path_key = relative
134
+ content_by_file[path_key] = simple
123
135
 
124
136
  output = calculate_index(content_by_file, content)
125
137
 
@@ -154,9 +166,10 @@ def calculate_index(
154
166
  # For a single file it is possible that the merged contents
155
167
  # are not a dict but are an LSST-style PropertyList. JSON needs
156
168
  # dict though. mypy can't know about PropertyList so we must ignore
157
- # the type error.
158
- if not isinstance(merged, collections.abc.Mapping):
159
- merged = dict(merged) # type: ignore
169
+ # the type error. We also need to force Astropy Header to a dict.
170
+ if not isinstance(merged, dict):
171
+ # dict(Header) brings along additional keys that can't be serialized.
172
+ merged = {k: v for k, v in merged.items()}
160
173
 
161
174
  # The structure to write to file is intended to look like (in YAML):
162
175
  # __COMMON__:
@@ -14,7 +14,6 @@ from __future__ import annotations
14
14
  __all__ = ("MetadataAssertHelper", "read_test_file")
15
15
 
16
16
  import json
17
- import os
18
17
  import pickle
19
18
  import warnings
20
19
  from collections.abc import MutableMapping
@@ -26,6 +25,7 @@ import yaml
26
25
  from astropy.io.fits import Header
27
26
  from astropy.io.fits.verify import VerifyWarning
28
27
  from astropy.time import Time
28
+ from lsst.resources import ResourcePath
29
29
 
30
30
  from astro_metadata_translator import ObservationInfo
31
31
 
@@ -43,6 +43,9 @@ try:
43
43
  except AttributeError:
44
44
  Loader = yaml.Loader
45
45
 
46
+ if TYPE_CHECKING:
47
+ from lsst.resources import ResourcePathExpression
48
+
46
49
 
47
50
  # Define a YAML loader for lsst.daf.base.PropertySet serializations that
48
51
  # we can use if daf_base is not available.
@@ -79,12 +82,14 @@ if daf_base is None:
79
82
  yaml.add_constructor("lsst.daf.base.PropertyList", pl_constructor, Loader=Loader) # type: ignore
80
83
 
81
84
 
82
- def read_test_file(filename: str, dir: str | None = None) -> MutableMapping[str, Any]:
85
+ def read_test_file(
86
+ filename: ResourcePathExpression, dir: ResourcePathExpression | None = None
87
+ ) -> MutableMapping[str, Any]:
83
88
  """Read the named test file relative to the location of this helper.
84
89
 
85
90
  Parameters
86
91
  ----------
87
- filename : `str`
92
+ filename : `str` or `lsst.resources.ResourcePathExpression`
88
93
  Name of file in the data directory.
89
94
  dir : `str`, optional
90
95
  Directory from which to read file. Current directory used if none
@@ -95,15 +100,18 @@ def read_test_file(filename: str, dir: str | None = None) -> MutableMapping[str,
95
100
  header : `dict`-like
96
101
  Header read from file.
97
102
  """
98
- if dir is not None and not os.path.isabs(filename):
99
- filename = os.path.join(dir, filename)
100
- with open(filename) as fd:
101
- if filename.endswith(".yaml"):
102
- header = yaml.load(fd, Loader=Loader)
103
- elif filename.endswith(".json"):
104
- header = json.load(fd)
105
- else:
106
- raise RuntimeError(f"Unrecognized file format: {filename}")
103
+ uri = ResourcePath(filename, forceAbsolute=False, forceDirectory=False)
104
+ if dir is not None and not uri.isabs():
105
+ test_dir = ResourcePath(dir, forceDirectory=True)
106
+ uri = test_dir.join(uri, forceDirectory=False)
107
+ content = uri.read()
108
+ ext = uri.getExtension()
109
+ if ext == ".yaml":
110
+ header = yaml.load(content, Loader=Loader)
111
+ elif ext == ".json":
112
+ header = json.loads(content)
113
+ else:
114
+ raise RuntimeError(f"Unrecognized file format: {uri}")
107
115
 
108
116
  # Cannot directly check for Mapping because PropertyList is not one
109
117
  if not hasattr(header, "items"):
@@ -35,6 +35,7 @@ from .properties import PROPERTIES, PropertyDefinition
35
35
 
36
36
  if TYPE_CHECKING:
37
37
  import astropy.coordinates
38
+ from lsst.resources import ResourcePathExpression
38
39
 
39
40
  log = logging.getLogger(__name__)
40
41
 
@@ -1298,7 +1299,7 @@ class MetadataTranslator:
1298
1299
 
1299
1300
  @classmethod
1300
1301
  def determine_translatable_headers(
1301
- cls, filename: str, primary: MutableMapping[str, Any] | None = None
1302
+ cls, filename: ResourcePathExpression, primary: MutableMapping[str, Any] | None = None
1302
1303
  ) -> Iterator[MutableMapping[str, Any]]:
1303
1304
  """Given a file return all the headers usable for metadata translation.
1304
1305
 
@@ -1325,7 +1326,7 @@ class MetadataTranslator:
1325
1326
 
1326
1327
  Parameters
1327
1328
  ----------
1328
- filename : `str`
1329
+ filename : `str` or `lsst.resources.ResourcePathExpression`
1329
1330
  Path to a file in a format understood by this translator.
1330
1331
  primary : `dict`-like, optional
1331
1332
  The primary header obtained by the caller. This is sometimes
@@ -25,6 +25,7 @@ import astropy.time
25
25
  import astropy.units as u
26
26
  from astropy.coordinates import Angle, EarthLocation, UnknownSiteException
27
27
  from astropy.io import fits
28
+ from lsst.resources import ResourcePath
28
29
 
29
30
  from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation
30
31
  from .fits import FitsTranslator
@@ -32,6 +33,7 @@ from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_de
32
33
 
33
34
  if TYPE_CHECKING:
34
35
  import astropy.coordinates
36
+ from lsst.resources import ResourcePathExpression
35
37
 
36
38
  log = logging.getLogger(__name__)
37
39
 
@@ -425,7 +427,7 @@ class DecamTranslator(FitsTranslator):
425
427
 
426
428
  @classmethod
427
429
  def determine_translatable_headers(
428
- cls, filename: str, primary: MutableMapping[str, Any] | None = None
430
+ cls, filename: ResourcePathExpression, primary: MutableMapping[str, Any] | None = None
429
431
  ) -> Iterator[MutableMapping[str, Any]]:
430
432
  """Given a file return all the headers usable for metadata translation.
431
433
 
@@ -438,7 +440,7 @@ class DecamTranslator(FitsTranslator):
438
440
 
439
441
  Parameters
440
442
  ----------
441
- filename : `str`
443
+ filename : `str` or `lsst.resources.ResourcePathExpression`
442
444
  Path to a file in a format understood by this translator.
443
445
  primary : `dict`-like, optional
444
446
  The primary header obtained by the caller. This is sometimes
@@ -472,7 +474,9 @@ class DecamTranslator(FitsTranslator):
472
474
  # Since we want to scan many HDUs we use astropy directly to keep
473
475
  # the file open rather than continually opening and closing it
474
476
  # as we go to each HDU.
475
- with fits.open(filename) as fits_file:
477
+ uri = ResourcePath(filename, forceDirectory=False)
478
+ fs, fspath = uri.to_fsspec()
479
+ with fs.open(fspath) as f, fits.open(f) as fits_file:
476
480
  # Astropy does not automatically handle the INHERIT=T in
477
481
  # DECam headers so the primary header must be merged.
478
482
  first_pass = True
@@ -48,6 +48,11 @@ log = logging.getLogger(__name__)
48
48
  def to_location_via_telescope_name(self: MetadataTranslator) -> EarthLocation:
49
49
  """Calculate the observatory location via the telescope name.
50
50
 
51
+ Parameters
52
+ ----------
53
+ self : `MetadataTranslator`
54
+ The translator being used.
55
+
51
56
  Returns
52
57
  -------
53
58
  loc : `astropy.coordinates.EarthLocation`
@@ -59,6 +64,11 @@ def to_location_via_telescope_name(self: MetadataTranslator) -> EarthLocation:
59
64
  def is_non_science(self: MetadataTranslator) -> None:
60
65
  """Raise an exception if this is a science observation.
61
66
 
67
+ Parameters
68
+ ----------
69
+ self : `MetadataTranslator`
70
+ The translator being used.
71
+
62
72
  Raises
63
73
  ------
64
74
  KeyError
@@ -94,6 +104,8 @@ def tracking_from_degree_headers(
94
104
 
95
105
  Parameters
96
106
  ----------
107
+ self : `MetadataTranslator`
108
+ The translator being used.
97
109
  radecsys : `list` or `tuple`
98
110
  Header keywords to try corresponding to the tracking system. If none
99
111
  match ICRS will be assumed.
@@ -163,6 +175,8 @@ def altaz_from_degree_headers(
163
175
 
164
176
  Parameters
165
177
  ----------
178
+ self : `MetadataTranslator`
179
+ The translator being used.
166
180
  altazpairs : `tuple` of `str`
167
181
  Pairs of keywords specifying Alt/Az in degrees. Each pair is tried
168
182
  in turn.
@@ -24,6 +24,7 @@ import astropy.time
24
24
  import astropy.units as u
25
25
  from astropy.coordinates import Angle, EarthLocation, UnknownSiteException
26
26
  from astropy.io import fits
27
+ from lsst.resources import ResourcePath
27
28
 
28
29
  from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation
29
30
  from .fits import FitsTranslator
@@ -32,6 +33,7 @@ from .helpers import altaz_from_degree_headers, tracking_from_degree_headers
32
33
  if TYPE_CHECKING:
33
34
  import astropy.coordinates
34
35
  import astropy.units
36
+ from lsst.resources import ResourcePathExpression
35
37
 
36
38
  _MK_FALLBACK_LOCATION = EarthLocation.from_geocentric(
37
39
  -5464301.77135369, -2493489.8730419, 2151085.16950589, unit=u.m
@@ -213,7 +215,7 @@ class MegaPrimeTranslator(FitsTranslator):
213
215
 
214
216
  @classmethod
215
217
  def determine_translatable_headers(
216
- cls, filename: str, primary: MutableMapping[str, Any] | None = None
218
+ cls, filename: ResourcePathExpression, primary: MutableMapping[str, Any] | None = None
217
219
  ) -> Iterator[MutableMapping[str, Any]]:
218
220
  """Given a file return all the headers usable for metadata translation.
219
221
 
@@ -224,7 +226,7 @@ class MegaPrimeTranslator(FitsTranslator):
224
226
 
225
227
  Parameters
226
228
  ----------
227
- filename : `str`
229
+ filename : `str` or `lsst.resources.ResourcePathExpression`
228
230
  Path to a file in a format understood by this translator.
229
231
  primary : `dict`-like, optional
230
232
  The primary header obtained by the caller. This is sometimes
@@ -250,7 +252,9 @@ class MegaPrimeTranslator(FitsTranslator):
250
252
  # Since we want to scan many HDUs we use astropy directly to keep
251
253
  # the file open rather than continually opening and closing it
252
254
  # as we go to each HDU.
253
- with fits.open(filename) as fits_file:
255
+ uri = ResourcePath(filename, forceDirectory=False)
256
+ fs, fspath = uri.to_fsspec()
257
+ with fs.open(fspath) as f, fits.open(f) as fits_file:
254
258
  for hdu in fits_file:
255
259
  # Astropy <=4.2 strips the EXTNAME header but some CFHT data
256
260
  # have two EXTNAME headers and the CCD number is in the
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "29.2025.4200"
2
+ __version__ = "29.2025.4400"
@@ -1,25 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astro-metadata-translator
3
- Version: 29.2025.4200
3
+ Version: 29.2025.4400
4
4
  Summary: A translator for astronomical metadata.
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
- License: BSD 3-Clause License
6
+ License-Expression: BSD-3-Clause
7
7
  Project-URL: Homepage, https://github.com/lsst/astro_metadata_translator
8
8
  Keywords: lsst
9
9
  Classifier: Intended Audience :: Science/Research
10
10
  Classifier: Topic :: Scientific/Engineering :: Astronomy
11
- Classifier: License :: OSI Approved :: BSD License
12
11
  Classifier: Operating System :: OS Independent
13
12
  Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.10
18
- Requires-Python: >=3.10.0
17
+ Requires-Python: >=3.11.0
19
18
  Description-Content-Type: text/markdown
20
19
  License-File: LICENSE
21
20
  Requires-Dist: astropy>=3.0.5
22
21
  Requires-Dist: pyyaml>=3.13
22
+ Requires-Dist: lsst-resources
23
+ Requires-Dist: fsspec
23
24
  Requires-Dist: click>=8
24
25
  Provides-Extra: test
25
26
  Requires-Dist: pytest; extra == "test"
@@ -1,18 +1,18 @@
1
1
  astro_metadata_translator/__init__.py,sha256=TgZ7RPdgoCQwsGlvIOMcrDTLBPefDVhvkb-S6N5XTWY,595
2
- astro_metadata_translator/file_helpers.py,sha256=Y8jovSkAjjHIUMbY4gtiEdEZZ8KzHFCXwP2JJLR9p5I,9440
2
+ astro_metadata_translator/file_helpers.py,sha256=jAqiwCr3HzdA-ruLfMZz6pOFxaUIojmjn-dk4bXVT7A,10913
3
3
  astro_metadata_translator/headers.py,sha256=21KWaPnBPDkonh5coZ0NolMdtM6KVozqi1Fd2d1z80M,18944
4
- astro_metadata_translator/indexing.py,sha256=GxQNDJ_-YVqNBAbaUFku6MrzgKrcS7yTLUO498b3rCM,14530
4
+ astro_metadata_translator/indexing.py,sha256=WcltI-8Jiu7JzKcn_xtT1YRAJ7XD_m-WNKY7J2XPVx8,15340
5
5
  astro_metadata_translator/observationGroup.py,sha256=XRP8xpHQXx4hSl_SrBBan_q1gmxpu2VuepjLZEWthsQ,8689
6
6
  astro_metadata_translator/observationInfo.py,sha256=mlxtrOlJB7qjMZzxVo2-8CjC-KFCexZsBkI_9VtXakI,27543
7
7
  astro_metadata_translator/properties.py,sha256=oiatCGP6g7O0sm5dku50dUyMJtu8IORFLXRu4Sx1hCo,18787
8
8
  astro_metadata_translator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- astro_metadata_translator/tests.py,sha256=S73nreZm3pCfRlU8sKHbXfEoEkZ2zSY5pbHs9hDYBAM,13251
10
- astro_metadata_translator/translator.py,sha256=5dA5Dsj57PxTMtdE-t1jixWHLPkW1BNsdUJhiYfsbNc,56602
11
- astro_metadata_translator/version.py,sha256=loN_SewEappJtRr1bfMkUAloXYiZtM4w0O03FF5yeZQ,55
9
+ astro_metadata_translator/tests.py,sha256=QCIniK0S0MBnDXudTvH_Z4vSn7ItlIJzOXSRrFafB-4,13552
10
+ astro_metadata_translator/translator.py,sha256=o-MYrRNWwQwidcXcmtQB69ucGHEBhITv33hHBEloTgw,56718
11
+ astro_metadata_translator/version.py,sha256=c_EQrzHIElqjF0MSUCohtezaryfBlqz_McPyAghglVk,55
12
12
  astro_metadata_translator/bin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- astro_metadata_translator/bin/translate.py,sha256=uf69YI58nxVI4i-p5nPxPsQWdHlk-QY_WmMBkeeKQB0,11018
14
- astro_metadata_translator/bin/writeindex.py,sha256=pGdN0Z8WNQKGIFCJz_YHd384nAxVsBKK6JQ1lWQbTN8,4220
15
- astro_metadata_translator/bin/writesidecar.py,sha256=kPtJ22DvrFQqlSDut7jDZ_hxqaeW57qfpyn0Iq8RXL4,5427
13
+ astro_metadata_translator/bin/translate.py,sha256=RzUBoq4ZfpDx7Xg2O43DmguKELUUY0xMLMUdAe12ctE,11284
14
+ astro_metadata_translator/bin/writeindex.py,sha256=JYNgbrTjy-ExeWmg2EdXNe6zdwaICfQSvMcSFiRPRKM,4586
15
+ astro_metadata_translator/bin/writesidecar.py,sha256=85v_oF4sp3XmA8Rb3mYPhtY-tFLfa1cyd0fvfCINlRE,4938
16
16
  astro_metadata_translator/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  astro_metadata_translator/cli/astrometadata.py,sha256=F8MHvWCgykijmOKKpy7YfQv3E5MuJFTl3SNeLHJrosw,9609
18
18
  astro_metadata_translator/corrections/CFHT/README.md,sha256=qgf5zPK9NUtlufSPESZgKqizWHH4fIL58IKWbmmjjWQ,190
@@ -50,18 +50,18 @@ astro_metadata_translator/corrections/SuprimeCam/README.md,sha256=UaaDRv8T5ik-iY
50
50
  astro_metadata_translator/serialize/__init__.py,sha256=pthEgasrT3Qw4e7nZk_X1rPBjDju5AsdHDnxqoOoAsU,427
51
51
  astro_metadata_translator/serialize/fits.py,sha256=mjBYhL_wy_-aGgmswxMn1bIgq0U764uv02ge_1_5iRg,3811
52
52
  astro_metadata_translator/translators/__init__.py,sha256=RHzTw7pnp5o5wyZPWg__udthT6qFQMZAJBsicxkRVZY,560
53
- astro_metadata_translator/translators/decam.py,sha256=R0M_p5EpPVvpeEQk0nSJdd34UJiIUvqBrlbsC_NQbOc,18286
53
+ astro_metadata_translator/translators/decam.py,sha256=03nXEPol97ZTRDitxVMZek8alo7arockSYfTJRgPGXY,18553
54
54
  astro_metadata_translator/translators/fits.py,sha256=h4tIB2XIg_oV-m5kVe6ny1gJIKMcDsEBO-SvC_NWinM,7030
55
- astro_metadata_translator/translators/helpers.py,sha256=SrfI1qo-jLZfsCO60AU9Mft8ecPx8VAq35ap7gv2we0,7478
55
+ astro_metadata_translator/translators/helpers.py,sha256=uK4q3r2dLzzJVJMjlEyT14lQCDogASp0h_lCGmYeMOc,7808
56
56
  astro_metadata_translator/translators/hsc.py,sha256=Ca7zKICl04HrKQG51_bWyxAio7Oiibuuw39fcvtG6wI,8529
57
- astro_metadata_translator/translators/megaprime.py,sha256=n46ZkE95XuybU5JOKhBTE1VIiS-aNwxjuaJxjrz_BH0,10882
57
+ astro_metadata_translator/translators/megaprime.py,sha256=fWbXVh_klB6t1dLJFMqfzX0W8XVoQOgo-M5Yxfy3vUw,11149
58
58
  astro_metadata_translator/translators/sdss.py,sha256=prUTawPqAXzJ5i2p2yXH3ekLuj3rWGy-YjJMquJPMq0,9445
59
59
  astro_metadata_translator/translators/subaru.py,sha256=-CTzxc2MpgEEpma-toD6B8RcvV-t5MNZq_fb4UGbHQU,2143
60
60
  astro_metadata_translator/translators/suprimecam.py,sha256=b3S54aHvZ2SprvXRdOLCIJToj6KAiqjK-JB5My8xLWM,8409
61
- astro_metadata_translator-29.2025.4200.dist-info/licenses/LICENSE,sha256=6DRIob1hPfPI0HmYxqGJXcJr7Vc81mXQJHtjH29gygw,1518
62
- astro_metadata_translator-29.2025.4200.dist-info/METADATA,sha256=i-8vIMr7lKEsB9Z36bHERlkM8dAD3KTUm6LLdnfpHNM,1901
63
- astro_metadata_translator-29.2025.4200.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
- astro_metadata_translator-29.2025.4200.dist-info/entry_points.txt,sha256=9dwf9VZWuNtomGwNBEc0S9HJ740wnPSVEO8yNEPto6Y,83
65
- astro_metadata_translator-29.2025.4200.dist-info/top_level.txt,sha256=ll138q-If3_TxKr2nTm5F3MEhWqvHbF3ptEDOhvgfRU,26
66
- astro_metadata_translator-29.2025.4200.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
67
- astro_metadata_translator-29.2025.4200.dist-info/RECORD,,
61
+ astro_metadata_translator-29.2025.4400.dist-info/licenses/LICENSE,sha256=6DRIob1hPfPI0HmYxqGJXcJr7Vc81mXQJHtjH29gygw,1518
62
+ astro_metadata_translator-29.2025.4400.dist-info/METADATA,sha256=uHFtTQtW43XvoksqzVbKiXy4S6pWl-7rymU56Q7OsmQ,1905
63
+ astro_metadata_translator-29.2025.4400.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
+ astro_metadata_translator-29.2025.4400.dist-info/entry_points.txt,sha256=9dwf9VZWuNtomGwNBEc0S9HJ740wnPSVEO8yNEPto6Y,83
65
+ astro_metadata_translator-29.2025.4400.dist-info/top_level.txt,sha256=ll138q-If3_TxKr2nTm5F3MEhWqvHbF3ptEDOhvgfRU,26
66
+ astro_metadata_translator-29.2025.4400.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
67
+ astro_metadata_translator-29.2025.4400.dist-info/RECORD,,