# BUG: Fix broadcasting bug in uniform() for ndim >= 2 inputs

<!-- These comments are hidden when you submit the pull request,
so you do not need to remove them! -->

<!-- Please be sure to check out our contributing guidelines,
https://github.com/astropy/astropy/blob/main/CONTRIBUTING.md .
Please be sure to check out our code of conduct,
https://github.com/astropy/astropy/blob/main/CODE_OF_CONDUCT.md . -->

<!-- If you are new or need to be re-acquainted with Astropy
contributing workflow, please see
https://docs.astropy.org/en/latest/development/quickstart.html .
There is even a practical example at
https://docs.astropy.org/en/latest/development/git_edit_workflow_examples.html . -->

<!-- Please just have a quick search on GitHub to see if a similar
pull request has already been posted.
We have old closed pull requests that might provide useful code or ideas
that directly tie in with your pull request. -->

<!-- We have several automatic features that run when a pull request is open.
They can appear daunting but do not worry because maintainers will help
you navigate them, if necessary. -->

### Description
<!-- Provide a general description of what your pull request does.
Complete the following sentence and add relevant details as you see fit. -->

<!-- In addition please ensure that the pull request title is descriptive
and allows maintainers to infer the applicable subpackage(s). -->

<!-- READ THIS FOR MANUAL BACKPORT FROM A MAINTAINER:
Apply "skip-basebranch-check" label **before** you open the PR! -->

This PR fixes a broadcasting bug in uniform() for inputs with ndim >= 2.

The sampling axis was incorrectly inserted using [:, np.newaxis], which fails for multi-dimensional arrays. It is now appended using [..., np.newaxis], consistent with normal() and poisson(). A regression test is included

<!-- If the pull request closes any open issues you can add this.
If you replace <Issue Number> with a number, GitHub will automatically link it.
If this pull request is unrelated to any issues, please remove
the following line. -->

<!-- Optional opt-out -->

- [ ] By checking this box, the PR author has requested that maintainers do **NOT** use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

diff --git a/astropy/uncertainty/tests/test_distribution.py b/astropy/uncertainty/tests/test_distribution.py
index 9fe47457e569..8d2b1de4f0d3 100644
--- a/astropy/uncertainty/tests/test_distribution.py
+++ b/astropy/uncertainty/tests/test_distribution.py
@@ -688,3 +688,29 @@ def setup_class(cls):
         cls.d = cls.d * u.Unit("km,m")
         cls.item = cls.item * u.Unit("Mm,km")
         cls.b_item = cls.b_item * u.km
+
+
+def test_helper_uniform_multidim_regression():
+    """
+    Regression test: uniform() must support ndim >= 2 inputs.
+
+    Before fix (using [:, np.newaxis]) this raised a broadcasting ValueError.
+    After fix (using [..., np.newaxis]) it should work and preserve trailing
+    sample axis.
+    """
+    lower = np.zeros((3, 4))
+    upper = np.ones((3, 4))
+    n_samples = 7
+
+    d = ds.uniform(lower=lower, upper=upper, n_samples=n_samples)
+
+    # Public shape hides sample axis
+    assert d.shape == (3, 4)
+    assert d.n_samples == n_samples
+
+    # Underlying samples must include trailing sampling axis
+    assert d.distribution.shape == (3, 4, n_samples)
+
+    # Bounds must hold for all samples
+    assert np.all(d.distribution >= lower[..., np.newaxis])
+    assert np.all(d.distribution <= upper[..., np.newaxis])

diff --git a/astropy/uncertainty/distributions.py b/astropy/uncertainty/distributions.py
index 91f617e78c6c..d4687b95bf91 100644
--- a/astropy/uncertainty/distributions.py
+++ b/astropy/uncertainty/distributions.py
@@ -199,11 +199,8 @@ def uniform(
         )

     newshape = lower.shape + (n_samples,)
-    if lower.shape == () and upper.shape == ():
-        width = upper - lower  # scalar
-    else:
-        width = (upper - lower)[:, np.newaxis]
-        lower = lower[:, np.newaxis]
+    width = (upper - lower)[..., np.newaxis]
+    lower = lower[..., np.newaxis]
     samples = lower + width * np.random.uniform(size=newshape)

     return cls(samples, **kwargs)


# 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
```

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)

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}'")


# Switch SAMP to safe XML-RPC parsing for better robustness against external entities

SAMP currently relies on the standard `xmlrpc` library for Hub and Proxy communication. While checking the implementation, I noticed that `xmlrpc.client.ServerProxy` is susceptible to XML External Entity (XXE) processing by default. This behavior could allow a malicious Hub to potentially access local files or trigger SSRF when processing responses from untrusted sources.

Switching these instances to `defusedxml.xmlrpc` would resolve the underlying logic issue while maintaining protocol compatibility. I'm checking to see if adding `defusedxml` as a dependency is acceptable for the core package, as it's the established standard for handling XML-RPC data safely in Python. Precision in parsing is key for a project of this scale.

diff --git a/astropy/samp/tests/web_profile_test_helpers.py b/astropy/samp/tests/web_profile_test_helpers.py
index f9f98ef4bca3..0126debfd6f6 100644
--- a/astropy/samp/tests/web_profile_test_helpers.py
+++ b/astropy/samp/tests/web_profile_test_helpers.py
@@ -7,7 +7,7 @@
 from astropy.samp.hub import WebProfileDialog
 from astropy.samp.hub_proxy import SAMPHubProxy
 from astropy.samp.integrated_client import SAMPIntegratedClient
-from astropy.samp.utils import ServerProxyPool
+from astropy.samp.utils import SAMPXXEServerProxy, ServerProxyPool

 class AlwaysApproveWebProfileDialog(WebProfileDialog):
@@ -51,7 +51,7 @@ def connect(self, pool_size=20, web_port=21012):
         try:
             self.proxy = ServerProxyPool(
                 pool_size,
-                xmlrpc.ServerProxy,
+                SAMPXXEServerProxy,
                 f"http://127.0.0.1:{web_port}",
                 allow_none=1,
             )

diff --git a/astropy/samp/hub.py b/astropy/samp/hub.py
index aecfa619b1ef..6710643e1118 100644
--- a/astropy/samp/hub.py
+++ b/astropy/samp/hub.py
@@ -19,7 +19,7 @@
 from .errors import SAMPHubError, SAMPProxyError, SAMPProxyTimeoutError, SAMPWarning
 from .lockfile_helpers import create_lock_file, read_lockfile
 from .standard_profile import ThreadingXMLRPCServer
-from .utils import ServerProxyPool, _HubAsClient, internet_on
+from .utils import SAMPXXEServerProxy, ServerProxyPool, _HubAsClient, internet_on
 from .web_profile import WebProfileXMLRPCServer, web_profile_text_dialog

 __all__ = ["SAMPHubServer", "WebProfileDialog"]
@@ -744,7 +744,7 @@ def _set_xmlrpc_callback(self, private_key, xmlrpc_addr):
             server_proxy_pool = None

             server_proxy_pool = ServerProxyPool(
-                self._pool_size, xmlrpc.ServerProxy, xmlrpc_addr, allow_none=1
+                self._pool_size, SAMPXXEServerProxy, xmlrpc_addr, allow_none=1
             )

             public_id = self._private_keys[private_key][0]
diff --git a/astropy/samp/hub_proxy.py b/astropy/samp/hub_proxy.py
index 2f56621b8627..2f52034a333f 100644
--- a/astropy/samp/hub_proxy.py
+++ b/astropy/samp/hub_proxy.py
@@ -6,7 +6,7 @@

 from .errors import SAMPHubError
 from .lockfile_helpers import get_main_running_hub
-from .utils import ServerProxyPool
+from .utils import SAMPXXEServerProxy, ServerProxyPool

 __all__ = ["SAMPHubProxy"]

@@ -65,7 +65,7 @@ def connect(self, hub=None, hub_params=None, pool_size=20):
             url = hub_params["samp.hub.xmlrpc.url"].replace("\\", "")

             self.proxy = ServerProxyPool(
-                pool_size, xmlrpc.ServerProxy, url, allow_none=1
+                pool_size, SAMPXXEServerProxy, url, allow_none=1
             )

             self.ping()
diff --git a/astropy/samp/lockfile_helpers.py b/astropy/samp/lockfile_helpers.py
index 97adb181303a..f2afec3c81c1 100644
--- a/astropy/samp/lockfile_helpers.py
+++ b/astropy/samp/lockfile_helpers.py
@@ -17,6 +17,7 @@
 from astropy.utils.data import get_readable_fileobj

 from .errors import SAMPHubError, SAMPWarning
+from .utils import SAMPXXEServerProxy

 def read_lockfile(lockfilename):
@@ -210,7 +211,7 @@ def check_running_hub(lockfilename):

     if "samp.hub.xmlrpc.url" in lockfiledict:
         try:
-            proxy = xmlrpc.ServerProxy(
+            proxy = SAMPXXEServerProxy(
                 lockfiledict["samp.hub.xmlrpc.url"].replace("\\", ""), allow_none=1
             )
             proxy.samp.hub.ping()
diff --git a/astropy/samp/standard_profile.py b/astropy/samp/standard_profile.py
index 31ee743b9d72..ac97c37e341a 100644
--- a/astropy/samp/standard_profile.py
+++ b/astropy/samp/standard_profile.py
@@ -10,6 +10,7 @@

 from .constants import SAMP_ICON
 from .errors import SAMPWarning
+from .utils import safe_xmlrpc_loads

 __all__ = []

@@ -53,7 +54,7 @@ def do_POST(self):
                 size_remaining -= len(L[-1])
             data = b"".join(L)

-            params, method = xmlrpc.loads(data)
+            params, method = safe_xmlrpc_loads(data)

             if method == "samp.webhub.register":
                 params = list(params)
diff --git a/astropy/samp/utils.py b/astropy/samp/utils.py
index f3d338f0596c..434bb82cd714 100644
--- a/astropy/samp/utils.py
+++ b/astropy/samp/utils.py
@@ -28,9 +28,58 @@ def internet_on():
             return True

-__all__ = ["SAMPMsgReplierWrapper"]
+__all__ = ["SAMPMsgReplierWrapper", "SAMPXXEServerProxy", "safe_xmlrpc_loads"]

-__doctest_skip__ = ["."]
+
+def get_safe_parser(use_datetime=False, use_builtin_types=False):
+    """
+    Return a safe XML parser and its associated unmarshaller.
+    """
+    unmarshaller = xmlrpc.Unmarshaller(use_datetime, use_builtin_types)
+    parser = xmlrpc.ExpatParser(unmarshaller)
+    if hasattr(parser, "_parser"):
+        # Explicitly disable external entities to prevent XXE.
+        # While None is often the default, being explicit ensures security
+        # across different environments and expat builds.
+        parser._parser.ExternalEntityRefHandler = None
+    return parser, unmarshaller
+
+
+def safe_xmlrpc_loads(data, use_datetime=False, use_builtin_types=False):
+    """
+    A secure replacement for `xmlrpc.client.loads` that prevents XXE.
+    """
+    parser, unmarshaller = get_safe_parser(use_datetime, use_builtin_types)
+    parser.feed(data)
+    parser.close()
+    return unmarshaller.close(), unmarshaller.getmethodname()
+
+
+class SAMPXXEServerProxy(xmlrpc.ServerProxy):
+    """
+    An XML-RPC server proxy that uses a safe transport to prevent XXE.
+    """
+
+    def __init__(self, uri, *args, **kwargs):
+        if "transport" not in kwargs:
+            from urllib.parse import urlparse
+
+            parsed_uri = urlparse(uri)
+            base_class = (
+                xmlrpc.SafeTransport
+                if parsed_uri.scheme == "https"
+                else xmlrpc.Transport
+            )
+
+            class XXESafeTransport(base_class):
+                def getparser(self):
+                    return get_safe_parser(self._use_datetime, self._use_builtin_types)
+
+            kwargs["transport"] = XXESafeTransport(
+                use_datetime=kwargs.get("use_datetime", False),
+                use_builtin_types=kwargs.get("use_builtin_types", False),
+            )
+        super().__init__(uri, *args, **kwargs)

 def getattr_recursive(variable, attribute):


# Tables with custom dtype cannot be stacked

### Description

As found in https://github.com/nanograv/PINT/issues/1944#issuecomment-3774332270, where an experiment was run using a column with a quadruple precision dtype (https://github.com/numpy/numpy-quaddtype): tables can hold numpy arrays with that dtype, but then they cannot be stacked, because internally tables use a string representation of the dtype instead of the actual one (and that string representation is not recognized by `np.dtype`).

~I'll investigate a bit more once I'm at a computer where I can install quaddtype myself...~ For details, see below.

### Expected behavior

No reason user dtypes should not work

### How to Reproduce
```
python3 -m venv temp
source temp/bin/activate
pip install git+https://github.com/numpy/numpy-quaddtype.git#egg=numpy-quaddtype
pip install astropy ipython
```
And then in `ipython`:
```python
from astropy.table import Table, Column, vstack
from numpy_quaddtype import QuadPrecDType
c = Column([1, 2], dtype=QuadPrecDType()) + 1e-25
print(c-[1,2])
#                              None
# -------------------------------------------------------------
# 0.00000000000000000000000010000000002821819343901975675512978
# 0.00000000000000000000000010000000002821819343901975675512978
t = Table([c], names=["c"])
vstack([t, t])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 vstack([t, t])

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/operations.py:715, in vstack(tables, join_type, metadata_conflicts)
    712 if len(tables) == 1:
    713     return tables[0]  # no point in stacking a single table
--> 715 out = _vstack(tables, join_type, metadata_conflicts)
    717 # Merge table metadata
    718 _merge_table_meta(out, tables, metadata_conflicts=metadata_conflicts)

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/operations.py:1492, in _vstack(arrays, join_type, metadata_conflicts)
   1488     raise NotImplementedError(
   1489         f"vstack unavailable for mixin column type(s): {col_cls.__name__}"
   1490     )
   1491 try:
-> 1492     col = col_cls.info.new_like(cols, n_rows, metadata_conflicts, out_name)
   1493 except metadata.MergeConflictError as err:
   1494     # Beautify the error message when we are trying to merge columns with incompatible
   1495     # types by including the name of the columns that originated the error.
   1496     raise TableMergeError(
   1497         f"The '{out_name}' columns have incompatible types: "
   1498         f"{err._incompat_types}"
   1499     ) from err

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:483, in ColumnInfo.new_like(self, cols, length, metadata_conflicts, name)
    455 """
    456 Return a new Column instance which is consistent with the
    457 input ``cols`` and has ``length`` rows.
   (...)    477
    478 """
    479 attrs = self.merge_cols_attributes(
    480     cols, metadata_conflicts, name, ("meta", "unit", "format", "description")
    481 )
--> 483 return self._parent_cls(length=length, **attrs)

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:1251, in Column.__new__(cls, data, name, dtype, shape, length, description, unit, format, meta, copy, copy_indices)
   1246 if isinstance(data, MaskedColumn) and np.any(data.mask):
   1247     raise TypeError(
   1248         "Cannot convert a MaskedColumn with masked value to a Column"
   1249     )
-> 1251 self = super().__new__(
   1252     cls,
   1253     data=data,
   1254     name=name,
   1255     dtype=dtype,
   1256     shape=shape,
   1257     length=length,
   1258     description=description,
   1259     unit=unit,
   1260     format=format,
   1261     meta=meta,
   1262     copy=copy,
   1263     copy_indices=copy_indices,
   1264 )
   1265 return self

File ~/data/mhvk/packages/numpy-quaddtype/temp/lib/python3.13/site-packages/astropy/table/column.py:517, in BaseColumn.__new__(cls, data, name, dtype, shape, length, description, unit, format, meta, copy, copy_indices)
    502 def __new__(
    503     cls,
    504     data=None,
   (...)    514     copy_indices=True,
    515 ):
    516     if data is None:
--> 517         self_data = np.zeros((length,) + shape, dtype=dtype)
    518     elif isinstance(data, BaseColumn) and hasattr(data, "_name"):
    519         # When unpickling a MaskedColumn, ``data`` will be a bare
    520         # BaseColumn with none of the expected attributes.  In this case
    521         # do NOT execute this block which initializes from ``data``
    522         # attributes.
    523         self_data = np.array(data.data, dtype=dtype, copy=copy)

TypeError: data type "QuadPrecDType(backend='sleef')" not understood
```

### Versions

```python
import astropy
astropy.system_info()

platform
--------
platform.platform() = 'Linux-6.17.13+deb14-amd64-x86_64-with-glibc2.42'
platform.version() = '#1 SMP PREEMPT_DYNAMIC Debian 6.17.13-1 (2025-12-20)'
platform.python_version() = '3.13.11'

packages
--------
astropy              7.2.0
numpy                2.4.1
scipy                --
matplotlib           --
pandas               --
pyerfa               2.0.1.5

```

diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index 63d1b09d8c10..bd0541b6e392 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -6,6 +6,7 @@

 import numpy as np
 import pytest
+from numpy.testing import assert_array_equal

 from astropy import table
 from astropy import units as u
@@ -32,7 +33,7 @@
 from astropy.timeseries import TimeSeries
 from astropy.units.quantity import Quantity
 from astropy.utils import metadata
-from astropy.utils.compat.optional_deps import HAS_SCIPY
+from astropy.utils.compat.optional_deps import HAS_NUMPY_QUADDTYPE, HAS_SCIPY
 from astropy.utils.masked import Masked
 from astropy.utils.metadata import MergeConflictError

@@ -2600,3 +2601,16 @@ def test_empty_skycoord_vstack():
     table2 = table.vstack([table1, table1])  # Used to fail.
     assert len(table2) == 0
     assert isinstance(table2["foo"], SkyCoord)
+
+
+@pytest.mark.skipif(not HAS_NUMPY_QUADDTYPE, reason="Tests QuadDtype")
+def test_user_dtype_vstack():
+    # Regression test for gh-19197
+    from numpy_quaddtype import QuadPrecDType
+
+    c = Column([1, 2], dtype=QuadPrecDType()) + 1e-25
+    t = Table([c], names=["c"])
+    t2 = table.vstack([t, t])  # This used to fail
+    assert t2["c"].dtype == c.dtype
+    assert_array_equal(t2[:2]["c"], c)
+    assert_array_equal(t2[2:]["c"], c)
diff --git a/astropy/utils/metadata/tests/test_metadata.py b/astropy/utils/metadata/tests/test_metadata.py
index ea755ddf51ad..436c2f602586 100644
--- a/astropy/utils/metadata/tests/test_metadata.py
+++ b/astropy/utils/metadata/tests/test_metadata.py
@@ -13,6 +13,7 @@
     enable_merge_strategies,
     merge,
 )
+from astropy.utils.metadata.utils import result_type

 class OrderedDictSubclass(OrderedDict):
@@ -244,29 +245,30 @@ class MergeConcatStrings(metadata.MergePlus):
     metadata.MERGE_STRATEGIES = original_merge_strategies

-def test_common_dtype_string():
+def test_result_type_string():
     u3 = np.array(["123"])
     u4 = np.array(["1234"])
     b3 = np.array([b"123"])
     b5 = np.array([b"12345"])
-    assert common_dtype([u3, u4]).endswith("U4")
-    assert common_dtype([b5, u4]).endswith("U5")
-    assert common_dtype([b3, b5]).endswith("S5")
+    assert result_type([u3, u4]) == np.dtype("U4")
+    assert result_type([b5, u4]) == np.dtype("U5")
+    assert result_type([b3, b5]) == np.dtype("S5")

-def test_common_dtype_basic():
+def test_result_type_basic():
     i8 = np.array(1, dtype=np.int64)
     f8 = np.array(1, dtype=np.float64)
     u3 = np.array("123")

     with pytest.raises(MergeConflictError):
-        common_dtype([i8, u3])
+        result_type([i8, u3])

-    assert common_dtype([i8, i8]).endswith("i8")
-    assert common_dtype([i8, f8]).endswith("f8")
+    assert result_type([i8, i8]) == np.dtype("i8")
+    assert result_type([i8, f8]) == np.dtype("f8")

-def test_common_dtype_exhaustive():
+@pytest.mark.parametrize("function", [result_type, common_dtype])
+def test_finding_common_type_exhaustive(function):
     """
     Test that allowed combinations are those expected.
     """
@@ -286,7 +288,7 @@ def test_common_dtype_exhaustive():
     for name1, _ in dtype:
         for name2, _ in dtype:
             try:
-                common_dtype([arr[name1], arr[name2]])
+                function([arr[name1], arr[name2]])
                 succeed.add(f"{name1} {name2}")
             except MergeConflictError:
                 fail.add(f"{name1} {name2}")

diff --git a/astropy/table/operations.py b/astropy/table/operations.py
index 81e1c6262ed5..f1b71da10fb6 100644
--- a/astropy/table/operations.py
+++ b/astropy/table/operations.py
@@ -1011,7 +1011,7 @@ def get_descrs(arrays, col_name_map):

         # Output dtype is the superset of all dtypes in in_arrays
         try:
-            dtype = common_dtype(in_cols)
+            dtype = result_type(in_cols)
         except TableMergeError as tme:
             # Beautify the error message when we are trying to merge columns with incompatible
             # types by including the name of the columns that originated the error.
@@ -1033,7 +1033,7 @@ def get_descrs(arrays, col_name_map):
     return out_descrs

-def common_dtype(cols):
+def result_type(cols):
     """
     Use numpy to find the common dtype for a list of columns.

@@ -1041,7 +1041,7 @@ def common_dtype(cols):
     np.bool_, np.object_, np.number, np.character, np.void
     """
     try:
-        return metadata.common_dtype(cols)
+        return metadata.utils.result_type(cols)
     except metadata.MergeConflictError as err:
         tme = TableMergeError(f"Columns have incompatible types {err._incompat_types}")
         tme._incompat_types = err._incompat_types
@@ -1088,7 +1088,7 @@ def _get_join_sort_idxs(keys, left, right):
             sort_right[sort_key] = right_sort_col

             # Build up dtypes for the structured array that gets sorted.
-            dtype_str = common_dtype([left_sort_col, right_sort_col])
+            dtype_str = result_type([left_sort_col, right_sort_col])
             sort_keys_dtypes.append((sort_key, dtype_str))
             ii += 1

diff --git a/astropy/utils/compat/optional_deps.py b/astropy/utils/compat/optional_deps.py
index 808a9eba20c2..505944640d94 100644
--- a/astropy/utils/compat/optional_deps.py
+++ b/astropy/utils/compat/optional_deps.py
@@ -32,6 +32,7 @@
     "matplotlib",
     "mpmath",
     "narwhals",
+    "numpy_quaddtype",
     "pandas",
     "PIL",
     "polars",
diff --git a/astropy/utils/data_info.py b/astropy/utils/data_info.py
index 08f498fa27cf..b068dda20c17 100644
--- a/astropy/utils/data_info.py
+++ b/astropy/utils/data_info.py
@@ -738,7 +738,7 @@ def getattrs(col):
             )

         # Output dtype is the superset of all dtypes in in_cols
-        out["dtype"] = metadata.common_dtype(cols)
+        out["dtype"] = metadata.utils.result_type(cols)

         # Make sure all input shapes are the same
         uniq_shapes = {col.shape[1:] for col in cols}
diff --git a/astropy/utils/metadata/merge.py b/astropy/utils/metadata/merge.py
index c7ee740fb861..ec21cada0347 100644
--- a/astropy/utils/metadata/merge.py
+++ b/astropy/utils/metadata/merge.py
@@ -8,7 +8,7 @@
 import numpy as np

 from .exceptions import MergeConflictError, MergeConflictWarning
-from .utils import common_dtype
+from .utils import result_type

 __all__ = [
     "MERGE_STRATEGIES",
@@ -130,7 +130,7 @@ class MergeNpConcatenate(MergeStrategy):
     @classmethod
     def merge(cls, left, right):
         left, right = np.asanyarray(left), np.asanyarray(right)
-        common_dtype([left, right])  # Ensure left and right have compatible dtype
+        result_type([left, right])  # Ensure left and right have compatible dtype
         return np.concatenate([left, right])

diff --git a/astropy/utils/metadata/utils.py b/astropy/utils/metadata/utils.py
index 8f4f16b93732..19573d6462a6 100644
--- a/astropy/utils/metadata/utils.py
+++ b/astropy/utils/metadata/utils.py
@@ -3,8 +3,6 @@

 import numpy as np

-from astropy.utils.misc import dtype_bytes_or_chars
-
 from .exceptions import MergeConflictError

 __all__ = ["common_dtype"]
@@ -31,32 +29,41 @@ def common_dtype(arrs):
     dtype_str : str
         String representation of dytpe (dtype ``str`` attribute)
     """
+    dt = result_type(arrs)
+    return dt.str if dt.names is None else dt.descr
+
+
+def result_type(arrs):
+    """
+    Use numpy to find the common type for a list of ndarray.
+
+    The difference with `numpy.result_type` is that all arrays should
+    share the same fundamental numpy data type, one of:
+    ``np.bool_``, ``np.object_``, ``np.number``, ``np.character``, ``np.void``
+    Hence, a mix like integer and string will raise instead of resulting
+    in a string type.
+
+    Parameters
+    ----------
+    arrs : list of ndarray
+        Array likes for which to find the common dtype. Anything not
+        an array (e.g, |Time|), will be considered an object array.
+
+    Returns
+    -------
+    dtype : ~numpy.dtype
+        The common type.
+    """
+    dtypes = [dtype(arr) for arr in arrs]
     np_types = (np.bool_, np.object_, np.number, np.character, np.void)
     uniq_types = {
-        tuple(issubclass(dtype(arr).type, np_type) for np_type in np_types)
-        for arr in arrs
+        tuple(issubclass(dt.type, np_type) for np_type in np_types) for dt in dtypes
     }
     if len(uniq_types) > 1:
         # Embed into the exception the actual list of incompatible types.
-        incompat_types = [dtype(arr).name for arr in arrs]
+        incompat_types = [dt.name for dt in dtypes]
         tme = MergeConflictError(f"Arrays have incompatible types {incompat_types}")
         tme._incompat_types = incompat_types
         raise tme

-    arrs = [np.empty(1, dtype=dtype(arr)) for arr in arrs]
-
-    # For string-type arrays need to explicitly fill in non-zero
-    # values or the final arr_common = .. step is unpredictable.
-    for i, arr in enumerate(arrs):
-        if arr.dtype.kind in ("S", "U"):
-            arrs[i] = [
-                ("0" if arr.dtype.kind == "U" else b"0")
-                * dtype_bytes_or_chars(arr.dtype)
-            ]
-
-    arr_common = np.array([arr[0] for arr in arrs])
-    return (
-        arr_common.dtype.str
-        if arr_common.dtype.names is None
-        else arr_common.dtype.descr
-    )
+    return np.result_type(*dtypes)


# `to_pandas` should support multidimensional columns

### What is the problem this feature will solve?

When a column contains arrays we get this error basically saying that pandas does not support multidimensional columns:
```
In [2]: t = Table({"a": ["foo", "bar"], "b":[[1,2],[3,4]]})
   ...: t.to_pandas()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[2], line 2
      1 t = Table({"a": ["foo", "bar"], "b":[[1,2],[3,4]]})
----> 2 t.to_pandas()

File ~/.pyenv/versions/3.13.1/envs/astropy71/lib/python3.13/site-packages/astropy/table/table.py:4155, in Table.to_pandas(self, index, use_nullable_int)
   4153 badcols = [name for name, col in self.columns.items() if len(col.shape) > 1]
   4154 if badcols:
-> 4155     raise ValueError(
   4156         f"Cannot convert a table with multidimensional columns to a "
   4157         f"pandas DataFrame. Offending columns are: {badcols}\n"
   4158         f"One can filter out such columns using:\n"
   4159         f"names = [name for name in tbl.colnames if len(tbl[name].shape) <= 1]\n"
   4160         f"tbl[names].to_pandas(...)"
   4161     )
   4163 out = OrderedDict()
   4165 for name, column in tbl.columns.items():

ValueError: Cannot convert a table with multidimensional columns to a pandas DataFrame. Offending columns are: ['b']
One can filter out such columns using:
names = [name for name in tbl.colnames if len(tbl[name].shape) <= 1]
tbl[names].to_pandas(...)
```

However it is possible to use "object" columns for this in pandas, which is maybe not as powerful as a real array but still quite practical in some cases. And the funny thing is that astropy will happily convert to pandas if the column contains arrays of different shapes:
```
In [3]: t = Table({"a": ["foo", "bar"], "b":[[1,2],[3,4,5]]})
   ...: t.to_pandas()
Out[3]:
     a          b
0  foo     [1, 2]
1  bar  [3, 4, 5]
```
So it's a bit sad that it's not possible to get the same thing when arrays have the same shape (I tried if I could trick it but didn't find a way).

### Describe the desired outcome

`Table({"a": ["foo", "bar"], "b":[[1,2],[3,4]]}).to_pandas()` should work.

### Additional context

_No response_

diff --git a/astropy/table/tests/test_df.py b/astropy/table/tests/test_df.py
index f4d5c2ceb9f5..27479da1b893 100644
--- a/astropy/table/tests/test_df.py
+++ b/astropy/table/tests/test_df.py
@@ -446,8 +446,14 @@ def test_nd_columns(self, backend, use_legacy_pandas_api, ndim):
         t["a"] = np.arange(np.prod(colshape)).reshape(colshape)

         match backend:
-            # Pandas and PyArrow do not support multidimensional columns
-            case "pandas" | "pyarrow":
+            # Pandas support multidimensional columns
+            case "pandas":
+                if ndim > 1:
+                    df = self._to_dataframe(t, backend, use_legacy_pandas_api)
+                    assert hasattr(df["a"].iloc[0], "__len__")
+                    return
+            # PyArrow do not support multidimensional columns
+            case "pyarrow":
                 if ndim > 1:
                     with pytest.raises(
                         ValueError,
@@ -678,3 +684,49 @@ def test_from_pandas_df_with_qtable(method):
     df = t.to_pandas()
     qt = getattr(table.QTable, method)(df)
     assert isinstance(qt, table.QTable)
+
+
+@pytest.mark.skipif(not HAS_PANDAS, reason="require pandas")
+@pytest.mark.parametrize("use_legacy_pandas_api", [True, False])
+def test_pandas_conversion_multidim_columns(use_legacy_pandas_api):
+    """Test that Table with multidim columns converts successfully to pandas (#19173).
+
+    This test only uses pandas since other backends do not support multidim columns.
+    It includes a variety of column types and dimensions (1d to 3d), including
+    masked columns to verify that all are handled correctly.
+    """
+    if not use_legacy_pandas_api and not HAS_NARWHALS:
+        pytest.skip("requires narwhals for generic pandas conversion")
+
+    t = Table()
+    t["a"] = ["foo", "bar"]  # 1-d str
+    t["b"] = [1.5, 2.5]  # 1-d float
+    t["c"] = np.array([[1, 2], [3, 4]])  # 2-d int
+    t["d"] = np.array([[[1.5, 0], [2, 1]], [[3, 2], [4, 3]]])  # 3-d float
+    t["e"] = np.array([["a", "b"], ["c", "d"]])  # 2-d str
+    t["f"] = np.empty((2, 2), dtype=object)
+    t["f"][:] = [[None, {"a": 1}], ["str", [1, 2]]]  # 2-d object type
+    t["g"] = np.ma.MaskedArray([[1, 2], [3, 4]], mask=[[True, False], [False, True]])
+    t["h"] = np.ma.MaskedArray([[1.5, 2], [3, 4]], mask=[[True, False], [False, True]])
+    tc = t.copy()
+    df = t.to_pandas() if use_legacy_pandas_api else t.to_df("pandas")
+
+    # Ensure that conversion process leaves `t` unchanged
+    assert np.all(t == tc)
+
+    # Check properties of converted dataframe `df`:
+    # - dtypes are as expected (all object except float64 for column "b").
+    # - Dataframe values exactly match table column values via .tolist().
+    # - All dataframe Series items for multidim columns are type list.
+    for name in t.colnames:
+        assert df[name].dtype == "float64" if name == "b" else "object"
+        assert df[name].tolist() == t[name].tolist()
+        if t[name].ndim > 1:
+            for val in df[name]:
+                assert type(val) is list
+
+    # Special-case testing for masked column
+    assert df["g"][0] == [None, 2]
+    assert df["g"][1] == [3, None]
+    assert df["h"][0] == [None, 2.0]
+    assert df["h"][1] == [3.0, None]

diff --git a/astropy/table/_dataframes.py b/astropy/table/_dataframes.py
index 15eeb1b784cc..b09a6fe03c8e 100644
--- a/astropy/table/_dataframes.py
+++ b/astropy/table/_dataframes.py
@@ -169,6 +169,24 @@ def _handle_index_argument(
         )

+def _convert_multidim_columns_to_object(table: Table) -> Table:
+    """
+    Convert any multidimensional columns in the table to 1D object arrays.
+
+    If there are no multidimensional columns, the table is returned unchanged.
+    """
+    if not any(col.ndim > 1 for col in table.itercols()):
+        return table
+
+    out = table.copy(copy_data=False)
+    for name, col in table.columns.items():
+        if col.ndim > 1:
+            # Convert to 1D object array (e.g. list of lists for ndim=2).
+            out[name] = np.empty(len(col), dtype=object)
+            out[name][:] = col.tolist()
+    return out
+
+
 def to_df(
     table: Table,
     *,
@@ -195,6 +213,9 @@ def to_df(

     # Encode mixins and validate columns
     tbl = _encode_mixins(table)
+    # Support numpy multidimensional arrays in columns for pandas-like backend
+    if backend_impl.is_pandas_like():
+        tbl = _convert_multidim_columns_to_object(tbl)
     _validate_columns_for_backend(tbl, backend_impl=backend_impl)

     # Convert to narwhals DataFrame
@@ -384,6 +405,8 @@ def to_pandas(

     # Encode mixins and validate columns
     tbl = _encode_mixins(table)
+    # Support numpy multidimensional arrays in columns
+    tbl = _convert_multidim_columns_to_object(tbl)
     _validate_columns_for_backend(tbl, backend_impl=PANDAS_LIKE)  # pandas validation

     out = {}


# BUG: avoid deprecated shape mutation APIs in time

### Description
ref #19126

opening as a draft: what I have seem necessary but not sufficient. Will provide more details later

<!-- Optional opt-out -->

- [ ] By checking this box, the PR author has requested that maintainers do **NOT** use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

diff --git a/astropy/time/tests/test_methods.py b/astropy/time/tests/test_methods.py
index f295a4978b96..8039974b3578 100644
--- a/astropy/time/tests/test_methods.py
+++ b/astropy/time/tests/test_methods.py
@@ -13,6 +13,7 @@
 from astropy.time.utils import day_frac
 from astropy.units.quantity_helper.function_helpers import ARRAY_FUNCTION_ENABLED
 from astropy.utils import iers
+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.exceptions import AstropyDeprecationWarning

 needs_array_function = pytest.mark.xfail(
@@ -320,7 +321,7 @@ def test_shape_setting(self, use_mask):

         t0_reshape = self.t0.copy()
         mjd = t0_reshape.mjd  # Creates a cache of the mjd attribute
-        t0_reshape.shape = (5, 2, 5)
+        t0_reshape = np.reshape(t0_reshape, (5, 2, 5))
         assert t0_reshape.shape == (5, 2, 5)
         assert mjd.shape != t0_reshape.mjd.shape  # Cache got cleared
         assert np.all(t0_reshape.jd1 == self.t0._time.jd1.reshape(5, 2, 5))
@@ -328,16 +329,26 @@ def test_shape_setting(self, use_mask):
         assert t0_reshape.location is None
         # But if the shape doesn't work, one should get an error.
         t0_reshape_t = t0_reshape.T
-        with pytest.raises(ValueError):
-            t0_reshape_t.shape = (12,)  # Wrong number of elements.
-        with pytest.raises(AttributeError):
-            t0_reshape_t.shape = (10, 5)  # Cannot be done without copy.
+
+        if NUMPY_LT_2_5:
+            depr_warning_action = "error"
+        else:
+            depr_warning_action = "ignore"
+        with warnings.catch_warnings():
+            warnings.simplefilter(depr_warning_action, DeprecationWarning)
+            with pytest.raises(ValueError):
+                t0_reshape_t.shape = (12,)  # Wrong number of elements.
+            with pytest.raises((AttributeError, ValueError)):
+                # the exact exception type isn't really in our control,
+                # as it ultimately comes from numpy but depends on astropy's
+                # execution flow
+                t0_reshape_t.shape = (10, 5)  # Cannot be done without copy.
         # check no shape was changed.
         assert t0_reshape_t.shape == t0_reshape.T.shape
         assert t0_reshape_t.jd1.shape == t0_reshape.T.shape
         assert t0_reshape_t.jd2.shape == t0_reshape.T.shape
         t1_reshape = self.t1.copy()
-        t1_reshape.shape = (2, 5, 5)
+        t1_reshape = np.reshape(t1_reshape, (2, 5, 5))
         assert t1_reshape.shape == (2, 5, 5)
         assert np.all(t1_reshape.jd1 == self.t1.jd1.reshape(2, 5, 5))
         # location is a single element, so its shape should not change.

diff --git a/astropy/time/core.py b/astropy/time/core.py
index 52d476d222da..5037db2b59e4 100644
--- a/astropy/time/core.py
+++ b/astropy/time/core.py
@@ -27,7 +27,7 @@
 from astropy.extern import _strptime
 from astropy.units import UnitConversionError
 from astropy.utils import lazyproperty
-from astropy.utils.compat import COPY_IF_NEEDED
+from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_5
 from astropy.utils.data_info import MixinInfo, data_info_factory
 from astropy.utils.decorators import deprecated
 from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning
@@ -926,15 +926,22 @@ def shape(self, shape):
             (self, "location"),
         ):
             val = getattr(obj, attr, None)
-            if val is not None and val.size > 1:
-                try:
+            if val is None or val.size <= 1:
+                continue
+            try:
+                if NUMPY_LT_2_5:
                     val.shape = shape
-                except Exception:
-                    for val2 in reshaped:
-                        val2.shape = oldshape
-                    raise
                 else:
-                    reshaped.append(val)
+                    val._set_shape(shape)
+            except Exception:
+                for val2 in reshaped:
+                    if NUMPY_LT_2_5:
+                        val2.shape = oldshape
+                    else:
+                        val2._set_shape(oldshape)
+                raise
+            else:
+                reshaped.append(val)

     def _shaped_like_input(self, value):
         if self.masked:
diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py
index 4ad48e26fc8c..8d69d2fde72f 100644
--- a/astropy/utils/masked/core.py
+++ b/astropy/utils/masked/core.py
@@ -22,7 +22,7 @@

 import numpy as np

-from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0
+from astropy.utils.compat import COPY_IF_NEEDED, NUMPY_LT_2_0, NUMPY_LT_2_5
 from astropy.utils.data_info import ParentDtypeInfo
 from astropy.utils.shapes import NDArrayShapeMethods, ShapedLikeNDArray

@@ -744,14 +744,26 @@ def shape(self):

     @shape.setter
     def shape(self, shape):
+        return self._set_shape(shape)
+
+    def _set_shape(self, shape):
         old_shape = self.shape
-        self._mask.shape = shape
+        if NUMPY_LT_2_5:
+            self._mask.shape = shape
+        else:
+            self._mask._set_shape(shape)
         # Reshape array proper in try/except just in case some broadcasting
         # or so causes it to fail.
         try:
-            super(MaskedNDArray, type(self)).shape.__set__(self, shape)
+            if NUMPY_LT_2_5:
+                super(MaskedNDArray, type(self)).shape.__set__(self, shape)
+            else:
+                super()._set_shape(shape)
         except Exception as exc:
-            self._mask.shape = old_shape
+            if NUMPY_LT_2_5:
+                self._mask.shape = old_shape
+            else:
+                self._mask._set_shape(old_shape)
             # Given that the mask reshaping succeeded, the only logical
             # reason for an exception is something like a broadcast error in
             # in __array_finalize__, or a different memory ordering between


# BUG: incoming deprecation warnings from numpy 2.5 (dev): direct mutations to array shape attributes are deprecated

xref: https://github.com/numpy/numpy/pull/29536

this PR (just merged) breaks about 150 tests throughout astropy. Some deprecations are more easily handled than others, so I'll start with the easy ones to reduce the number of failures so we can streamline the discussion where it's really needed.

upstream reports/PRs:
- https://github.com/brandon-rhodes/python-jplephem/issues/63
- https://github.com/brandon-rhodes/python-jplephem/pull/64

linked PRs:
- #19127
- #19128
- #19129
- #19130
- #19131
- #19132
- #19152
- #19158
- #19182
- #19184
- #19187

diff --git a/astropy/time/tests/test_methods.py b/astropy/time/tests/test_methods.py
index 263f8be6718e..e410d44ade0f 100644
--- a/astropy/time/tests/test_methods.py
+++ b/astropy/time/tests/test_methods.py
@@ -11,6 +11,7 @@
 from astropy.time import Time
 from astropy.time.utils import day_frac
 from astropy.utils import iers
+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.exceptions import AstropyDeprecationWarning

@@ -314,7 +315,7 @@ def test_shape_setting(self, use_mask):

         t0_reshape = self.t0.copy()
         mjd = t0_reshape.mjd  # Creates a cache of the mjd attribute
-        t0_reshape.shape = (5, 2, 5)
+        t0_reshape = np.reshape(t0_reshape, (5, 2, 5))
         assert t0_reshape.shape == (5, 2, 5)
         assert mjd.shape != t0_reshape.mjd.shape  # Cache got cleared
         assert np.all(t0_reshape.jd1 == self.t0._time.jd1.reshape(5, 2, 5))
@@ -322,16 +323,26 @@ def test_shape_setting(self, use_mask):
         assert t0_reshape.location is None
         # But if the shape doesn't work, one should get an error.
         t0_reshape_t = t0_reshape.T
-        with pytest.raises(ValueError):
-            t0_reshape_t.shape = (12,)  # Wrong number of elements.
-        with pytest.raises(AttributeError):
-            t0_reshape_t.shape = (10, 5)  # Cannot be done without copy.
+
+        if NUMPY_LT_2_5:
+            depr_warning_action = "error"
+        else:
+            depr_warning_action = "ignore"
+        with warnings.catch_warnings():
+            warnings.simplefilter(depr_warning_action, DeprecationWarning)
+            with pytest.raises(ValueError):
+                t0_reshape_t.shape = (12,)  # Wrong number of elements.
+            with pytest.raises((AttributeError, ValueError)):
+                # the exact exception type isn't really in our control,
+                # as it ultimately comes from numpy but depends on astropy's
+                # execution flow
+                t0_reshape_t.shape = (10, 5)  # Cannot be done without copy.
         # check no shape was changed.
         assert t0_reshape_t.shape == t0_reshape.T.shape
         assert t0_reshape_t.jd1.shape == t0_reshape.T.shape
         assert t0_reshape_t.jd2.shape == t0_reshape.T.shape
         t1_reshape = self.t1.copy()
-        t1_reshape.shape = (2, 5, 5)
+        t1_reshape = np.reshape(t1_reshape, (2, 5, 5))
         assert t1_reshape.shape == (2, 5, 5)
         assert np.all(t1_reshape.jd1 == self.t1.jd1.reshape(2, 5, 5))
         # location is a single element, so its shape should not change.

diff --git a/astropy/time/core.py b/astropy/time/core.py
index 52cd4d76d507..1e0b13c42a1f 100644
--- a/astropy/time/core.py
+++ b/astropy/time/core.py
@@ -27,6 +27,7 @@
 from astropy.extern import _strptime
 from astropy.units import UnitConversionError
 from astropy.utils import lazyproperty
+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.data_info import MixinInfo, data_info_factory
 from astropy.utils.decorators import deprecated
 from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning
@@ -925,15 +926,22 @@ def shape(self, shape):
             (self, "location"),
         ):
             val = getattr(obj, attr, None)
-            if val is not None and val.size > 1:
-                try:
+            if val is None or val.size <= 1:
+                continue
+            try:
+                if NUMPY_LT_2_5:
                     val.shape = shape
-                except Exception:
-                    for val2 in reshaped:
-                        val2.shape = oldshape
-                    raise
                 else:
-                    reshaped.append(val)
+                    val._set_shape(shape)
+            except Exception:
+                for val2 in reshaped:
+                    if NUMPY_LT_2_5:
+                        val2.shape = oldshape
+                    else:
+                        val2._set_shape(oldshape)
+                raise
+            else:
+                reshaped.append(val)

     def _shaped_like_input(self, value):
         if self.masked:
diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py
index 5564e74af1b0..1805aca6d850 100644
--- a/astropy/utils/masked/core.py
+++ b/astropy/utils/masked/core.py
@@ -22,6 +22,7 @@

 import numpy as np

+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.data_info import ParentDtypeInfo
 from astropy.utils.shapes import NDArrayShapeMethods, ShapedLikeNDArray

@@ -743,14 +744,26 @@ def shape(self):

     @shape.setter
     def shape(self, shape):
+        return self._set_shape(shape)
+
+    def _set_shape(self, shape):
         old_shape = self.shape
-        self._mask.shape = shape
+        if NUMPY_LT_2_5:
+            self._mask.shape = shape
+        else:
+            self._mask._set_shape(shape)
         # Reshape array proper in try/except just in case some broadcasting
         # or so causes it to fail.
         try:
-            super(MaskedNDArray, type(self)).shape.__set__(self, shape)
+            if NUMPY_LT_2_5:
+                super(MaskedNDArray, type(self)).shape.__set__(self, shape)
+            else:
+                super()._set_shape(shape)
         except Exception as exc:
-            self._mask.shape = old_shape
+            if NUMPY_LT_2_5:
+                self._mask.shape = old_shape
+            else:
+                self._mask._set_shape(old_shape)
             # Given that the mask reshaping succeeded, the only logical
             # reason for an exception is something like a broadcast error in
             # in __array_finalize__, or a different memory ordering between


# BUG/TST: avoid (or accept) NumPy deprecation warnings for mutating shape attributes in utils.masked

### Description
ref #19126

<!-- Optional opt-out -->

- [ ] By checking this box, the PR author has requested that maintainers do **NOT** use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

diff --git a/astropy/utils/masked/tests/test_masked.py b/astropy/utils/masked/tests/test_masked.py
index f06cba7192eb..f2123cd2ccf6 100644
--- a/astropy/utils/masked/tests/test_masked.py
+++ b/astropy/utils/masked/tests/test_masked.py
@@ -14,7 +14,7 @@
 from astropy import units as u
 from astropy.coordinates import Longitude
 from astropy.units import Quantity
-from astropy.utils.compat import NUMPY_LT_2_0, NUMPY_LT_2_2, NUMPY_LT_2_3
+from astropy.utils.compat import NUMPY_LT_2_0, NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_3
 from astropy.utils.compat.optional_deps import HAS_PLT
 from astropy.utils.masked import Masked, MaskedNDArray

@@ -304,8 +304,11 @@ def test_viewing(self):

     def test_viewing_independent_shape(self):
         mms = Masked(self.a, mask=self.m)
-        mms2 = mms.view()
-        mms2.shape = mms2.shape[::-1]
+        if NUMPY_LT_2_1:
+            mms2 = mms.view()
+            mms2.shape = mms2.shape[::-1]
+        else:
+            mms2 = np.reshape(mms, mms.shape[::-1], copy=False)
         assert mms2.shape == mms.shape[::-1]
         assert mms2.mask.shape == mms.shape[::-1]
         # This should not affect the original array!
@@ -563,14 +566,21 @@ def test_reshape(self):
         assert_array_equal(ma_reshape.mask, expected_mask)

     def test_shape_setting(self):
-        ma_reshape = self.ma.copy()
-        ma_reshape.shape = (6,)
+        if NUMPY_LT_2_1:
+            ma_reshape = self.ma.copy()
+            ma_reshape.shape = (6,)
+        else:
+            ma_reshape = np.reshape(self.ma, (6,), copy=True)
+
         expected_data = self.a.reshape((6,))
         expected_mask = self.mask_a.reshape((6,))
         assert ma_reshape.shape == expected_data.shape
         assert_array_equal(ma_reshape.unmasked, expected_data)
         assert_array_equal(ma_reshape.mask, expected_mask)

+    @pytest.mark.filterwarnings(
+        "default:Setting the shape on a NumPy array has been deprecated in NumPy 2.5:DeprecationWarning"
+    )
     def test_shape_setting_failure(self):
         ma = self.ma.copy()
         with pytest.raises(ValueError, match="cannot reshape"):

diff --git a/astropy/utils/masked/function_helpers.py b/astropy/utils/masked/function_helpers.py
index c8aebdc3f833..786d50b8a0c5 100644
--- a/astropy/utils/masked/function_helpers.py
+++ b/astropy/utils/masked/function_helpers.py
@@ -24,6 +24,7 @@
     import numpy._core as np_core
     from numpy.lib._function_base_impl import _quantile_is_valid, _ureduce

+from astropy.utils.compat import NUMPY_LT_2_5

 # This module should not really be imported, but we define __all__
 # such that sphinx can typeset the functions with docstrings.
@@ -1563,7 +1564,10 @@ def in1d(ar1, ar2, assume_unique=False, invert=False, *, kind=None):
 def isin(element, test_elements, assume_unique=False, invert=False, *, kind=None):
     element = np.asanyarray(element)
     result = _in1d(element, test_elements, assume_unique, invert, kind=kind)
-    result.shape = element.shape
+    if NUMPY_LT_2_5:
+        result.shape = element.shape
+    else:
+        result._set_shape(element.shape)
     return result, _copy_of_mask(element), None


# Control display of vector columns in TimeSeries

### What is the problem this feature will solve?

At present, vector columns show only the first and last element; the rest are elided with "..". This behavior is [hardwired in the code](https://github.com/astropy/astropy/blob/main/astropy/table/pprint.py#L533).

### Describe the desired outcome

Set an option, something like `np.set_printoptions(threshhold=np.inf)` or similar, to change the number of elements shown, or to specify a linewidth, etc.

### Additional context

An example of need is position or velocity three-vectors. For most screens, there is enough width to display the middle element, particularly if formatting is set to show a modest number of digits, and this is more informative.

diff --git a/astropy/table/tests/test_pprint.py b/astropy/table/tests/test_pprint.py
index 9f9819345c1b..0df67673288b 100644
--- a/astropy/table/tests/test_pprint.py
+++ b/astropy/table/tests/test_pprint.py
@@ -1,17 +1,19 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst

+import sys
 from io import StringIO
 from shutil import get_terminal_size

 import numpy as np
 import pytest

-from astropy import conf, table
+from astropy import table
 from astropy import units as u
 from astropy.io import ascii
 from astropy.table import Column, QTable, Table
 from astropy.table.table_helpers import simple_table
+from astropy.utils.console import conf as console_conf
 from astropy.utils.exceptions import AstropyDeprecationWarning

 BIG_WIDE_ARR = np.arange(2000, dtype=np.float64).reshape(100, 20)
@@ -162,7 +164,10 @@ def test_format0(self, table_type):
         """
         self._setup(table_type)
         arr = np.arange(4000, dtype=np.float64).reshape(100, 40)
-        with conf.set_temp("max_width", None), conf.set_temp("max_lines", None):
+        with (
+            console_conf.set_temp("max_width", None),
+            console_conf.set_temp("max_lines", None),
+        ):
             lines = table_type(arr).pformat(max_lines=None, max_width=None)
         width, nlines = get_terminal_size()
         assert len(lines) == nlines
@@ -391,9 +396,7 @@ def test_column_format(self, table_type):
         assert t["a"].format == "%4.2f {0:}"  # format did not change

     def test_column_format_with_threshold(self, table_type):
-        from astropy import conf
-
-        with conf.set_temp("max_lines", 8):
+        with console_conf.set_temp("max_lines", 8):
             t = table_type([np.arange(20)], names=["a"])
             t["a"].format = "%{0:}"
             assert str(t["a"]).splitlines() == [
@@ -520,9 +523,7 @@ def test_column_format(self):
         assert str(t["a"]) == "   a   \n-------\n     --\n%4.2f 2\n     --"

     def test_column_format_with_threshold_masked_table(self):
-        from astropy import conf
-
-        with conf.set_temp("max_lines", 8):
+        with console_conf.set_temp("max_lines", 8):
             t = Table([np.arange(20)], names=["a"], masked=True)
             t["a"].format = "%{0:}"
             t["a"].mask[0] = True
@@ -1141,3 +1142,124 @@ def test_zero_length_string():
         "          12",
     ]
     assert t.pformat(show_dtype=True) == exp
+
+
+def test_multidim_threshold_default():
+    """Test default behavior (threshold=1) shows only first and last elements"""
+    # Default threshold is 1, so size > 1 will show "first .. last"
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 1):
+        data = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
+        col = Column(data, name="test")
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["1 .. 5", "6 .. 10"]
+
+
+def test_multidim_threshold_show_all():
+    """Test large threshold shows all elements"""
+    # Use sys.maxsize or a large value to show all elements
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", sys.maxsize):
+        data = np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
+        col = Column(data, name="test")
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["[1 2 3 4 5]", "[6 7 8 9 10]"]
+
+
+def test_multidim_threshold_partial():
+    """Test threshold shows elements with ellipsis when size > threshold"""
+    # Size is 10 (10 elements in the array), threshold is 4
+    # Since 10 > 4, should show "first .. last"
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 4):
+        data = np.array([[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]])
+        col = Column(data, name="test")
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["0 .. 9"]
+
+
+def test_multidim_threshold_three_vectors():
+    """Test threshold=3 for 3-element vectors (common use case)"""
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 3):
+        data = np.array([[1, 2, 3], [4, 5, 6]])
+        col = Column(data, name="position")
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert lines == ["[1 2 3]", "[4 5 6]"]
+
+
+def test_multidim_threshold_with_formatting():
+    """Test threshold works with column formatting"""
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", sys.maxsize):
+        data = np.array([[1.23456, 2.34567, 3.45678]], dtype=float)
+        col = Column(data, name="float", format="%.2f")
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert lines == ["[1.23 2.35 3.46]"]
+
+
+def test_multidim_threshold_2x3_array():
+    """Test 2x3 array display with different thresholds"""
+    # 2x3 array has size = 2*3 = 6
+    data = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]])
+    col = Column(data, name="test")
+
+    # With threshold=1, size=6 > 1, so show "first .. last"
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 1):
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["1 .. 6", "7 .. 12"]
+
+    # With threshold=6, size=6 <= 6, so show full element
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 6):
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == [
+            "[[1 2 3] [4 5 6]]",
+            "[[7 8 9] [10 11 12]]",
+        ]
+
+    # With threshold=5, size=6 > 5, so show "first .. last"
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 5):
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["1 .. 6", "7 .. 12"]
+
+
+def test_multidim_threshold_3d_array():
+    """Test 3D array display.
+
+    Each table element has shape (2, 3, 2); the full column shape is (2, 2, 3, 2)
+    where the first dimension (2) is the number of rows, and the remaining dimensions
+    (2, 3, 2) define the shape of each element. Size = 2*3*2 = 12.
+    """
+    data = np.arange(2 * 2 * 3 * 2).reshape(2, 2, 3, 2)
+    col = Column(data, name="test")
+
+    # With threshold=1, size=12 > 1, so show "first .. last"
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 1):
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert [line.strip() for line in lines] == ["0 .. 11", "12 .. 23"]
+
+    # With threshold=12, size=12 <= 12, so show full element with brackets
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 12):
+        lines = col.pformat(show_name=False, show_unit=False)
+        # Each row has shape (2, 3, 2) = [[[0,1] [2,3] [4,5]] [[6,7] [8,9] [10,11]]]
+        assert [line.strip() for line in lines] == [
+            "[[[0 1] [2 3] [4 5]] [[6 7] [8 9] [10 11]]]",
+            "[[[12 13] [14 15] [16 17]] [[18 19] [20 21] [22 23]]]",
+        ]
+
+
+def test_multidim_threshold_2x2_array_with_threshold_4():
+    """Test that 2x2 array (size=4) is fully shown when threshold=4"""
+    data = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])
+    col = Column(data, name="test")
+
+    # With threshold=4, size=4 <= 4, so show full element
+    # Uses astropy.table.conf.format_size_threshold
+    with table.conf.set_temp("format_size_threshold", 4):
+        lines = col.pformat(show_name=False, show_unit=False)
+        assert lines == ["[[1 2] [3 4]]", "[[5 6] [7 8]]"]

diff --git a/astropy/table/__init__.py b/astropy/table/__init__.py
index 45dda58923b4..e02cda7c15a2 100644
--- a/astropy/table/__init__.py
+++ b/astropy/table/__init__.py
@@ -68,6 +68,7 @@ class Conf(_config.ConfigNamespace):
         "'always', 'slice', 'refcount', 'attributes'.",
         "string_list",
     )
+
     replace_inplace = _config.ConfigItem(
         False,
         "Always use in-place update of a table column when using setitem, "
@@ -77,6 +78,15 @@ class Conf(_config.ConfigNamespace):
         "subsequent major releases.",
     )

+    format_size_threshold = _config.ConfigItem(
+        1,
+        "Maximum total size (product of all dimensions except the first) for displaying full multidimensional column elements. "
+        "If the size exceeds this threshold, only the first and last elements are shown with '..'. "
+        "Otherwise, the full element is displayed. Default is 1 which shows only first and last (e.g., '1 .. 5'). "
+        "Set to a large value like sys.maxsize for no limit.",
+        cfgtype="integer",
+    )
+

 conf = Conf()

diff --git a/astropy/table/pprint.py b/astropy/table/pprint.py
index 92946c097f8f..a059ff21b14a 100644
--- a/astropy/table/pprint.py
+++ b/astropy/table/pprint.py
@@ -8,7 +8,7 @@

 import numpy as np

-from astropy import log
+from astropy import log, table
 from astropy.utils.console import Getch, color_print, conf
 from astropy.utils.data_info import dtype_info_name

@@ -176,12 +176,12 @@ def _get_pprint_size(max_lines=None, max_width=None):
         If no value of ``max_lines`` is supplied then the height of the
         screen terminal is used to set ``max_lines``.  If the terminal
         height cannot be determined then the default will be determined
-        using the ``astropy.table.conf.max_lines`` configuration item. If a
+        using the ``astropy.console.max_lines`` console configuration item. If a
         negative value of ``max_lines`` is supplied then there is no line
         limit applied.

         The same applies for max_width except the configuration item is
-        ``astropy.table.conf.max_width``.
+        ``astropy.console.conf.max_width``.

         Parameters
         ----------
@@ -518,23 +518,40 @@ def _pformat_col_iter(
             indices = np.arange(n_rows)

         def format_col_str(idx):
-            if multidims:
-                # Prevents columns like Column(data=[[(1,)],[(2,)]], name='a')
-                # with shape (n,1,...,1) from being printed as if there was
-                # more than one element in a row
-                if multidims_all_ones:
-                    return format_func(col_format, col[(idx,) + multidim0])
-                elif multidims_has_zero:
-                    # Any zero dimension means there is no data to print
-                    return ""
-                else:
-                    left = format_func(col_format, col[(idx,) + multidim0])
-                    right = format_func(col_format, col[(idx,) + multidim1])
-                    return f"{left} .. {right}"
-            elif is_scalar:
-                return format_func(col_format, col)
+            if not multidims:
+                return format_func(col_format, col if is_scalar else col[idx])
+
+            # Prevents columns like Column(data=[[(1,)],[(2,)]], name='a')
+            # with shape (n,1,...,1) from being printed as if there was
+            # more than one element in a row
+            if multidims_all_ones:
+                return format_func(col_format, col[(idx,) + multidim0])
+
+            if multidims_has_zero:
+                # Any zero dimension means there is no data to print
+                return ""
+
+            size = np.prod(multidims)
+            # Uses astropy.table.conf.format_size_threshold for multidimensional column formatting
+            threshold = table.conf.format_size_threshold
+
+            # If size > threshold, revert to legacy "first .. last" format
+            if size > threshold:
+                left = format_func(col_format, col[(idx,) + multidim0])
+                right = format_func(col_format, col[(idx,) + multidim1])
+                result = f"{left} .. {right}"
             else:
-                return format_func(col_format, col[idx])
+
+                def format_multidim(arr):
+                    """Recursively format multidimensional arrays."""
+                    if arr.ndim == 0:
+                        return format_func(col_format, arr)
+                    else:
+                        return "[" + " ".join(format_multidim(val) for val in arr) + "]"
+
+                result = format_multidim(col[idx])
+
+            return result

         # Add formatted values if within bounds allowed by max_lines
         for idx in indices:


diff --git a/astropy/utils/masked/tests/test_masked.py b/astropy/utils/masked/tests/test_masked.py
index 4cd7ca309980..c9dc2c5c5bc7 100644
--- a/astropy/utils/masked/tests/test_masked.py
+++ b/astropy/utils/masked/tests/test_masked.py
@@ -14,7 +14,7 @@
 from astropy import units as u
 from astropy.coordinates import Longitude
 from astropy.units import Quantity
-from astropy.utils.compat import NUMPY_LT_2_2, NUMPY_LT_2_3
+from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_3
 from astropy.utils.compat.optional_deps import HAS_PLT
 from astropy.utils.masked import Masked, MaskedNDArray

@@ -304,8 +304,11 @@ def test_viewing(self):

     def test_viewing_independent_shape(self):
         mms = Masked(self.a, mask=self.m)
-        mms2 = mms.view()
-        mms2.shape = mms2.shape[::-1]
+        if NUMPY_LT_2_1:
+            mms2 = mms.view()
+            mms2.shape = mms2.shape[::-1]
+        else:
+            mms2 = np.reshape(mms, mms.shape[::-1], copy=False)
         assert mms2.shape == mms.shape[::-1]
         assert mms2.mask.shape == mms.shape[::-1]
         # This should not affect the original array!
@@ -563,14 +566,21 @@ def test_reshape(self):
         assert_array_equal(ma_reshape.mask, expected_mask)

     def test_shape_setting(self):
-        ma_reshape = self.ma.copy()
-        ma_reshape.shape = (6,)
+        if NUMPY_LT_2_1:
+            ma_reshape = self.ma.copy()
+            ma_reshape.shape = (6,)
+        else:
+            ma_reshape = np.reshape(self.ma, (6,), copy=True)
+
         expected_data = self.a.reshape((6,))
         expected_mask = self.mask_a.reshape((6,))
         assert ma_reshape.shape == expected_data.shape
         assert_array_equal(ma_reshape.unmasked, expected_data)
         assert_array_equal(ma_reshape.mask, expected_mask)

+    @pytest.mark.filterwarnings(
+        "default:Setting the shape on a NumPy array has been deprecated in NumPy 2.5:DeprecationWarning"
+    )
     def test_shape_setting_failure(self):
         ma = self.ma.copy()
         with pytest.raises(ValueError, match="cannot reshape"):

diff --git a/astropy/utils/masked/function_helpers.py b/astropy/utils/masked/function_helpers.py
index 7cdec60111e3..08e5ad856be7 100644
--- a/astropy/utils/masked/function_helpers.py
+++ b/astropy/utils/masked/function_helpers.py
@@ -17,7 +17,7 @@
 from numpy.lib._function_base_impl import _quantile_is_valid, _ureduce

 from astropy.units.quantity_helper.function_helpers import FunctionAssigner
-from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4
+from astropy.utils.compat import NUMPY_LT_2_1, NUMPY_LT_2_2, NUMPY_LT_2_4, NUMPY_LT_2_5

 # This module should not really be imported, but we define __all__
 # such that sphinx can typeset the functions with docstrings.
@@ -1427,7 +1427,10 @@ def in1d(ar1, ar2, assume_unique=False, invert=False, *, kind=None):
 def isin(element, test_elements, assume_unique=False, invert=False, *, kind=None):
     element = np.asanyarray(element)
     result = _in1d(element, test_elements, assume_unique, invert, kind=kind)
-    result.shape = element.shape
+    if NUMPY_LT_2_5:
+        result.shape = element.shape
+    else:
+        result._set_shape(element.shape)
     return result, _copy_of_mask(element), None


# Fix `PicklingError` when pickling `Masked` subclasses with initialized `info` attribute

<!-- These comments are hidden when you submit the pull request,
so you do not need to remove them! -->

<!-- Please be sure to check out our contributing guidelines,
https://github.com/astropy/astropy/blob/main/CONTRIBUTING.md .
Please be sure to check out our code of conduct,
https://github.com/astropy/astropy/blob/main/CODE_OF_CONDUCT.md . -->

<!-- If you are new or need to be re-acquainted with Astropy
contributing workflow, please see
https://docs.astropy.org/en/latest/development/quickstart.html .
There is even a practical example at
https://docs.astropy.org/en/latest/development/git_edit_workflow_examples.html . -->

<!-- Please just have a quick search on GitHub to see if a similar
pull request has already been posted.
We have old closed pull requests that might provide useful code or ideas
that directly tie in with your pull request. -->

<!-- We have several automatic features that run when a pull request is open.
They can appear daunting but do not worry because maintainers will help
you navigate them, if necessary. -->

### Description
<!-- Provide a general description of what your pull request does.
Complete the following sentence and add relevant details as you see fit. -->

<!-- In addition please ensure that the pull request title is descriptive
and allows maintainers to infer the applicable subpackage(s). -->

<!-- READ THIS FOR MANUAL BACKPORT FROM A MAINTAINER:
Apply "skip-basebranch-check" label **before** you open the PR! -->

This pull request is to address a `PicklingError` when attempting to pickle dynamically created `Masked` subclasses that have an initialized `info` attribute.  While #17685 ensured the `Masked` subclasses themselves were importable, their associated info classes, which are also created dynamically, remained inaccessible to `pickle`. This fix ensures that the info classes are assigned the correct module and are accessible through the module's `__getattr__` method.  This also resolves failures with pickling Masked `QTable` instances, since `MaskedQuantity` columns contain an `info` attribute. I also included regression tests for importing the info classes of common `Masked` subclasses.

Example of fixed behavior:
```python
import pickle
from astropy.utils.masked import Masked
from astropy import units as u

mq = Masked([1,2,3]*u.m, mask=[True,False,False])
mq.info # Access info to create it
# Previously raised _pickle.PicklingError
pickle.loads(pickle.dumps(mq))
```
<!-- If the pull request closes any open issues you can add this.
If you replace <Issue Number> with a number, GitHub will automatically link it.
If this pull request is unrelated to any issues, please remove
the following line. -->

Fixes #19040
Fixes #18206

<!-- Optional opt-out -->

- [ ] By checking this box, the PR author has requested that maintainers do **NOT** use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

diff --git a/astropy/table/tests/test_pickle.py b/astropy/table/tests/test_pickle.py
index 6e31e6de3f8e..8c670ff66101 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)

diff --git a/astropy/utils/masked/core.py b/astropy/utils/masked/core.py
index b87ac39ce429..4ad48e26fc8c 100644
--- a/astropy/utils/masked/core.py
+++ b/astropy/utils/masked/core.py
@@ -618,10 +618,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()

@@ -1430,13 +1433,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
@@ -1450,13 +1454,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}'")


# BUG: avoid deprecated interface for mutating shape attibutes (io.fits)

### Description
ref #19126

<!-- Optional opt-out -->

- [ ] By checking this box, the PR author has requested that maintainers do **NOT** use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

diff --git a/astropy/io/fits/tests/test_image.py b/astropy/io/fits/tests/test_image.py
index 0d7b2d868035..2e6c1b31e310 100644
--- a/astropy/io/fits/tests/test_image.py
+++ b/astropy/io/fits/tests/test_image.py
@@ -9,6 +9,7 @@
 from numpy.testing import assert_equal

 from astropy.io import fits
+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.data import get_pkg_data_filename
 from astropy.utils.exceptions import AstropyUserWarning

@@ -977,7 +978,12 @@ def test_open_scaled_in_update_mode(self):

         # Try reshaping the data, then closing and reopening the file; let's
         # see if all the changes are preserved properly
-        hdul[0].data.shape = (42, 10)
+        if NUMPY_LT_2_5:
+            hdul[0].data.shape = (42, 10)
+        else:
+            # ndarray._set_shape is semi-private, but the only
+            # non deprecated, strict semantic equivalent in Numpy 2.5+
+            hdul[0].data._set_shape((42, 10))
         hdul.close()

         hdul = fits.open(testfile)
@@ -988,6 +994,13 @@ def test_open_scaled_in_update_mode(self):
         assert "BSCALE" not in hdul[0].header
         hdul.close()

+        with fits.open(testfile, mode="update") as hdul:
+            # Try reshaping the data again, this time using np.reshape
+            hdul[0].data = np.reshape(hdul[0].data, (21, 20))
+
+        with fits.open(testfile) as hdul:
+            assert hdul[0].shape == (21, 20)
+
     def test_scale_back(self):
         """A simple test for https://aeon.stsci.edu/ssb/trac/pyfits/ticket/120

diff --git a/astropy/io/fits/util.py b/astropy/io/fits/util.py
index 14b353e78d72..ad74e20ab6bc 100644
--- a/astropy/io/fits/util.py
+++ b/astropy/io/fits/util.py
@@ -20,6 +20,7 @@
 from packaging.version import Version

 from astropy.utils import data
+from astropy.utils.compat import NUMPY_LT_2_5
 from astropy.utils.compat.optional_deps import HAS_DASK
 from astropy.utils.exceptions import AstropyUserWarning

@@ -870,7 +871,10 @@ def _rstrip_inplace(array):
     # Note: the code will work if this fails; the chunks will just be larger.
     if b.ndim > 2:
         try:
-            b.shape = -1, b.shape[-1]
+            if NUMPY_LT_2_5:
+                b.shape = -1, b.shape[-1]
+            else:
+                b._set_shape((-1, b.shape[-1]))
         except AttributeError:  # can occur for non-contiguous arrays
             pass
     for j in range(0, b.shape[0], bufsize):


# ⚠ Nightly upstream-dev CI failed ⚠

[Workflow Run URL](https://github.com/pydata/xarray/actions/runs/22881049985)
<details><summary>Python 3.12 Test Summary</summary>

```
xarray/tests/test_computation.py::test_fix: DeprecationWarning: numpy.fix is deprecated. Use numpy.trunc instead, which is faster and follows the Array API standard.
xarray/tests/test_dataarray.py::TestDataArray::test_curvefit_helpers: Failed: DID NOT RAISE <class 'ValueError'>
xarray/tests/test_variable.py::TestIndexVariable::test_concat_periods: ValueError: Could not convert <xarray.IndexVariable 't' (t: 5)> Size: 40B
<PeriodArray>
['2000-01-01', '2000-01-02', '2000-01-03', '2000-01-04', '2000-01-05']
Length: 5, dtype: period[D] to a NumPy dtype (via `.dtype` value period[D]).
```

</details>

diff --git a/xarray/tests/test_computation.py b/xarray/tests/test_computation.py
index 84727cd210f..1cb8e6f4a3f 100644
--- a/xarray/tests/test_computation.py
+++ b/xarray/tests/test_computation.py
@@ -2646,6 +2646,7 @@ def test_complex_number_reduce(compute_backend):
     da.min()

+@pytest.mark.filterwarnings("ignore:numpy.fix is deprecated.")
 def test_fix() -> None:
     val = 3.0
     val_fixed = np.fix(val)
diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index dc4c29d1a68..5e514cc9767 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -4908,8 +4908,6 @@ def exp_decay(t, n0, tau=1):
         param_names = ["a"]
         params, func_args = _get_func_args(np.power, param_names)
         assert params == param_names
-        with pytest.raises(ValueError):
-            _get_func_args(np.power, [])

     @requires_scipy
     @pytest.mark.parametrize("use_dask", [True, False])

diff --git a/xarray/core/utils.py b/xarray/core/utils.py
index c6828f0a363..100c256fa9d 100644
--- a/xarray/core/utils.py
+++ b/xarray/core/utils.py
@@ -205,7 +205,7 @@ def get_valid_numpy_dtype(array: np.ndarray | pd.Index) -> np.dtype:

 def maybe_coerce_to_str(index, original_coords):
-    """maybe coerce a pandas Index back to a nunpy array of type str
+    """maybe coerce a pandas Index back to a numpy array of type str

     pd.Index uses object-dtype to store str - try to avoid this for coords
     """
@@ -213,7 +213,7 @@ def maybe_coerce_to_str(index, original_coords):

     try:
         result_type = dtypes.result_type(*original_coords)
-    except TypeError:
+    except (TypeError, ValueError):
         pass
     else:
         if result_type.kind in "SU":