Repository: astropy/astropy

Issue:
# Error when pickling masked QTable column

### Description

Pickling a `QTable` with a masked quantity column results in an error because the class `astropy.utils.data_info.MaskedQuantityInfo` cannot be found. I looked at #16352, which addresses pickling `MaskedQuantity` arrays on their own, and it seems to be resolved in v7.1.0 by #17685. However, the info on `QTable` masked columns seems to be causing additional problems.

For context, I am trying to construct a `functools.partial` with a `QTable` as one of the arguments, to map using a multiprocessing pool. The `QTable` comes from a very large eCSV file, so I'd prefer not to re-read it in each function call. If this is an XY problem I'd be happy to solve it another way!

### Expected behavior

I would expect that a `QTable` can be pickled even if it has masked columns.

### How to Reproduce

This code snippet should reproduce the error.
```python
import pickle

import astropy.units as u
from astropy.table import QTable
from astropy.utils.masked import Masked
import numpy as np

a = np.arange(3) * u.m
m = np.array([True, False, False])
a_masked = Masked(a, mask=m)

t = QTable([a_masked], names=["a"])
print(t)
print(pickle.loads(pickle.dumps(t)))
```
```
Traceback (most recent call last):
  File ".../test.py", line 29, in <module>
    print(pickle.loads(pickle.dumps(t)))
                      ^^^^^^^^^^^^^^^
_pickle.PicklingError: Can't pickle <class 'astropy.utils.data_info.MaskedQuantityInfo'>: attribute lookup MaskedQuantityInfo on astropy.utils.data_info failed
```

### Versions

```python
import astropy
astropy.system_info()
```
```
platform
--------
platform.platform() = 'macOS-15.5-x86_64-i386-64bit'
platform.version() = 'Darwin Kernel Version 24.5.0: Tue Apr 22 19:53:26 PDT 2025; root:xnu-11417.121.6~2/RELEASE_X86_64'
platform.python_version() = '3.12.7'

packages
--------
astropy              7.1.0
numpy                2.2.6
scipy                --
matplotlib           --
pandas               --
pyerfa               2.0.1.5
```


Implementation patch:
diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py
index 068f75248a77..5564e74af1b0 100644
--- a/astropy/utils/masked/core.py
+++ b/astropy/utils/masked/core.py
@@ -617,10 +617,13 @@ def __new__(newcls, *args, mask=None, **kwargs):
         if "info" not in cls.__dict__ and hasattr(cls._data_cls, "info"):
             data_info = cls._data_cls.info
             attr_names = data_info.attr_names | {"serialize_method"}
+            # Ensure the new info class uses the class's module.
+            # Without this, the DataInfoMeta metaclass incorrectly sets
+            # __module__ to its own.
             new_info = type(
                 cls.__name__ + "Info",
                 (MaskedArraySubclassInfo, data_info.__class__),
-                dict(attr_names=attr_names),
+                dict(attr_names=attr_names, __module__=cls.__module__),
             )
             cls.info = new_info()
 
@@ -1411,13 +1414,14 @@ def __repr__(self):
 
 
 def __getattr__(key):
-    """Make commonly used Masked subclasses importable for ASDF support.
+    """Make commonly used Masked subclasses and their info classes importable for ASDF support.
 
     Registered types associated with ASDF converters must be importable by
     their fully qualified name. Masked classes are dynamically created and have
     apparent names like ``astropy.utils.masked.core.MaskedQuantity`` although
     they aren't actually attributes of this module. Customize module attribute
-    lookup so that certain commonly used Masked classes are importable.
+    lookup so that certain commonly used Masked classes are importable. The same
+    is done for their dynamically created info classes.
 
     See:
     - https://asdf.readthedocs.io/en/latest/asdf/extending/converters.html#entry-point-performance-considerations
@@ -1431,13 +1435,14 @@ def __getattr__(key):
         base_class_name = key[len(Masked.__name__) :]
         for base_class_qualname in __construct_mixin_classes:
             module, _, name = base_class_qualname.rpartition(".")
-            if name == base_class_name:
+            is_info = name + "Info" == base_class_name
+            if name == base_class_name or is_info:
                 base_class = getattr(importlib.import_module(module), name)
                 # Try creating the masked class
                 masked_class = Masked(base_class)
                 # But only return it if it is a standard one, not one
                 # where we just used the ndarray fallback.
                 if base_class in Masked._masked_classes:
-                    return masked_class
+                    return masked_class.info.__class__ if is_info else masked_class
 
     raise AttributeError(f"module '{__name__}' has no attribute '{key}'")


Test patch:
diff --git a/astropy/table/tests/test_pickle.py b/astropy/table/tests/test_pickle.py
index 7335be6ae6d3..3da780da8faa 100644
--- a/astropy/table/tests/test_pickle.py
+++ b/astropy/table/tests/test_pickle.py
@@ -2,7 +2,7 @@
 
 import numpy as np
 
-from astropy.coordinates import SkyCoord
+from astropy.coordinates import Angle, SkyCoord
 from astropy.table import Column, MaskedColumn, QTable, Table
 from astropy.table.table_helpers import simple_table
 from astropy.time import Time
@@ -141,6 +141,29 @@ def test_pickle_masked_table(protocol):
     assert tp.meta == t.meta
 
 
+def test_pickle_masked_qtable(protocol):
+    t = QTable(meta={"a": 1}, masked=True)
+    t["a"] = Quantity([1, 2], unit="m")
+    t["b"] = Angle([1, 2], unit=deg)
+
+    t["a"].mask[0] = True
+    t["b"].mask[1] = True
+
+    ts = pickle.dumps(t, protocol=protocol)
+    tp = pickle.loads(ts)
+
+    assert tp.__class__ is QTable
+
+    assert np.all(tp["a"].mask == t["a"].mask)
+    assert np.all(tp["a"].unmasked == t["a"].unmasked)
+    assert np.all(tp["b"].mask == t["b"].mask)
+    assert np.all(tp["b"].unmasked == t["b"].unmasked)
+    assert type(tp["a"]) is type(t["a"])  # nopep8
+    assert type(tp["b"]) is type(t["b"])  # nopep8
+    assert tp.meta == t.meta
+    assert type(tp) is type(t)
+
+
 def test_pickle_indexed_table(protocol):
     """
     Ensure that any indices that have been added will survive pickling.
diff --git a/astropy/utils/masked/tests/test_dynamic_subclasses.py b/astropy/utils/masked/tests/test_dynamic_subclasses.py
index f05a632ec9c3..972b09ac9177 100644
--- a/astropy/utils/masked/tests/test_dynamic_subclasses.py
+++ b/astropy/utils/masked/tests/test_dynamic_subclasses.py
@@ -1,5 +1,5 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
-"""Test importability of common Masked classes."""
+"""Test importability of common Masked classes and their info classes."""
 
 import pytest
 
@@ -11,3 +11,11 @@
 @pytest.mark.parametrize("base_class", [Quantity, Angle, Latitude, Longitude])
 def test_importable(base_class):
     assert getattr(core, f"Masked{base_class.__name__}") is core.Masked(base_class)
+
+
+@pytest.mark.parametrize("base_class", [Quantity, Angle, Latitude, Longitude])
+def test_importable_info(base_class):
+    assert (
+        getattr(core, f"Masked{base_class.__name__}Info")
+        is core.Masked(base_class).info.__class__
+    )
diff --git a/astropy/utils/masked/tests/test_pickle.py b/astropy/utils/masked/tests/test_pickle.py
new file mode 100644
index 000000000000..1429a91fbd43
--- /dev/null
+++ b/astropy/utils/masked/tests/test_pickle.py
@@ -0,0 +1,37 @@
+# Licensed under a 3-clause BSD style license - see LICENSE.rst
+
+import pickle
+
+import numpy as np
+import pytest
+
+import astropy.units as u
+from astropy.coordinates import Angle, Latitude, Longitude
+from astropy.units import Quantity
+from astropy.utils.masked import Masked
+
+
+@pytest.mark.parametrize(
+    "data",
+    [
+        Quantity([1, 2, 3], u.m),
+        Angle([1, 2, 3], u.deg),
+        Latitude([1, 2, 3], u.deg),
+        Longitude([1, 2, 3], u.deg),
+    ],
+)
+def test_masked_pickle(data):
+    mask = [True, False, False]
+    m = Masked(data, mask=mask)
+
+    # Force creation of the info object (see #19142)
+    assert m.info is not None
+
+    m2 = pickle.loads(pickle.dumps(m))
+
+    np.testing.assert_array_equal(m.unmasked, m2.unmasked)
+    np.testing.assert_array_equal(m.mask, m2.mask)
+
+    assert m.unit == m2.unit
+    assert type(m) is type(m2)
+    assert type(m.info) is type(m2.info)