ceph-devstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. ceph_devstack/Dockerfile.selinux +20 -0
  2. ceph_devstack/__init__.py +187 -0
  3. ceph_devstack/ceph_devstack.pp +0 -0
  4. ceph_devstack/ceph_devstack.te +127 -0
  5. ceph_devstack/cli.py +64 -0
  6. ceph_devstack/config.toml +24 -0
  7. ceph_devstack/exec.py +93 -0
  8. ceph_devstack/host.py +154 -0
  9. ceph_devstack/logging.conf +30 -0
  10. ceph_devstack/py.typed +0 -0
  11. ceph_devstack/requirements.py +277 -0
  12. ceph_devstack/resources/__init__.py +115 -0
  13. ceph_devstack/resources/ceph/__init__.py +266 -0
  14. ceph_devstack/resources/ceph/containers.py +419 -0
  15. ceph_devstack/resources/ceph/exceptions.py +3 -0
  16. ceph_devstack/resources/ceph/requirements.py +90 -0
  17. ceph_devstack/resources/ceph/utils.py +45 -0
  18. ceph_devstack/resources/container.py +171 -0
  19. ceph_devstack/resources/misc.py +15 -0
  20. ceph_devstack-0.1.0.dist-info/METADATA +222 -0
  21. ceph_devstack-0.1.0.dist-info/RECORD +44 -0
  22. ceph_devstack-0.1.0.dist-info/WHEEL +5 -0
  23. ceph_devstack-0.1.0.dist-info/entry_points.txt +2 -0
  24. ceph_devstack-0.1.0.dist-info/licenses/LICENSE +21 -0
  25. ceph_devstack-0.1.0.dist-info/top_level.txt +2 -0
  26. tests/__init__.py +0 -0
  27. tests/conftest.py +9 -0
  28. tests/resources/__init__.py +0 -0
  29. tests/resources/ceph/__init__.py +0 -0
  30. tests/resources/ceph/fixtures/__init__.py +0 -0
  31. tests/resources/ceph/fixtures/testnode-config.toml +2 -0
  32. tests/resources/ceph/test_cephdevstack_core.py +459 -0
  33. tests/resources/ceph/test_devstack.py +182 -0
  34. tests/resources/ceph/test_env_vars.py +110 -0
  35. tests/resources/ceph/test_requirements_ceph.py +262 -0
  36. tests/resources/ceph/test_ssh_keypair.py +109 -0
  37. tests/resources/ceph/test_testnode.py +36 -0
  38. tests/resources/test_container.py +247 -0
  39. tests/resources/test_misc.py +46 -0
  40. tests/resources/test_podmanresource.py +59 -0
  41. tests/test_config.py +120 -0
  42. tests/test_deep_merge.py +71 -0
  43. tests/test_parse_args.py +228 -0
  44. tests/test_requirements_core.py +495 -0
@@ -0,0 +1,247 @@
1
+ import json
2
+ import os
3
+ import pytest
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import patch, AsyncMock
7
+
8
+ from ceph_devstack import config
9
+ from ceph_devstack.resources.container import Container
10
+ from .test_podmanresource import (
11
+ TestPodmanResource as _TestPodmanResource,
12
+ )
13
+
14
+
15
+ class _TestContainerBase:
16
+ @pytest.fixture
17
+ def cls(self):
18
+ return Container
19
+
20
+ def setup_method(self):
21
+ config["containers"]["container"] = {"image": "example.com/image:latest"}
22
+
23
+
24
+ class TestContainerResource(_TestPodmanResource, _TestContainerBase):
25
+ @pytest.fixture
26
+ def cls(self):
27
+ return Container
28
+
29
+ @pytest.fixture(
30
+ scope="class", params=["build", "create", "start", "stop", "remove"]
31
+ )
32
+ def action(self, request):
33
+ return request.param
34
+
35
+ async def test_action_calls_cmd_with_correct_args(self, cls, action):
36
+ if action == "build":
37
+ config["containers"][cls.__name__.lower()]["repo"] = "/repo_path"
38
+ obj = cls()
39
+ with patch.object(obj, "cmd") as mock_cmd:
40
+ mock_proc = AsyncMock()
41
+ mock_cmd.return_value = mock_proc
42
+ action_cmd = "rm" if action == "remove" else action
43
+ await getattr(obj, action)()
44
+ if action == "create":
45
+ assert len(mock_cmd.call_args_list) == 2
46
+ assert "inspect" in mock_cmd.call_args_list[0][0][0]
47
+ assert action_cmd in mock_cmd.call_args_list[-1][0][0]
48
+ else:
49
+ mock_cmd.assert_called_once()
50
+ call_args = mock_cmd.call_args[0][0]
51
+ assert action_cmd in call_args
52
+
53
+ async def test_empty_cmd_skips_action(self, cls, action):
54
+ with patch.object(cls, "cmd"):
55
+ obj = cls()
56
+ setattr(obj, f"{action}_cmd", [])
57
+ await getattr(obj, action)()
58
+ obj.cmd.assert_not_awaited()
59
+
60
+ async def test_action_cmd_called_with_stream_output(self, cls, action):
61
+ if action == "remove":
62
+ pytest.skip("remove action doesn't stream output")
63
+ if action == "build":
64
+ config["containers"][cls.__name__.lower()]["repo"] = "/repo_path"
65
+ with patch.object(cls, "cmd") as mock_cmd:
66
+ obj = cls()
67
+ await getattr(obj, action)()
68
+ _, kwargs = mock_cmd.call_args
69
+ assert kwargs.get("stream_output") is True
70
+
71
+ async def test_build_action_skips_when_no_repo(self, cls):
72
+ config["containers"][cls.__name__.lower()]["repo"] = ""
73
+ obj = cls()
74
+ with patch.object(obj, "cmd") as mock_cmd:
75
+ await obj.build()
76
+ mock_cmd.assert_not_called()
77
+
78
+ async def test_pull_action_skips_localhost_images(self, cls):
79
+ config["containers"]["container"]["image"] = "localhost/image:latest"
80
+ obj = cls()
81
+ with patch.object(obj, "cmd") as mock_cmd:
82
+ await obj.pull()
83
+ mock_cmd.assert_not_called()
84
+
85
+ @pytest.mark.parametrize(
86
+ "output,rc,expected", ([b"12345", 0, 12345], [b"error", 1, 1])
87
+ )
88
+ async def test_wait_returns_output_on_success(self, cls, output, rc, expected):
89
+ obj = cls()
90
+ with patch.object(obj, "cmd") as mock_cmd:
91
+ mock_proc = AsyncMock()
92
+ mock_proc.communicate = AsyncMock(return_value=(output, b""))
93
+ mock_proc.returncode = rc
94
+ mock_cmd.return_value = mock_proc
95
+ result = await obj.wait()
96
+ assert expected == result
97
+
98
+ async def test_wait_action_returns_error_code_on_failure(self, cls):
99
+ obj = cls()
100
+ with patch.object(obj, "cmd") as mock_cmd:
101
+ mock_proc = AsyncMock()
102
+ mock_proc.communicate = AsyncMock(return_value=(b"", b"error occurred"))
103
+ mock_proc.returncode = 130
104
+ mock_cmd.return_value = mock_proc
105
+ result = await obj.wait()
106
+ assert result == 130
107
+
108
+
109
+ class TestContainerInit(_TestContainerBase):
110
+ def test_init_sets_env_vars_from_class(self, cls):
111
+ Container.env_vars = {"TEST_VAR": "default_value"}
112
+ obj = cls()
113
+ assert "TEST_VAR" in obj.env_vars
114
+ assert obj.env_vars["TEST_VAR"] == "default_value"
115
+
116
+ def test_init_overrides_env_vars_from_environment(self, cls):
117
+ Container.env_vars = {"TEST_VAR": "default"}
118
+ with patch.dict(os.environ, {"TEST_VAR": "env_value"}):
119
+ obj = cls()
120
+ assert obj.env_vars["TEST_VAR"] == "env_value"
121
+
122
+ def test_init_does_not_override_missing_env_vars(self, cls):
123
+ Container.env_vars = {"TEST_VAR": "default"}
124
+ with patch.dict(os.environ, {}, clear=False):
125
+ if "TEST_VAR" in os.environ:
126
+ del os.environ["TEST_VAR"]
127
+ obj = cls()
128
+ assert obj.env_vars["TEST_VAR"] == "default"
129
+
130
+
131
+ class TestContainerExists(_TestContainerBase):
132
+ @pytest.mark.parametrize("rc,res", ([0, True], [1, False]))
133
+ async def test_exists(self, cls, rc, res):
134
+ with patch.object(cls, "cmd"):
135
+ obj = cls()
136
+ obj.cmd.return_value = AsyncMock()
137
+ obj.cmd.return_value.wait.return_value = rc
138
+ assert await obj.exists() == res
139
+
140
+
141
+ class TestContainerRunning(_TestContainerBase):
142
+ async def test_is_running_yes(self, cls):
143
+ with patch.object(cls, "cmd"):
144
+ obj = cls()
145
+ output_obj = [{"State": {"Status": "running"}}]
146
+ m_read = AsyncMock(return_value=json.dumps(output_obj))
147
+ m_stdout = AsyncMock(read=m_read)
148
+ obj.cmd.return_value = AsyncMock(
149
+ stdout=m_stdout,
150
+ returncode=0,
151
+ )
152
+ obj.cmd.return_value.wait.return_value = 0
153
+ assert await obj.is_running() is True
154
+
155
+ async def test_is_running_no_bc_status(self, cls):
156
+ with patch.object(cls, "cmd"):
157
+ obj = cls()
158
+ output_obj = [{"State": {"Status": "crashed"}}]
159
+ m_read = AsyncMock(return_value=json.dumps(output_obj))
160
+ m_stdout = AsyncMock(read=m_read)
161
+ obj.cmd.return_value = AsyncMock(
162
+ stdout=m_stdout,
163
+ returncode=0,
164
+ )
165
+ obj.cmd.return_value.wait.return_value = 0
166
+ assert await obj.is_running() is False
167
+
168
+ async def test_is_running_no_bc_dne(self, cls):
169
+ with patch.object(cls, "cmd"):
170
+ obj = cls()
171
+ obj.cmd.return_value = AsyncMock(returncode=1)
172
+ assert await obj.is_running() is False
173
+
174
+
175
+ class TestContainerImageName(_TestContainerBase):
176
+ def test_image_name_default_returns_class_name(self, cls):
177
+ obj = cls()
178
+ assert obj.image_name == "container"
179
+
180
+ def test_image_name_returns_custom_when_set(self, cls):
181
+ obj = cls()
182
+ obj._image_name = "custom-image"
183
+ assert obj.image_name == "custom-image"
184
+
185
+
186
+ class TestContainerImageTag(_TestContainerBase):
187
+ def test_image_tag_with_colon(self, cls):
188
+ obj = cls()
189
+ config["containers"]["container"] = {"image": "example.com/image:v1.0"}
190
+ assert obj.image_tag == "v1.0"
191
+
192
+ def test_image_tag_without_colon(self, cls):
193
+ obj = cls()
194
+ config["containers"]["container"] = {"image": "example.com/image"}
195
+ assert obj.image_tag == "latest"
196
+
197
+
198
+ class TestContainerImage(_TestContainerBase):
199
+ def test_image_returns_config_image_when_no_repo(self, cls):
200
+ obj = cls()
201
+ assert obj.image == "example.com/image:latest"
202
+
203
+ def test_image_returns_localhost_when_repo_exists(self, cls):
204
+ config["containers"]["container"]["repo"] = "/path/to/repo"
205
+ obj = cls()
206
+ obj._image_name = "my-image"
207
+ assert obj.image == "localhost/my-image"
208
+
209
+
210
+ class TestContainerCwd(_TestContainerBase):
211
+ def test_cwd_returns_repo_when_exists(self, cls):
212
+ obj = cls()
213
+ with patch.object(type(obj), "repo", Path("/path/to/repo")):
214
+ assert obj.cwd == Path("/path/to/repo")
215
+
216
+ def test_cwd_returns_dot_when_no_repo(self, cls):
217
+ obj = cls()
218
+ assert obj.cwd == "."
219
+
220
+
221
+ class TestContainerAddEnvToArgs(_TestContainerBase):
222
+ def test_add_env_to_args_inserts_env_vars(self, cls):
223
+ obj = cls()
224
+ obj.env_vars = {"KEY1": "value1", "KEY2": "value2"}
225
+ args = ["podman", "run", "image"]
226
+ result = obj.add_env_to_args(args)
227
+ assert result[-1] == "image" # last element is preserved
228
+ assert "-e" in result
229
+ assert "KEY1=value1" in result
230
+ assert "KEY2=value2" in result
231
+
232
+ def test_add_env_to_args_skips_empty_values(self, cls):
233
+ obj = cls()
234
+ obj.env_vars = {"KEY1": "value1", "KEY2": None, "KEY3": ""}
235
+ args = ["podman", "run", "image"]
236
+ result = obj.add_env_to_args(args)
237
+ assert "KEY1=value1" in result
238
+ assert "KEY2=" not in result
239
+ assert "KEY3=" not in result
240
+
241
+ def test_add_env_to_args_preserves_order(self, cls):
242
+ obj = cls()
243
+ obj.env_vars = {"KEY": "value"}
244
+ args = ["podman", "run", "image"]
245
+ result = obj.add_env_to_args(args)
246
+ assert result[-1] == "image"
247
+ assert result.index("-e") < result.index("image")
@@ -0,0 +1,46 @@
1
+ import pytest
2
+
3
+ from unittest.mock import patch, AsyncMock
4
+
5
+ from ceph_devstack.resources.misc import Network, Secret
6
+
7
+ from .test_podmanresource import (
8
+ TestPodmanResource as _TestPodmanResource,
9
+ )
10
+
11
+
12
+ class TestMiscResource(_TestPodmanResource):
13
+ @pytest.fixture(scope="class", params=[Network, Secret])
14
+ def cls(self, request):
15
+ return request.param
16
+
17
+ @pytest.fixture(scope="class", params=["create", "exists", "remove"])
18
+ def action(self, request):
19
+ return request.param
20
+
21
+ async def test_exists_means_inspect(self, cls):
22
+ obj = cls()
23
+ assert "inspect" in obj.exists_cmd
24
+
25
+ @pytest.mark.parametrize("rc,expected", [[0, True], [1, False]])
26
+ async def test_exists(self, cls, rc, expected):
27
+ obj = cls()
28
+ with patch.object(obj, "cmd") as mock_cmd:
29
+ mock_proc = AsyncMock()
30
+ mock_proc.wait = AsyncMock(return_value=rc)
31
+ mock_cmd.return_value = mock_proc
32
+ result = await obj.exists()
33
+ assert result is expected
34
+
35
+ @pytest.mark.parametrize("exists", [True, False])
36
+ async def test_create_when_not_exists(self, cls, exists):
37
+ obj = cls()
38
+ with (
39
+ patch.object(obj, "exists", return_value=exists),
40
+ patch.object(obj, "cmd") as mock_cmd,
41
+ ):
42
+ mock_proc = AsyncMock()
43
+ mock_proc.wait = AsyncMock(return_value=(0 if exists else 1))
44
+ mock_cmd.return_value = mock_proc
45
+ await obj.create()
46
+ assert len(mock_cmd.call_args_list) == (0 if exists else 1)
@@ -0,0 +1,59 @@
1
+ import pytest
2
+
3
+ from pathlib import Path
4
+ from subprocess import CalledProcessError
5
+ from unittest.mock import patch
6
+
7
+
8
+ from ceph_devstack.resources import PodmanResource
9
+
10
+
11
+ class TestPodmanResource:
12
+ @pytest.fixture(scope="class")
13
+ def cls(self):
14
+ return PodmanResource
15
+
16
+ @pytest.fixture(scope="class", params=["create", "remove"])
17
+ def action(self, request):
18
+ return request.param
19
+
20
+ def test_name(self, cls):
21
+ obj = cls()
22
+ assert obj.name == cls.__name__.lower()
23
+ obj = cls(name="foo")
24
+ assert obj.name == "foo"
25
+
26
+ def test_format_cmd(self, cls):
27
+ obj = cls(name="pr")
28
+ assert "name" in obj.cmd_vars
29
+ res = obj.format_cmd(["foo", "{name}", "bar", "x{name}x"])
30
+ assert res == ["foo", "pr", "bar", "xprx"]
31
+
32
+ def test_cmd_vars_contains_name(self, cls):
33
+ assert "name" in cls.cmd_vars
34
+
35
+ def test_repr(self, cls):
36
+ obj = cls()
37
+ class_name = cls.__name__
38
+ assert repr(obj) == f"{class_name}()"
39
+ obj = cls(name="foo")
40
+ assert repr(obj) == f'{class_name}(name="foo")'
41
+
42
+ async def test_apply(self, cls, action):
43
+ with patch.object(cls, action):
44
+ obj = cls()
45
+ await obj.apply(action)
46
+ method = getattr(obj, action)
47
+ method.assert_awaited_once()
48
+
49
+ async def test_cmd(self, cls):
50
+ with patch("ceph_devstack.host.host.arun") as m_arun:
51
+ obj = cls()
52
+ await obj.cmd(["0"])
53
+ print(m_arun.await_args_list)
54
+ m_arun.assert_awaited_once_with(["0"], cwd=Path("."), stream_output=False)
55
+
56
+ async def test_cmd_failed(self, cls):
57
+ obj = cls()
58
+ with pytest.raises(CalledProcessError):
59
+ await obj.cmd(["false"], check=True)
tests/test_config.py ADDED
@@ -0,0 +1,120 @@
1
+ import tomlkit
2
+
3
+ from ceph_devstack import config, Config
4
+
5
+
6
+ class TestConfigDump:
7
+ def test_config_dump_returns_string(self):
8
+ result = config.dump()
9
+ assert isinstance(result, str)
10
+
11
+ def test_config_dump_is_valid_toml(self):
12
+ result = config.dump()
13
+ parsed = tomlkit.parse(result)
14
+ assert isinstance(parsed, tomlkit.TOMLDocument)
15
+
16
+ def test_config_contents_basic(self):
17
+ result = config.dump()
18
+ requires = [
19
+ "containers",
20
+ "data_dir",
21
+ "postgres",
22
+ "beanstalk",
23
+ "paddles",
24
+ "pulpito",
25
+ "testnode",
26
+ "teuthology",
27
+ "archive",
28
+ ]
29
+ for item in requires:
30
+ assert item in result
31
+
32
+
33
+ class TestConfigGetValue:
34
+ def test_get_value_simple_key(self):
35
+ result = config.get_value("data_dir")
36
+ assert isinstance(result, str)
37
+
38
+ def test_get_value_nested_count(self):
39
+ result = config.get_value("containers.testnode.count")
40
+ assert result == "3"
41
+
42
+ def test_get_value_nested_loop_device_size(self):
43
+ result = config.get_value("containers.testnode.loop_device_size")
44
+ assert result == "5G"
45
+
46
+ def test_get_value_nested_image(self):
47
+ result = config.get_value("containers.testnode.image")
48
+ assert "quay.io/ceph-infra/teuthology-testnode:main" in result
49
+
50
+ def test_get_value_returns_string_for_int(self):
51
+ result = config.get_value("containers.testnode.count")
52
+ assert result == "3"
53
+ assert isinstance(result, str)
54
+
55
+
56
+ class TestConfigSet:
57
+ def test_set_value_simple_key(self, tmp_path):
58
+ test_config = Config()
59
+ config_file = tmp_path / "test_config.toml"
60
+ config_file.write_text("")
61
+ test_config.load(config_file)
62
+ test_config.set_value("test_key", "test_value")
63
+ assert test_config["test_key"] == "test_value"
64
+
65
+ def test_set_value_nested_key(self, tmp_path):
66
+ test_config = Config()
67
+ config_file = tmp_path / "test_config.toml"
68
+ config_file.write_text("")
69
+ test_config.load(config_file)
70
+ test_config.set_value("test_section.test_key", "test_value")
71
+ assert test_config["test_section"]["test_key"] == "test_value"
72
+
73
+ def test_set_value_updates_user_obj(self, tmp_path):
74
+ test_config = Config()
75
+ config_file = tmp_path / "test_config.toml"
76
+ config_file.write_text("")
77
+ test_config.load(config_file)
78
+ test_config.set_value("new_key", "new_value")
79
+ assert "new_key" in test_config.user_obj
80
+
81
+ def test_set_value_creates_intermediate_sections(self, tmp_path):
82
+ test_config = Config()
83
+ config_file = tmp_path / "test_config.toml"
84
+ config_file.write_text("")
85
+ test_config.load(config_file)
86
+ test_config.set_value("deep.nested.key", "value")
87
+ assert test_config.user_obj["deep"]["nested"]["key"] == "value"
88
+
89
+ def test_set_value_overrides_existing(self, tmp_path):
90
+ test_config = Config()
91
+ config_file = tmp_path / "test_config.toml"
92
+ config_file.write_text("")
93
+ test_config.load(config_file)
94
+ original_count = test_config["containers"]["testnode"]["count"]
95
+ new_count = original_count + 2
96
+ test_config.set_value("containers.testnode.count", str(new_count))
97
+ assert test_config["containers"]["testnode"]["count"] != original_count
98
+ assert test_config["containers"]["testnode"]["count"] == new_count
99
+
100
+
101
+ class TestConfigDefaults:
102
+ def test_config_defaults(self):
103
+ assert config == {
104
+ "data_dir": "~/.local/share/ceph-devstack",
105
+ "containers": {
106
+ "archive": {"image": "python:alpine"},
107
+ "beanstalk": {"image": "quay.io/ceph-infra/teuthology-beanstalkd:main"},
108
+ "paddles": {"image": "quay.io/ceph-infra/paddles:main"},
109
+ "postgres": {
110
+ "image": "quay.io/ceph-infra/teuthology-postgresql:latest"
111
+ },
112
+ "pulpito": {"image": "quay.io/ceph-infra/pulpito:main"},
113
+ "testnode": {
114
+ "count": 3,
115
+ "loop_device_size": "5G",
116
+ "image": "quay.io/ceph-infra/teuthology-testnode:main",
117
+ },
118
+ "teuthology": {"image": "quay.io/ceph-infra/teuthology-dev:main"},
119
+ },
120
+ }
@@ -0,0 +1,71 @@
1
+ from ceph_devstack import deep_merge
2
+
3
+
4
+ class TestDeepMerge:
5
+ def test_deep_merge_empty_maps(self):
6
+ result = deep_merge()
7
+ assert result == {}
8
+
9
+ def test_deep_merge_single_map(self):
10
+ m = {"a": 1, "b": 2}
11
+ result = deep_merge(m)
12
+ assert result == m
13
+
14
+ def test_deep_merge_two_maps_no_overlap(self):
15
+ m1 = {"a": 1}
16
+ m2 = {"b": 2}
17
+ result = deep_merge(m1, m2)
18
+ assert result == {"a": 1, "b": 2}
19
+
20
+ def test_deep_merge_two_maps_with_overlap(self):
21
+ m1 = {"a": 1, "b": 2}
22
+ m2 = {"b": 3, "c": 4}
23
+ result = deep_merge(m1, m2)
24
+ assert result == {"a": 1, "b": 3, "c": 4}
25
+
26
+ def test_deep_merge_nested_dicts(self):
27
+ m1 = {"a": {"x": 1, "y": 2}}
28
+ m2 = {"a": {"y": 3, "z": 4}}
29
+ result = deep_merge(m1, m2)
30
+ assert result == {"a": {"x": 1, "y": 3, "z": 4}}
31
+
32
+ def test_deep_merge_three_maps(self):
33
+ m1 = {"a": 1}
34
+ m2 = {"b": 2}
35
+ m3 = {"c": 3}
36
+ result = deep_merge(m1, m2, m3)
37
+ assert result == {"a": 1, "b": 2, "c": 3}
38
+
39
+ def test_deep_merge_nested_override(self):
40
+ m1 = {"outer": {"inner": "default", "keep": "value"}}
41
+ m2 = {"outer": {"inner": "override"}}
42
+ result = deep_merge(m1, m2)
43
+ assert result["outer"]["inner"] == "override"
44
+ assert result["outer"]["keep"] == "value"
45
+
46
+ def test_deep_merge_with_none_value(self):
47
+ m1 = {"a": 1}
48
+ m2 = {"b": None}
49
+ result = deep_merge(m1, m2)
50
+ assert result == {"a": 1, "b": None}
51
+
52
+ def test_deep_merge_with_list_values(self):
53
+ m1 = {"a": [1, 2, 3]}
54
+ m2 = {"a": [4, 5]}
55
+ result = deep_merge(m1, m2)
56
+ assert result["a"] == [4, 5]
57
+
58
+ def test_deep_merge_does_not_modify_original_maps(self):
59
+ m1 = {"a": {"x": 1}}
60
+ m2 = {"a": {"y": 2}}
61
+ m1_copy = {"a": {"x": 1}}
62
+ m2_copy = {"a": {"y": 2}}
63
+ deep_merge(m1, m2)
64
+ assert m1 == m1_copy
65
+ assert m2 == m2_copy
66
+
67
+ def test_deep_merge_with_different_types(self):
68
+ m1 = {"a": 1}
69
+ m2 = {"a": "string"}
70
+ result = deep_merge(m1, m2)
71
+ assert result["a"] == "string"