Repository: astropy/astropy

Issue:
fitsdiff raises a KeyError for lowercase HIERARCH keywords
### Description

Fitsdiff raises a Keyerror exception when comparing FITS files with a lowercase HIERARCH keyword because it tries to compare the uppercase keyword which does not exist.

### Expected behavior

No exception being raised.

### How to Reproduce

1. Install astropy. I tried 5.3.4 and 6.0.1 from conda-forge.

2. Then run

```python
from astropy.io import fits
from astropy.io.fits.scripts.fitsdiff import main

# Does not work, raises "KeyError: 'LOWER'":
myheader = fits.Header([("HIERARCH lower", 42)])
myhdu = fits.hdu.PrimaryHDU(header=myheader)
myhdus = fits.HDUList([myhdu])
fits.diff.FITSDiff(myhdus, myhdus)
```

3. An error occurs.
```
Traceback (most recent call last):
  File "fitsdiffhierarchlowerminimal.py", line 8, in <module>
    fits.diff.FITSDiff(myhdus, myhdus)
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 331, in __init__
    super().__init__(a, b)
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 87, in __init__
    self._diff()
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 369, in _diff
    hdu_diff = HDUDiff.fromdiff(self, self.a[idx], self.b[idx])
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "./site-packages/astropy/io/fits/diff.py", line 120, in fromdiff
    return cls(a, b, **kwargs)
           ^^^^^^^^^^^^^^^^^^^
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 570, in __init__
    super().__init__(a, b)
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 87, in __init__
    self._diff()
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 588, in _diff
    self.diff_headers = HeaderDiff.fromdiff(
                        ^^^^^^^^^^^^^^^^^^^^
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 120, in fromdiff
    return cls(a, b, **kwargs)
           ^^^^^^^^^^^^^^^^^^^
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 822, in __init__
    super().__init__(a, b)
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 87, in __init__
    self._diff()
  File ".../python3.12/site-packages/astropy/io/fits/diff.py", line 890, in _diff
    counta = len(valuesa[keyword])
                 ~~~~~~~^^^^^^^^^
KeyError: 'LOWER'
```


### Versions

```
Linux-5.4.0-177-generic-x86_64-with-glibc2.31
Python 3.12.3 | packaged by conda-forge | (main, Apr 15 2024, 18:38:13) [GCC 12.3.0]
astropy 6.0.1
Numpy 1.26.4
pyerfa 2.0.1.4
Scipy not installed
Matplotlib not installed
```


Discussion:

Patch:
diff --git a/astropy/coordinates/baseframe.py b/astropy/coordinates/baseframe.py
index 459caefb9c..83ec945b2c 100644
--- a/astropy/coordinates/baseframe.py
+++ b/astropy/coordinates/baseframe.py
@@ -21,9 +21,9 @@ from typing import TYPE_CHECKING, NamedTuple
 import numpy as np
 
 from astropy import units as u
-from astropy.utils import ShapedLikeNDArray
+from astropy.utils import ShapedLikeNDArray, check_broadcast
 from astropy.utils.decorators import deprecated, format_doc, lazyproperty
-from astropy.utils.exceptions import AstropyWarning, _add_note_to_exception
+from astropy.utils.exceptions import AstropyWarning
 
 from . import representation as r
 from .angles import Angle, position_angle
@@ -344,13 +344,11 @@ class BaseCoordinateFrame(ShapedLikeNDArray):
 
         # Determine the overall shape of the frame.
         try:
-            self._shape = np.broadcast_shapes(*shapes)
-        except ValueError as exc:
-            _add_note_to_exception(
-                exc,
-                f"non-scalar data and/or attributes with inconsistent shapes: {shapes}",
-            )
-            raise exc
+            self._shape = check_broadcast(*shapes)
+        except ValueError as err:
+            raise ValueError(
+                f"non-scalar data and/or attributes with inconsistent shapes: {shapes}"
+            ) from err
 
         # Broadcast the data if necessary and set it
         if data is not None and data.shape != self._shape:
diff --git a/astropy/io/fits/diff.py b/astropy/io/fits/diff.py
index dd84a9598d..e6487b0bd0 100644
--- a/astropy/io/fits/diff.py
+++ b/astropy/io/fits/diff.py
@@ -843,10 +843,8 @@ class HeaderDiff(_BaseDiff):
         valuesa, commentsa = get_header_values_comments(cardsa)
         valuesb, commentsb = get_header_values_comments(cardsb)
 
-        # Normalize all keyword to upper-case for comparison's sake;
-        # TODO: HIERARCH keywords should be handled case-sensitively I think
-        keywordsa = {k.upper() for k in valuesa}
-        keywordsb = {k.upper() for k in valuesb}
+        keywordsa = set(valuesa)
+        keywordsb = set(valuesb)
 
         self.common_keywords = sorted(keywordsa.intersection(keywordsb))
         if len(cardsa) != len(cardsb):
diff --git a/astropy/modeling/core.py b/astropy/modeling/core.py
index b469f32cdf..7d21ed52a6 100644
--- a/astropy/modeling/core.py
+++ b/astropy/modeling/core.py
@@ -19,8 +19,6 @@ import copy
 import functools
 import inspect
 import operator
-import re
-import warnings
 from collections import defaultdict, deque
 from inspect import signature
 from textwrap import indent
@@ -32,6 +30,8 @@ from astropy.table import Table
 from astropy.units import Quantity, UnitsError, dimensionless_unscaled
 from astropy.units.utils import quantity_asanyarray
 from astropy.utils import (
+    IncompatibleShapeError,
+    check_broadcast,
     find_current_module,
     isiterable,
     metadata,
@@ -39,7 +39,6 @@ from astropy.utils import (
 )
 from astropy.utils.codegen import make_function_with_signature
 from astropy.utils.compat import COPY_IF_NEEDED
-from astropy.utils.exceptions import _add_note_to_exception
 
 from .bounding_box import CompoundBoundingBox, ModelBoundingBox
 from .parameters import InputParameterError, Parameter, _tofloat, param_repr_oneline
@@ -1064,12 +1063,11 @@ class Model(metaclass=_ModelMeta):
             )
 
         try:
-            input_shape = np.broadcast_shapes(*all_shapes)
-        except ValueError as exc:
-            _add_note_to_exception(
-                exc, "All inputs must have identical shapes or must be scalars."
-            )
-            raise exc
+            input_shape = check_broadcast(*all_shapes)
+        except IncompatibleShapeError as e:
+            raise ValueError(
+                "All inputs must have identical shapes or must be scalars."
+            ) from e
 
         return input_shape
 
@@ -1951,17 +1949,15 @@ class Model(metaclass=_ModelMeta):
             for param in params:
                 try:
                     if self.standard_broadcasting:
-                        broadcast = np.broadcast_shapes(input_shape, param.shape)
+                        broadcast = check_broadcast(input_shape, param.shape)
                     else:
                         broadcast = input_shape
-                except ValueError as exc:
-                    _add_note_to_exception(
-                        exc,
+                except IncompatibleShapeError:
+                    raise ValueError(
                         f"self input argument {self.inputs[idx]!r} of shape"
                         f" {input_shape!r} cannot be broadcast with parameter"
-                        f" {param.name!r} of shape {param.shape!r}.",
+                        f" {param.name!r} of shape {param.shape!r}."
                     )
-                    raise exc
 
                 if len(broadcast) > len(max_broadcast):
                     max_broadcast = broadcast
@@ -2016,19 +2012,17 @@ class Model(metaclass=_ModelMeta):
 
             for param in params:
                 try:
-                    np.broadcast_shapes(
+                    check_broadcast(
                         input_shape,
                         self._remove_axes_from_shape(param.shape, model_set_axis_param),
                     )
-                except ValueError as exc:
-                    _add_note_to_exception(
-                        exc,
+                except IncompatibleShapeError:
+                    raise ValueError(
                         f"Model input argument {self.inputs[idx]!r} of shape"
                         f" {input_shape!r} "
                         f"cannot be broadcast with parameter {param.name!r} of shape "
-                        f"{self._remove_axes_from_shape(param.shape, model_set_axis_param)!r}.",
+                        f"{self._remove_axes_from_shape(param.shape, model_set_axis_param)!r}."
                     )
-                    raise exc
 
                 if len(param.shape) - 1 > len(max_param_shape):
                     max_param_shape = self._remove_axes_from_shape(
@@ -2233,23 +2227,11 @@ class Model(metaclass=_ModelMeta):
 
     def _prepare_outputs_single_model(self, outputs, broadcasted_shapes):
         outputs = list(outputs)
-        shapes = broadcasted_shapes[0]
         for idx, output in enumerate(outputs):
-            if None in shapes:
-                # Previously, we used our own function (check_broadcast) instead
-                # of np.broadcast_shapes in the following try block
-                # - check_broadcast raised an exception when passed a None.
-                # - as of numpy 1.26, np.broadcast raises a deprecation warning
-                # when passed a `None` value, but returns an empty tuple.
-                #
-                # Since () and None have different effects downstream of this function,
-                # and to preserve backward-compatibility, we handle this special here
-                broadcast_shape = shapes[idx]
-            else:
-                try:
-                    broadcast_shape = np.broadcast_shapes(*shapes)
-                except Exception:
-                    broadcast_shape = shapes[idx]
+            try:
+                broadcast_shape = check_broadcast(*broadcasted_shapes[0])
+            except (IndexError, TypeError):
+                broadcast_shape = broadcasted_shapes[0][idx]
 
             outputs[idx] = self._prepare_output_single_model(output, broadcast_shape)
 
@@ -2760,45 +2742,18 @@ class Model(metaclass=_ModelMeta):
 
         # Now check mutual broadcastability of all shapes
         try:
-            np.broadcast_shapes(*all_shapes)
-        except ValueError as exc:
-            # In a previous version, we used to have our own version of
-            # np.broadcast_shapes (check_broadcast). In order to preserve
-            # backward compatibility, we now have to go the extra mile and
-            # parse an error message controlled by numpy.
-            base_message = (
-                "All parameter arrays "
+            check_broadcast(*all_shapes)
+        except IncompatibleShapeError as exc:
+            shape_a, shape_a_idx, shape_b, shape_b_idx = exc.args
+            param_a = self.param_names[shape_a_idx]
+            param_b = self.param_names[shape_b_idx]
+
+            raise InputParameterError(
+                f"Parameter {param_a!r} of shape {shape_a!r} cannot be broadcast with "
+                f"parameter {param_b!r} of shape {shape_b!r}.  All parameter arrays "
                 "must have shapes that are mutually compatible according "
                 "to the broadcasting rules."
             )
-            broadcast_shapes_error_re = re.compile(
-                r"shape mismatch: objects cannot be broadcast to a single shape\.  "
-                r"Mismatch is between "
-                r"arg (?P<argno_a>\d+) with shape (?P<shape_a>\((\d+(, ?)?)+\)) and "
-                r"arg (?P<argno_b>\d+) with shape (?P<shape_b>\((\d+(, ?)?)+\))\."
-            )
-            if (match := broadcast_shapes_error_re.fullmatch(str(exc))) is not None:
-                shape_a = match.group("shape_a")
-                shape_b = match.group("shape_b")
-                shape_a_idx = int(match.group("argno_a"))
-                shape_b_idx = int(match.group("argno_b"))
-                param_a = self.param_names[shape_a_idx]
-                param_b = self.param_names[shape_b_idx]
-                message = (
-                    f"Parameter {param_a!r} of shape {shape_a} cannot be broadcast with "
-                    f"parameter {param_b!r} of shape {shape_b}."
-                )
-            else:
-                warnings.warn(
-                    "Failed to parse error message from np.broadcast_shapes. "
-                    "Please report this at "
-                    "https://github.com/astropy/astropy/issues/new/choose",
-                    category=RuntimeWarning,
-                    stacklevel=1,
-                )
-                message = "Some parameters failed to broadcast with each other."
-
-            raise InputParameterError(f"{message} {base_message}") from None
 
     def _param_sets(self, raw=False, units=False):
         """
diff --git a/astropy/modeling/polynomial.py b/astropy/modeling/polynomial.py
index d67cfb3154..07cdf0f25a 100644
--- a/astropy/modeling/polynomial.py
+++ b/astropy/modeling/polynomial.py
@@ -10,6 +10,7 @@ from textwrap import indent
 
 import numpy as np
 
+from astropy.utils import check_broadcast
 from astropy.utils.compat import COPY_IF_NEEDED
 
 from .core import FittableModel, Model
@@ -1188,7 +1189,7 @@ class Polynomial2D(PolynomialModel):
         # still as expected by the broadcasting rules, even though the x and y
         # inputs are not used in the evaluation
         if self.degree == 0:
-            output_shape = np.broadcast_shapes(np.shape(coeffs[0]), x.shape)
+            output_shape = check_broadcast(np.shape(coeffs[0]), x.shape)
             if output_shape:
                 new_result = np.empty(output_shape)
                 new_result[:] = result
diff --git a/astropy/utils/exceptions.py b/astropy/utils/exceptions.py
index bcf7291a21..6a301ecc94 100644
--- a/astropy/utils/exceptions.py
+++ b/astropy/utils/exceptions.py
@@ -75,17 +75,6 @@ class _NoValue:
 NoValue = _NoValue()
 
 
-def _add_note_to_exception(exc: Exception, note: str) -> None:
-    import sys
-
-    if sys.version_info >= (3, 11):
-        exc.add_note(note)
-    else:
-        # mimic Python 3.11 behavior:
-        # preserve error message and traceback
-        exc.args += ("\n", note)
-
-
 def __getattr__(name: str):
     if name in ("ErfaError", "ErfaWarning"):
         import warnings
diff --git a/astropy/utils/shapes.py b/astropy/utils/shapes.py
index e419ad2e17..b2f2114f29 100644
--- a/astropy/utils/shapes.py
+++ b/astropy/utils/shapes.py
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING
 import numpy as np
 
 from astropy.utils.compat import NUMPY_LT_2_0
-from astropy.utils.decorators import deprecated
 
 if NUMPY_LT_2_0:
     import numpy.core as np_core
@@ -357,7 +356,6 @@ class IncompatibleShapeError(ValueError):
         super().__init__(shape_a, shape_a_idx, shape_b, shape_b_idx)
 
 
-@deprecated("7.0", alternative="np.broadcast_shapes")
 def check_broadcast(*shapes: tuple[int, ...]) -> tuple[int, ...]:
     """
     Determines whether two or more Numpy arrays can be broadcast with each
diff --git a/docs/changes/io.fits/16357.bugfix.rst b/docs/changes/io.fits/16357.bugfix.rst
new file mode 100644
index 0000000000..0879c6af21
--- /dev/null
+++ b/docs/changes/io.fits/16357.bugfix.rst
@@ -0,0 +1,1 @@
+Let fitsdiff compare files with lower case HIERARCH keywords
diff --git a/docs/changes/utils/16346.api.rst b/docs/changes/utils/16346.api.rst
deleted file mode 100644
index 79e99ed688..0000000000
--- a/docs/changes/utils/16346.api.rst
+++ /dev/null
@@ -1,2 +0,0 @@
-``astropy.utils.check_broadcast`` is now deprecated in favor of
-``numpy.broadcast_shapes``


Test patch:
diff --git a/astropy/coordinates/tests/test_frames.py b/astropy/coordinates/tests/test_frames.py
index bf378976e3..c42d6f65b6 100644
--- a/astropy/coordinates/tests/test_frames.py
+++ b/astropy/coordinates/tests/test_frames.py
@@ -1,7 +1,6 @@
 # Licensed under a 3-clause BSD style license - see LICENSE.rst
 
 import re
-import sys
 from copy import deepcopy
 
 import numpy as np
@@ -39,7 +38,6 @@ from astropy.coordinates.representation import (
     REPRESENTATION_CLASSES,
     CartesianDifferential,
 )
-from astropy.tests.helper import PYTEST_LT_8_0
 from astropy.tests.helper import assert_quantity_allclose as assert_allclose
 from astropy.time import Time
 from astropy.units import allclose
@@ -256,22 +254,12 @@ def test_no_data_nonscalar_frames():
     assert a1.obstime.shape == (3, 10)
     assert a1.temperature.shape == (3, 10)
     assert a1.shape == (3, 10)
-
-    if sys.version_info >= (3, 11) and PYTEST_LT_8_0:
-        # Exception.__notes__ are available (and used here) but ignored in matching,
-        # so we'll match manually and post-mortem instead
-        match = None
-    else:
-        match = r".*inconsistent shapes.*"
-
-    with pytest.raises(ValueError, match=match) as exc:
+    with pytest.raises(ValueError) as exc:
         AltAz(
             obstime=Time("2012-01-01") + np.arange(10.0) * u.day,
             temperature=np.ones((3,)) * u.deg_C,
         )
-
-    if match is None:
-        assert "inconsistent shapes" in "\n".join(exc.value.__notes__)
+    assert "inconsistent shapes" in str(exc.value)
 
 
 def test_frame_repr():
diff --git a/astropy/io/fits/tests/test_diff.py b/astropy/io/fits/tests/test_diff.py
index 5edd41cca3..8712e257f6 100644
--- a/astropy/io/fits/tests/test_diff.py
+++ b/astropy/io/fits/tests/test_diff.py
@@ -308,6 +308,25 @@ class TestDiff(FitsTestCase):
         assert not diff.identical
         assert diff.diff_keyword_comments == {"C": [("C", "E")]}
 
+    def test_hierarch_keywords_identical(self):
+        ha = Header(
+            [
+                ("HIERARCH UPPER", 1),
+                ("HIERARCH lower", 2),
+                ("HIERARCH veryverylong", 3),
+            ]
+        )
+        hb = ha.copy()
+        assert HeaderDiff(ha, hb).identical
+
+    def test_hierarch_keywords_different(self):
+        ha = Header([("HIERARCH Both", 1)])
+        hb = Header([("HIERARCH BOTh", 1)])
+        diff = HeaderDiff(ha, hb)
+        assert not diff.identical
+        assert diff.common_keywords == []
+        assert diff.diff_keywords == (["Both"], ["BOTh"])
+
     def test_trivial_identical_images(self):
         ia = np.arange(100).reshape(10, 10)
         ib = np.arange(100).reshape(10, 10)
diff --git a/astropy/modeling/tests/test_input.py b/astropy/modeling/tests/test_input.py
index c42dc94995..28ac50b9f5 100644
--- a/astropy/modeling/tests/test_input.py
+++ b/astropy/modeling/tests/test_input.py
@@ -436,7 +436,11 @@ class TestSingleInputSingleOutputSingleModel:
         assert np.shape(y2) == (2, 2)
         assert np.all(y2 == [[111, 122], [211, 222]])
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"self input argument 'x' of shape .* cannot be broadcast with parameter"
+            r" 'p1' of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             # Doesn't broadcast
             t([100, 200, 300])
 
@@ -462,7 +466,11 @@ class TestSingleInputSingleOutputSingleModel:
             ]
         )
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
+            r" of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             # Doesn't broadcast
             t([[100, 200, 300], [400, 500, 600]])
 
@@ -646,7 +654,11 @@ class TestSingleInputSingleOutputTwoModel:
         assert np.shape(y1) == (2, 3)
         assert np.all(y1 == [[111, 122, 133], [244, 255, 266]])
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"Model input argument .* of shape .* cannot be broadcast with parameter .*"
+            r" of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             # Doesn't broadcast with the shape of the parameters, (3,)
             y2 = t([100, 200], model_set_axis=False)
 
@@ -670,7 +682,11 @@ class TestSingleInputSingleOutputTwoModel:
             ]
         )
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"Model input argument .* of shape .* cannot be broadcast with parameter .*"
+            r" of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             y2 = t([[100, 200, 300], [400, 500, 600]])
 
         y2 = t([[[100, 200], [300, 400]], [[500, 600], [700, 800]]])
@@ -829,7 +845,11 @@ class TestSingleInputDoubleOutputSingleModel:
         assert np.all(y2 == [[111, 122], [211, 222]])
         assert np.all(z2 == [[1111, 2122], [1211, 2222]])
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
+            r" of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             # Doesn't broadcast
             y3, z3 = t([100, 200, 300])
 
@@ -865,7 +885,11 @@ class TestSingleInputDoubleOutputSingleModel:
             ]
         )
 
-        with pytest.raises(ValueError, match="broadcast"):
+        MESSAGE = (
+            r"self input argument .* of shape .* cannot be broadcast with parameter .*"
+            r" of shape .*"
+        )
+        with pytest.raises(ValueError, match=MESSAGE):
             # Doesn't broadcast
             y3, z3 = t([[100, 200, 300], [400, 500, 600]])
 
diff --git a/astropy/modeling/tests/test_models.py b/astropy/modeling/tests/test_models.py
index 04fa9999f4..bb6b4d1d17 100644
--- a/astropy/modeling/tests/test_models.py
+++ b/astropy/modeling/tests/test_models.py
@@ -125,7 +125,9 @@ def test_inconsistent_input_shapes():
     g = Gaussian2D()
     x = np.arange(-1.0, 1, 0.2)
     y = np.arange(-1.0, 1, 0.1)
-    with pytest.raises(ValueError, match="broadcast"):
+    with pytest.raises(
+        ValueError, match="All inputs must have identical shapes or must be scalars"
+    ):
         g(x, y)
 
 
diff --git a/astropy/utils/tests/test_shapes.py b/astropy/utils/tests/test_shapes.py
index b4f1b0456b..1173fd22ac 100644
--- a/astropy/utils/tests/test_shapes.py
+++ b/astropy/utils/tests/test_shapes.py
@@ -6,16 +6,9 @@ from hypothesis import given
 from hypothesis.extra.numpy import basic_indices
 from numpy.testing import assert_equal
 
-from astropy.utils.exceptions import AstropyDeprecationWarning
 from astropy.utils.shapes import check_broadcast, simplify_basic_index, unbroadcast
 
 
-def test_check_broadcast_deprecation():
-    with pytest.warns(AstropyDeprecationWarning):
-        check_broadcast((1,), (2,))
-
-
-@pytest.mark.filterwarnings("ignore")
 def test_check_broadcast():
     assert check_broadcast((10, 1), (3,)) == (10, 3)
     assert check_broadcast((10, 1), (3,), (4, 1, 1, 3)) == (4, 1, 10, 3)