Repository: astropy/astropy

Issue:
# ECSV error reading if YAML's tag not used

Hi.

astropy.version.**version**: u'1.3.2'

I have a doubt whether the following behaviour is intentional.
In the following, *reading* means:
```
from astropy import table
t = table.Table.read('myfile.ecsv', format='ascii.ecsv')
```
Reading a ECSV file like the following crashes the process:
```
# %ECSV 0.9
# ---
# meta:
# - keyword:
#    this_is: a_test
# datatype:
# - name: fake
#   datatype: str
fake
0
```

And the problem is the missing argument `!!omap` after `meta`.
The followgin works fine:
```
# %ECSV 0.9
# ---
# meta: !!omap
# - keyword:
#    this_is: a_test
# datatype:
# - name: fake
#   datatype: str
fake
0
```

Is there a bug or the ordered dictionary (yaml's `omap`) is really mandatory for Astropy's table?
Thanks.


Implementation patch:
diff --git a/astropy/io/ascii/ecsv.py b/astropy/io/ascii/ecsv.py
index a4992bc1eef9..0d088e289c18 100644
--- a/astropy/io/ascii/ecsv.py
+++ b/astropy/io/ascii/ecsv.py
@@ -13,6 +13,7 @@
 import numpy as np
 
 from astropy.io.ascii.core import convert_numpy
+from astropy.io.misc.ecsv import table_meta_as_dict
 from astropy.table import meta, serialize
 from astropy.utils.data_info import serialize_context_as
 from astropy.utils.exceptions import AstropyUserWarning
@@ -194,8 +195,7 @@ def get_cols(self, lines):
                 "unable to parse yaml in meta header"
             ) from e
 
-        if "meta" in header:
-            self.table_meta = header["meta"]
+        self.table_meta = table_meta_as_dict(header)
 
         if "delimiter" in header:
             delimiter = header["delimiter"]
@@ -515,7 +515,6 @@ class Ecsv(basic.Basic):
     ----- -----
       001     2
       004     3
-
     """
 
     _format_name = "ecsv"
diff --git a/astropy/io/misc/ecsv.py b/astropy/io/misc/ecsv.py
index efca71b7ab9b..6eb9fc7e00c0 100644
--- a/astropy/io/misc/ecsv.py
+++ b/astropy/io/misc/ecsv.py
@@ -489,6 +489,39 @@ def get_header_lines(
     return lines, idx, n_empty, n_comment
 
 
+def table_meta_as_dict(header) -> dict:
+    """Ensure meta information is some type of dictionary.
+
+    The ECSV format allows arbitrary data types for the `meta` keyword
+    but astropy requires a mapping (dict) for the meta information in a table.
+    For non-dict ECSV meta, we need to put the structure into a dict.
+    If that happens, warn the user of the conversion.
+
+    Parameters
+    ----------
+    header : dict
+        The header dictionary parsed from the ECSV file.
+        This contains the "meta" key if meta information is in the file.
+
+    Returns
+    -------
+    dict : dict
+        A dictionary containing the meta information. If the input `meta` is already
+        a dict, it is returned as is. Otherwise, it is wrapped in a dict under the key 'meta'.
+    """
+    if "meta" not in header:
+        return {}
+    meta = header["meta"]
+    if isinstance(meta, dict):
+        return meta
+
+    warnings.warn(
+        f"Found ECSV table meta of type {type(meta).__name__} instead of mapping (dict)."
+        f"This data structure is being copied to the 'meta' key of the output table meta attribute."
+    )
+    return {"meta": meta}
+
+
 def get_csv_np_type_dtype_shape(
     datatype: str, subtype: str | None, name: str
 ) -> DerivedColumnProperties:
@@ -654,7 +687,7 @@ def read_header(
     except meta.YamlParseError as e:
         raise InconsistentTableError("unable to parse yaml in meta header") from e
 
-    table_meta = header.get("meta", None)
+    table_meta = table_meta_as_dict(header)
 
     delimiter = header.get("delimiter", " ")
     if delimiter not in DELIMITERS:


Test patch:
diff --git a/astropy/io/ascii/tests/test_ecsv.py b/astropy/io/ascii/tests/test_ecsv.py
index 8e7eaac8c203..adcf219e17ac 100644
--- a/astropy/io/ascii/tests/test_ecsv.py
+++ b/astropy/io/ascii/tests/test_ecsv.py
@@ -1305,3 +1305,77 @@ def test_compressed_files(tmp_path, format_engine, compressed_filename):
     # Open compressed file and compare to ensure it's read correctly
     t_comp = Table.read(compressed_filename, **format_engine)
     assert_array_equal(t, t_comp)
+
+
+def test_meta_not_omap_but_list(format_engine):
+    """The ecsv spec allows that header meta to be any valid safe YAML.
+
+    Though astropy's writer writes with an !!omap tag, and all examples
+    in the ecsv spec (https://github.com/astropy/astropy-APEs/blob/main/APE6.rst)
+    use that extra (non standard) tag, the spec specifically says
+    that the header meta
+    'an arbitrary data structure consisting purely of data types that can be encoded
+    and decoded with the YAML "safe" dumper and loader'.
+
+    See one example in https://github.com/astropy/astropy/issues/5990
+    """
+    txt = """
+# %ECSV 1.0
+# ---
+# meta:
+# - keywords:
+#   - {z_key1: val1}
+#   - {a_key2: val2}
+# - comments: [Comment 1, Comment 2, Comment 3]
+# datatype:
+# - name: fake
+#   datatype: string
+fake
+0"""
+    with pytest.warns(
+        UserWarning,
+        match="Found ECSV table meta of type list instead of",
+    ):
+        t = Table.read(txt, **format_engine)
+    assert len(t.meta) == 1
+    assert len(t.meta["meta"]) == 2
+    assert t.meta["meta"][0] == {"keywords": [{"z_key1": "val1"}, {"a_key2": "val2"}]}
+    assert t.meta["meta"][1] == {"comments": ["Comment 1", "Comment 2", "Comment 3"]}
+
+
+MAP_BUT_NOT_OMAP_LINES = [
+    "# %ECSV 1.0",
+    "# ---",
+    "# datatype:",
+    "# - {name: fake, datatype: string}",
+    "# meta: !!omap",
+    "# - {hr: 65}",
+    "# - {avg: 0.278}",
+    "# - {rbi: 147}",
+    "# schema: astropy-2.0",
+    "fake",
+    "0",
+]
+
+
+def test_meta_not_omap_but_map(format_engine):
+    """Like test_meta_not_omap_but_list but for a mapping"""
+    txt = """
+# %ECSV 1.0
+# ---
+# datatype:
+# - {name: fake, datatype: string}
+# meta:
+#   hr:  65    # Home runs
+#   avg: 0.278 # Batting average
+#   rbi: 147   # Runs Batted In
+# schema: astropy-2.0
+fake
+0"""
+    t = Table.read(txt, **format_engine)
+    assert t.meta["hr"] == 65
+    assert t.meta["avg"] == 0.278
+    assert t.meta["rbi"] == 147
+    out = StringIO()
+    t.write(out, format="ascii.ecsv")
+    assert out.getvalue().splitlines() == MAP_BUT_NOT_OMAP_LINES