Repository: astropy/astropy

Issue:
BUG: tables do not deal well with zero-sized string columns
### Description

@saimn [noted](https://github.com/astropy/astropy/pull/16894#issuecomment-2314640002) in #16894 that zero sized data are a problem:
```
import numpy as np
from astropy.io import fits
from astropy.table import QTable, Table
data = np.array([("", 12)], dtype=[("a", "S"), ("b", "i4")])
fits.BinTableHDU(data).writeto("zerodtable.fits", overwrite=True)
t = Table.read("zerodtable.fits")
t

File ~/dev/astropy/astropy/astropy/table/pprint.py:295, in TableFormatter._pformat_col(self, col, max_lines, show_name, show_unit, show_dtype, show_length, html, align)
    283 col_strs_iter = self._pformat_col_iter(
    284     col,
    285     max_lines,
   (...)
    290     outs=outs,
    291 )
    293 # Replace tab and newline with text representations so they display nicely.
    294 # Newline in particular is a problem in a multicolumn table.
--> 295 col_strs = [
    296     val.replace("\t", "\\t").replace("\n", "\\n") for val in col_strs_iter
    297 ]
    298 if len(col_strs) > 0:
    299     col_width = max(len(x) for x in col_strs)

File ~/dev/astropy/astropy/astropy/table/pprint.py:452, in TableFormatter._pformat_col_iter(self, col, max_lines, show_name, show_unit, outs, show_dtype, show_length)
    450 n_header += 1
    451 if dtype is not None:
--> 452     col_dtype = dtype_info_name((dtype, multidims))
    453 else:
    454     col_dtype = col.__class__.__qualname__ or "object"

File ~/dev/astropy/astropy/astropy/utils/data_info.py:100, in dtype_info_name(dtype)
     74 def dtype_info_name(dtype):
     75     """Return a human-oriented string name of the ``dtype`` arg.
     76     This can be use by astropy methods that present type information about
     77     a data object.
   (...)
     98         String name of ``dtype``
     99     """
--> 100     dtype = np.dtype(dtype)
    101     if dtype.names is not None:
    102         info_names = ", ".join(dtype_info_name(dt[0]) for dt in dtype.fields.values())

ValueError: invalid itemsize in generic type tuple
```
This problem is in displaying, not creating the table:
```
t["a"]
<MaskedColumn name='a' dtype='bytes0' length=1>
--
```
The display problem seems a bug in numpy - https://github.com/numpy/numpy/issues/27301 - but one we should work around for now.

Furthermore, `QTable` can read it, but changes the size
```
QTable.read("zerodtable.fits")
<QTable length=1>
  a      b  
bytes1 int32
------ -----
    --    12
```

This turns out to be because in `io`, for table subclasses, one does `QTable(table)` - and with a copy this changes the dtype.

Discussion:

Patch:
diff --git a/astropy/io/registry/core.py b/astropy/io/registry/core.py
index 9147101f8b..b7325c4beb 100644
--- a/astropy/io/registry/core.py
+++ b/astropy/io/registry/core.py
@@ -225,7 +225,7 @@ class UnifiedInputRegistry(_UnifiedIORegistryBase):
                 # registered.  This returns the parent class, so try coercing
                 # to desired subclass.
                 try:
-                    data = cls(data)
+                    data = cls(data, copy=False)
                 except Exception:
                     raise TypeError(
                         f"could not convert reader output to {cls.__name__} class."
diff --git a/astropy/table/pprint.py b/astropy/table/pprint.py
index a910753a2e..5648571776 100644
--- a/astropy/table/pprint.py
+++ b/astropy/table/pprint.py
@@ -449,7 +449,10 @@ class TableFormatter:
             i_centers.append(n_header)
             n_header += 1
             if dtype is not None:
-                col_dtype = dtype_info_name((dtype, multidims))
+                # For zero-length strings, np.dtype((dtype, ())) does not work;
+                # see https://github.com/numpy/numpy/issues/27301
+                # As a work-around, just omit the shape if there is none.
+                col_dtype = dtype_info_name((dtype, multidims) if multidims else dtype)
             else:
                 col_dtype = col.__class__.__qualname__ or "object"
             yield col_dtype
diff --git a/docs/changes/io.fits/16898.bugfix.rst b/docs/changes/io.fits/16898.bugfix.rst
new file mode 100644
index 0000000000..40da95978d
--- /dev/null
+++ b/docs/changes/io.fits/16898.bugfix.rst
@@ -0,0 +1,3 @@
+Ensure that ``QTable``, like ``Table``, can read zero-length string columns,
+and not convert them to length 1 strings. In the process, avoid a needless
+copy of all the data for ``QTable``.
diff --git a/docs/changes/table/16898.bugfix.rst b/docs/changes/table/16898.bugfix.rst
new file mode 100644
index 0000000000..57eb6564a5
--- /dev/null
+++ b/docs/changes/table/16898.bugfix.rst
@@ -0,0 +1,2 @@
+Pretty-printing of Tables now also works in the presence of zero-length string
+columns (which sometimes are present in FITS tables).


Test patch:
diff --git a/astropy/io/fits/tests/test_connect.py b/astropy/io/fits/tests/test_connect.py
index 6117d223e6..641e51bb83 100644
--- a/astropy/io/fits/tests/test_connect.py
+++ b/astropy/io/fits/tests/test_connect.py
@@ -1116,3 +1116,14 @@ def test_unsigned_int_dtype_propagation_for_zero_length_table():
     hdu = BinTableHDU(tbl)
     tbl2 = Table.read(hdu)
     assert tbl.dtype == tbl2.dtype
+
+
+@pytest.mark.parametrize("table_type", [Table, QTable])
+def test_zero_length_string_columns_can_be_read_into_table(table_type, tmp_path):
+    filename = tmp_path / "zerodtable.fits"
+    data = np.array([("", 12)], dtype=[("a", "S"), ("b", "i4")])
+    hdu = fits.BinTableHDU(data)
+    hdu.writeto(filename)
+    t = table_type.read(filename)
+    assert t["a"].dtype.itemsize == 0
+    assert t["a"].dtype == data["a"].dtype
diff --git a/astropy/table/tests/test_column.py b/astropy/table/tests/test_column.py
index aa76bd114b..654c716d1f 100644
--- a/astropy/table/tests/test_column.py
+++ b/astropy/table/tests/test_column.py
@@ -1145,3 +1145,23 @@ def test_masked_unit_conversion():
     c = table.MaskedColumn([3.5, 2.4, 1.7], name="test", unit=u.km)
     c.convert_unit_to(u.m)
     assert c.unit == (c * 2.0).unit
+
+
+@pytest.mark.parametrize(
+    "copy",
+    [
+        False,
+        pytest.param(
+            True,
+            marks=pytest.mark.xfail(
+                reason="See https://github.com/numpy/numpy/issues/27301"
+            ),
+        ),
+    ],
+)
+def test_zero_length_strings(Column, copy):
+    # Easiest way to get a zero-sized byte string is with a structured dtype.
+    data = np.array([("", 12)], dtype=[("a", "S"), ("b", "i4")])
+    col = Column(data["a"], name="a", copy=copy)
+    assert col.dtype.itemsize == 0
+    assert col.dtype == data.dtype["a"]
diff --git a/astropy/table/tests/test_pprint.py b/astropy/table/tests/test_pprint.py
index 31f584ae34..aa40b6baf6 100644
--- a/astropy/table/tests/test_pprint.py
+++ b/astropy/table/tests/test_pprint.py
@@ -1086,3 +1086,15 @@ def test_multidims_with_zero_dim():
         "   b             ",
     ]
     assert t.pformat_all(show_dtype=True) == exp
+
+
+def test_zero_length_string():
+    data = np.array([("", 12)], dtype=[("a", "S"), ("b", "i4")])
+    t = Table(data, copy=False)
+    exp = [
+        "  a      b  ",
+        "bytes0 int32",
+        "------ -----",
+        "          12",
+    ]
+    assert t.pformat_all(show_dtype=True) == exp