diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5f3a243ae..ae959ed6946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This release is compatible with NumPy 2.5. * Added `--includes` and `--include-dir` options to the `dpnp` CLI [#2916](https://github.com/IntelPython/dpnp/pull/2916) * Added `dpnp-config.cmake` to make `find_package(Dpnp)` work out of the box, and an example which uses it [#2941](https://github.com/IntelPython/dpnp/pull/2941) * Added implementation of `dpnp.lib.stride_tricks.as_strided` [#2991](https://github.com/IntelPython/dpnp/pull/2991) +* Added `dpnp.tensor.broadcast_shapes` to align with the 2025.12 version of the Python array API [#3009](https://github.com/IntelPython/dpnp/pull/3009) ### Changed diff --git a/dpnp/dpnp_iface_manipulation.py b/dpnp/dpnp_iface_manipulation.py index b96e5593c2a..bab98391057 100644 --- a/dpnp/dpnp_iface_manipulation.py +++ b/dpnp/dpnp_iface_manipulation.py @@ -1126,7 +1126,25 @@ def broadcast_shapes(*args): """ - return numpy.broadcast_shapes(*args) + shapes = [] + for sh in args: + # a bare integer is treated as a one-dimensional shape + if not isinstance(sh, (tuple, list)): + sh = (sh,) + + new_sh = [] + for dim in sh: + if isinstance(dim, bool): + raise TypeError( + "'bool' object cannot be interpreted as an integer" + ) + + dim = operator.index(dim) + if dim < 0: + raise ValueError("negative dimensions are not allowed") + new_sh.append(dim) + shapes.append(tuple(new_sh)) + return dpt.broadcast_shapes(*shapes) # pylint: disable=redefined-outer-name diff --git a/dpnp/tensor/__init__.py b/dpnp/tensor/__init__.py index 0118e04f7ab..c2ad5bdaff9 100644 --- a/dpnp/tensor/__init__.py +++ b/dpnp/tensor/__init__.py @@ -174,6 +174,7 @@ ) from ._manipulation_functions import ( broadcast_arrays, + broadcast_shapes, broadcast_to, concat, expand_dims, @@ -282,6 +283,7 @@ "bitwise_right_shift", "bitwise_xor", "broadcast_arrays", + "broadcast_shapes", "broadcast_to", "can_cast", "cbrt", diff --git a/dpnp/tensor/_manipulation_functions.py b/dpnp/tensor/_manipulation_functions.py index d12422fdefa..4f443feb136 100644 --- a/dpnp/tensor/_manipulation_functions.py +++ b/dpnp/tensor/_manipulation_functions.py @@ -248,6 +248,32 @@ def broadcast_arrays(*args): return [broadcast_to(X, shape) for X in args] +def broadcast_shapes(*shapes): + """broadcast_shapes(*shapes) + + Broadcasts one or more shapes against one another. + + Args: + shapes (Tuple[int, ...]): an arbitrary number of shapes to be + broadcasted against one another. Each shape must be a tuple of + integers. + + Returns: + Tuple[int, ...]: + The broadcasted shape resulting from broadcasting the input + shapes against one another. + + Raises: + TypeError: if a shape contains a non-integer dimension. + ValueError: if the input shapes are not broadcast-compatible. + """ + if len(shapes) == 0: + return () + + normalized = [tuple(operator.index(dim) for dim in sh) for sh in shapes] + return _broadcast_shape_impl(normalized) + + def broadcast_to(X, /, shape): """broadcast_to(x, shape) diff --git a/dpnp/tests/tensor/test_usm_ndarray_manipulation.py b/dpnp/tests/tensor/test_usm_ndarray_manipulation.py index bb0a99a537f..1ea36cd4f01 100644 --- a/dpnp/tests/tensor/test_usm_ndarray_manipulation.py +++ b/dpnp/tests/tensor/test_usm_ndarray_manipulation.py @@ -469,6 +469,42 @@ def test_broadcast_arrays_no_args(): dpt.broadcast_arrays() +@pytest.mark.parametrize( + "shapes", + [ + [(1,), (3,)], + [(1, 3), (3, 3)], + [(3, 1), (3, 3)], + [(1, 3), (3, 1)], + [(6, 7), (5, 6, 1), (7,), (5, 1, 7)], + [(1, 2), (3, 1), (3, 2)], + [(1, 0), (0, 1)], + [()], + [(5,)], + ], +) +def test_broadcast_shapes(shapes): + expected = np.broadcast_shapes(*shapes) + result = dpt.broadcast_shapes(*shapes) + assert result == expected + + +def test_broadcast_shapes_no_args(): + # matches numpy.broadcast_shapes() returning an empty shape + assert dpt.broadcast_shapes() == () + + +def test_broadcast_shapes_raises(): + with pytest.raises(ValueError): + dpt.broadcast_shapes((2, 3), (4, 5)) + + +@pytest.mark.parametrize("shapes", [[(2.0,)], [(1.5,), (2,)], [(2,), (3.0, 1)]]) +def test_broadcast_shapes_non_integer_dim(shapes): + with pytest.raises(TypeError): + dpt.broadcast_shapes(*shapes) + + def test_flip_axis_incorrect(): q = get_queue_or_skip() diff --git a/dpnp/tests/test_manipulation.py b/dpnp/tests/test_manipulation.py index 9eedcfe275d..a33af5594bc 100644 --- a/dpnp/tests/test_manipulation.py +++ b/dpnp/tests/test_manipulation.py @@ -308,7 +308,7 @@ def test_no_copy(self): assert_array_equal(b, a) -class TestBroadcast: +class TestBroadcastShapes: @pytest.mark.parametrize( "shape", [ @@ -332,6 +332,31 @@ def test_broadcast_shapes(self, shape): result = dpnp.broadcast_shapes(*shape) assert_equal(result, expected) + @pytest.mark.parametrize( + "shape", + [ + [1, 2], + [(3, 1), 3], + [1, (5, 1), 5], + ], + ) + def test_scalar(self, shape): + expected = numpy.broadcast_shapes(*shape) + result = dpnp.broadcast_shapes(*shape) + assert_equal(result, expected) + + @pytest.mark.parametrize("xp", [dpnp, numpy]) + @pytest.mark.parametrize("shape", [[(-1,)], [(2, -3), (2, 3)], [-1, 2]]) + def test_negative_dim(self, xp, shape): + with pytest.raises(ValueError, match="negative dimensions"): + xp.broadcast_shapes(*shape) + + @pytest.mark.parametrize("xp", [dpnp, numpy]) + @pytest.mark.parametrize("shape", [[(2.0,)], [(True, 2)], [2.5]]) + def test_non_integer_dim(self, xp, shape): + with pytest.raises(TypeError, match="integer"): + xp.broadcast_shapes(*shape) + class TestCopyTo: testdata = []