Skip to content

Fix merge override attrs reference issue - #11511

Open
nocdoggo wants to merge 3 commits into
pydata:mainfrom
nocdoggo:fix-merge-attrs-override-final
Open

Fix merge override attrs reference issue#11511
nocdoggo wants to merge 3 commits into
pydata:mainfrom
nocdoggo:fix-merge-attrs-override-final

Conversation

@nocdoggo

Copy link
Copy Markdown

When combine_attrs='override', the merge function was returning a reference to the first object's attrs dictionary instead of a copy. This meant that modifications to the merged object's attrs would affect the original object.\n\nThis change makes merge_attrs return dict(variable_attrs[0]) instead of variable_attrs[0] to ensure a proper copy is made, consistent with other combine_attrs modes like 'no_conflicts' which already create copies.\n\nAlso adds a test to verify that when combine_attrs='override', the merge function creates a copy of the attrs dictionary rather than a reference. This ensures that modifications to the merged dataset's attrs do not affect the original dataset's attrs.\n\nFixes #4629

SWE-bench and others added 3 commits September 10, 2025 15:04
…hen combine_attrs='override', the merge function\ncreates a copy of the attrs dictionary rather than a reference. This ensures\nthat modifications to the merged dataset's attrs do not affect the original\ndataset's attrs.\n\nAlso includes a test for empty input cases.
Copilot AI lite review requested due to automatic review settings August 12, 2026 07:46
@welcome

welcome Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thank you for opening this pull request! It may take us a few days to respond here, so thank you for being patient.
If you have questions, some answers may be found in our contributing guidelines.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an attrs aliasing bug in merge_attrs() when combine_attrs="override" by returning a copy of the first attrs mapping rather than the original object, and attempts to add regression tests to ensure merged results don’t share attrs references with inputs.

Changes:

  • Update merge_attrs(..., "override") to return dict(variable_attrs[0]) (copy) instead of variable_attrs[0] (reference).
  • Add new tests intended to verify override returns independent attrs objects and handles empty/single-input cases.
  • Restructure merge tests (currently replacing the prior test_merge.py contents with a minimal subset).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
xarray/core/merge.py Copy attrs in override mode to avoid returning a shared reference.
xarray/xarray/tests/test_merge_attrs.py Adds override attrs-copy tests, but currently placed in a likely non-collected path and contains incorrect expectations.
xarray/tests/test_merge.py Adds override attrs-copy tests, but currently removes the prior merge test suite (major coverage loss).
Suppressed comments (4)

xarray/xarray/tests/test_merge_attrs.py:20

  • combine_attrs='override' keeps the first object's attrs only (see existing semantics in merge_attrs), so the merged dataset should not pick up key 'd' from the second dataset. As written, this assertion will fail once the test is moved into the collected test tree.
    assert xds3.attrs['a'] == 'b'  # Should take value from first dataset
    assert xds3.attrs['b'] == 'c'
    assert xds3.attrs['d'] == 'e'

xarray/xarray/tests/test_merge_attrs.py:35

  • merge_attrs returns None when given an empty variable_attrs list (independent of combine_attrs). This test currently expects {}, which doesn’t match the actual contract and will fail.
    # Test with empty inputs
    assert xr.core.merge.merge_attrs([], 'override') == {}
    assert xr.core.merge.merge_attrs([{}], 'override') == {}

xarray/xarray/tests/test_merge_attrs.py:2

  • pytest is imported but never used in this module; this will fail linting (ruff/flake8) and should be removed.
import pytest
import xarray as xr

xarray/tests/test_merge.py:3

  • pytest is imported but never used in this test module; this will fail linting and should be removed.
import pytest
import xarray as xr
from xarray.core.merge import merge_attrs

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1 to +5
import pytest
import xarray as xr

def test_merge_override_attrs_copy():
"""Test that merge with combine_attrs='override' creates a copy of attrs"""
Comment on lines 1 to +5
import pytest

import xarray as xr
from xarray.core import dtypes, merge
from xarray.core.merge import MergeError
from xarray.testing import assert_identical

from . import raises_regex
from .test_dataset import create_test_data


class TestMergeInternals:
def test_broadcast_dimension_size(self):
actual = merge.broadcast_dimension_size(
[xr.Variable("x", [1]), xr.Variable("y", [2, 1])]
)
assert actual == {"x": 1, "y": 2}

actual = merge.broadcast_dimension_size(
[xr.Variable(("x", "y"), [[1, 2]]), xr.Variable("y", [2, 1])]
)
assert actual == {"x": 1, "y": 2}

with pytest.raises(ValueError):
merge.broadcast_dimension_size(
[xr.Variable(("x", "y"), [[1, 2]]), xr.Variable("y", [2])]
)


class TestMergeFunction:
def test_merge_arrays(self):
data = create_test_data()
actual = xr.merge([data.var1, data.var2])
expected = data[["var1", "var2"]]
assert actual.identical(expected)

def test_merge_datasets(self):
data = create_test_data()

actual = xr.merge([data[["var1"]], data[["var2"]]])
expected = data[["var1", "var2"]]
assert actual.identical(expected)

actual = xr.merge([data, data])
assert actual.identical(data)

def test_merge_dataarray_unnamed(self):
data = xr.DataArray([1, 2], dims="x")
with raises_regex(ValueError, "without providing an explicit name"):
xr.merge([data])

def test_merge_arrays_attrs_default(self):
var1_attrs = {"a": 1, "b": 2}
var2_attrs = {"a": 1, "c": 3}
expected_attrs = {}

data = create_test_data()
data.var1.attrs = var1_attrs
data.var2.attrs = var2_attrs
actual = xr.merge([data.var1, data.var2])
expected = data[["var1", "var2"]]
expected.attrs = expected_attrs
assert actual.identical(expected)

@pytest.mark.parametrize(
"combine_attrs, var1_attrs, var2_attrs, expected_attrs, expect_exception",
[
(
"no_conflicts",
{"a": 1, "b": 2},
{"a": 1, "c": 3},
{"a": 1, "b": 2, "c": 3},
False,
),
("no_conflicts", {"a": 1, "b": 2}, {}, {"a": 1, "b": 2}, False),
("no_conflicts", {}, {"a": 1, "c": 3}, {"a": 1, "c": 3}, False),
(
"no_conflicts",
{"a": 1, "b": 2},
{"a": 4, "c": 3},
{"a": 1, "b": 2, "c": 3},
True,
),
("drop", {"a": 1, "b": 2}, {"a": 1, "c": 3}, {}, False),
("identical", {"a": 1, "b": 2}, {"a": 1, "b": 2}, {"a": 1, "b": 2}, False),
("identical", {"a": 1, "b": 2}, {"a": 1, "c": 3}, {"a": 1, "b": 2}, True),
(
"override",
{"a": 1, "b": 2},
{"a": 4, "b": 5, "c": 3},
{"a": 1, "b": 2},
False,
),
],
)
def test_merge_arrays_attrs(
self, combine_attrs, var1_attrs, var2_attrs, expected_attrs, expect_exception
):
data = create_test_data()
data.var1.attrs = var1_attrs
data.var2.attrs = var2_attrs
if expect_exception:
with raises_regex(MergeError, "combine_attrs"):
actual = xr.merge([data.var1, data.var2], combine_attrs=combine_attrs)
else:
actual = xr.merge([data.var1, data.var2], combine_attrs=combine_attrs)
expected = data[["var1", "var2"]]
expected.attrs = expected_attrs
assert actual.identical(expected)

def test_merge_dicts_simple(self):
actual = xr.merge([{"foo": 0}, {"bar": "one"}, {"baz": 3.5}])
expected = xr.Dataset({"foo": 0, "bar": "one", "baz": 3.5})
assert actual.identical(expected)

def test_merge_dicts_dims(self):
actual = xr.merge([{"y": ("x", [13])}, {"x": [12]}])
expected = xr.Dataset({"x": [12], "y": ("x", [13])})
assert actual.identical(expected)

def test_merge_error(self):
ds = xr.Dataset({"x": 0})
with pytest.raises(xr.MergeError):
xr.merge([ds, ds + 1])

def test_merge_alignment_error(self):
ds = xr.Dataset(coords={"x": [1, 2]})
other = xr.Dataset(coords={"x": [2, 3]})
with raises_regex(ValueError, "indexes .* not equal"):
xr.merge([ds, other], join="exact")

def test_merge_wrong_input_error(self):
with raises_regex(TypeError, "objects must be an iterable"):
xr.merge([1])
ds = xr.Dataset(coords={"x": [1, 2]})
with raises_regex(TypeError, "objects must be an iterable"):
xr.merge({"a": ds})
with raises_regex(TypeError, "objects must be an iterable"):
xr.merge([ds, 1])

def test_merge_no_conflicts_single_var(self):
ds1 = xr.Dataset({"a": ("x", [1, 2]), "x": [0, 1]})
ds2 = xr.Dataset({"a": ("x", [2, 3]), "x": [1, 2]})
expected = xr.Dataset({"a": ("x", [1, 2, 3]), "x": [0, 1, 2]})
assert expected.identical(xr.merge([ds1, ds2], compat="no_conflicts"))
assert expected.identical(xr.merge([ds2, ds1], compat="no_conflicts"))
assert ds1.identical(xr.merge([ds1, ds2], compat="no_conflicts", join="left"))
assert ds2.identical(xr.merge([ds1, ds2], compat="no_conflicts", join="right"))
expected = xr.Dataset({"a": ("x", [2]), "x": [1]})
assert expected.identical(
xr.merge([ds1, ds2], compat="no_conflicts", join="inner")
)

with pytest.raises(xr.MergeError):
ds3 = xr.Dataset({"a": ("x", [99, 3]), "x": [1, 2]})
xr.merge([ds1, ds3], compat="no_conflicts")

with pytest.raises(xr.MergeError):
ds3 = xr.Dataset({"a": ("y", [2, 3]), "y": [1, 2]})
xr.merge([ds1, ds3], compat="no_conflicts")

def test_merge_no_conflicts_multi_var(self):
data = create_test_data()
data1 = data.copy(deep=True)
data2 = data.copy(deep=True)

expected = data[["var1", "var2"]]
actual = xr.merge([data1.var1, data2.var2], compat="no_conflicts")
assert expected.identical(actual)

data1["var1"][:, :5] = np.nan
data2["var1"][:, 5:] = np.nan
data1["var2"][:4, :] = np.nan
data2["var2"][4:, :] = np.nan
del data2["var3"]

actual = xr.merge([data1, data2], compat="no_conflicts")
assert data.equals(actual)

def test_merge_no_conflicts_preserve_attrs(self):
data = xr.Dataset({"x": ([], 0, {"foo": "bar"})})
actual = xr.merge([data, data])
assert data.identical(actual)

def test_merge_no_conflicts_broadcast(self):
datasets = [xr.Dataset({"x": ("y", [0])}), xr.Dataset({"x": np.nan})]
actual = xr.merge(datasets)
expected = xr.Dataset({"x": ("y", [0])})
assert expected.identical(actual)

datasets = [xr.Dataset({"x": ("y", [np.nan])}), xr.Dataset({"x": 0})]
actual = xr.merge(datasets)
assert expected.identical(actual)


class TestMergeMethod:
def test_merge(self):
data = create_test_data()
ds1 = data[["var1"]]
ds2 = data[["var3"]]
expected = data[["var1", "var3"]]
actual = ds1.merge(ds2)
assert expected.identical(actual)

actual = ds2.merge(ds1)
assert expected.identical(actual)

actual = data.merge(data)
assert data.identical(actual)
actual = data.reset_coords(drop=True).merge(data)
assert data.identical(actual)
actual = data.merge(data.reset_coords(drop=True))
assert data.identical(actual)

with pytest.raises(ValueError):
ds1.merge(ds2.rename({"var3": "var1"}))
with raises_regex(ValueError, "should be coordinates or not"):
data.reset_coords().merge(data)
with raises_regex(ValueError, "should be coordinates or not"):
data.merge(data.reset_coords())

def test_merge_broadcast_equals(self):
ds1 = xr.Dataset({"x": 0})
ds2 = xr.Dataset({"x": ("y", [0, 0])})
actual = ds1.merge(ds2)
assert ds2.identical(actual)

actual = ds2.merge(ds1)
assert ds2.identical(actual)

actual = ds1.copy()
actual.update(ds2)
assert ds2.identical(actual)

ds1 = xr.Dataset({"x": np.nan})
ds2 = xr.Dataset({"x": ("y", [np.nan, np.nan])})
actual = ds1.merge(ds2)
assert ds2.identical(actual)

def test_merge_compat(self):
ds1 = xr.Dataset({"x": 0})
ds2 = xr.Dataset({"x": 1})
for compat in ["broadcast_equals", "equals", "identical", "no_conflicts"]:
with pytest.raises(xr.MergeError):
ds1.merge(ds2, compat=compat)

ds2 = xr.Dataset({"x": [0, 0]})
for compat in ["equals", "identical"]:
with raises_regex(ValueError, "should be coordinates or not"):
ds1.merge(ds2, compat=compat)

ds2 = xr.Dataset({"x": ((), 0, {"foo": "bar"})})
with pytest.raises(xr.MergeError):
ds1.merge(ds2, compat="identical")

with raises_regex(ValueError, "compat=.* invalid"):
ds1.merge(ds2, compat="foobar")

assert ds1.identical(ds1.merge(ds2, compat="override"))

def test_merge_auto_align(self):
ds1 = xr.Dataset({"a": ("x", [1, 2]), "x": [0, 1]})
ds2 = xr.Dataset({"b": ("x", [3, 4]), "x": [1, 2]})
expected = xr.Dataset(
{"a": ("x", [1, 2, np.nan]), "b": ("x", [np.nan, 3, 4])}, {"x": [0, 1, 2]}
)
assert expected.identical(ds1.merge(ds2))
assert expected.identical(ds2.merge(ds1))

expected = expected.isel(x=slice(2))
assert expected.identical(ds1.merge(ds2, join="left"))
assert expected.identical(ds2.merge(ds1, join="right"))

expected = expected.isel(x=slice(1, 2))
assert expected.identical(ds1.merge(ds2, join="inner"))
assert expected.identical(ds2.merge(ds1, join="inner"))

@pytest.mark.parametrize("fill_value", [dtypes.NA, 2, 2.0, {"a": 2, "b": 1}])
def test_merge_fill_value(self, fill_value):
ds1 = xr.Dataset({"a": ("x", [1, 2]), "x": [0, 1]})
ds2 = xr.Dataset({"b": ("x", [3, 4]), "x": [1, 2]})
if fill_value == dtypes.NA:
# if we supply the default, we expect the missing value for a
# float array
fill_value_a = fill_value_b = np.nan
elif isinstance(fill_value, dict):
fill_value_a = fill_value["a"]
fill_value_b = fill_value["b"]
else:
fill_value_a = fill_value_b = fill_value

expected = xr.Dataset(
{"a": ("x", [1, 2, fill_value_a]), "b": ("x", [fill_value_b, 3, 4])},
{"x": [0, 1, 2]},
)
assert expected.identical(ds1.merge(ds2, fill_value=fill_value))
assert expected.identical(ds2.merge(ds1, fill_value=fill_value))
assert expected.identical(xr.merge([ds1, ds2], fill_value=fill_value))

def test_merge_no_conflicts(self):
ds1 = xr.Dataset({"a": ("x", [1, 2]), "x": [0, 1]})
ds2 = xr.Dataset({"a": ("x", [2, 3]), "x": [1, 2]})
expected = xr.Dataset({"a": ("x", [1, 2, 3]), "x": [0, 1, 2]})

assert expected.identical(ds1.merge(ds2, compat="no_conflicts"))
assert expected.identical(ds2.merge(ds1, compat="no_conflicts"))

assert ds1.identical(ds1.merge(ds2, compat="no_conflicts", join="left"))

assert ds2.identical(ds1.merge(ds2, compat="no_conflicts", join="right"))

expected2 = xr.Dataset({"a": ("x", [2]), "x": [1]})
assert expected2.identical(ds1.merge(ds2, compat="no_conflicts", join="inner"))

with pytest.raises(xr.MergeError):
ds3 = xr.Dataset({"a": ("x", [99, 3]), "x": [1, 2]})
ds1.merge(ds3, compat="no_conflicts")

with pytest.raises(xr.MergeError):
ds3 = xr.Dataset({"a": ("y", [2, 3]), "y": [1, 2]})
ds1.merge(ds3, compat="no_conflicts")

def test_merge_dataarray(self):
ds = xr.Dataset({"a": 0})
da = xr.DataArray(data=1, name="b")

assert_identical(ds.merge(da), xr.merge([ds, da]))
from xarray.core.merge import merge_attrs

def test_merge_override_attrs_copy():
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants