Skip to content

Commit d888dd1

Browse files
fix(config): let an explicitly falsy value on the right-hand side win in merge_config (#4005)
fix(config): let an explicitly falsy value on the right-hand side win merge_config() documents 'take the non-null value, with precedence on rhs', but it used `rhs_value or lhs_value`, which falls back to the left-hand side for any falsy value, not just None. load_catalog(name, **properties) merges the configuration file into the caller's properties, so turning an option off explicitly was silently ignored when the file set it: # .pyiceberg.yaml has s3.path-style-access: "true" load_catalog("prod", **{"s3.path-style-access": False}) # -> 's3.path-style-access' stays 'true' That contradicts the intent of #45, which made the passed-in argument take precedence over the environment. property_as_bool() in the same codebase already distinguishes 'not set' from falsy by testing against (None, ""), so the two now agree. None on the right-hand side still means 'not set' and keeps the left-hand value.
1 parent 4dba4a4 commit d888dd1

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

‎pyiceberg/utils/config.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ def merge_config(lhs: RecursiveDict, rhs: RecursiveDict) -> RecursiveDict:
4242
# If they are both dicts, then we have to go deeper
4343
new_config[rhs_key] = merge_config(lhs_value, rhs_value)
4444
else:
45-
# Take the non-null value, with precedence on rhs
46-
new_config[rhs_key] = rhs_value or lhs_value
45+
# Take the non-null value, with precedence on rhs. `None` means "not set",
46+
# while a falsy value such as `False` or `0` is an explicit setting and wins.
47+
new_config[rhs_key] = rhs_value if rhs_value is not None else lhs_value
4748
else:
4849
# New key
4950
new_config[rhs_key] = rhs_value

‎tests/utils/test_config.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717
import os
18-
from typing import Any
18+
from typing import Any, cast
1919
from unittest import mock
2020

2121
import pytest
@@ -87,6 +87,28 @@ def test_merge_config() -> None:
8787
assert result["common_key"] == rhs["common_key"]
8888

8989

90+
@pytest.mark.parametrize("falsy_value", [False, "", 0])
91+
def test_merge_config_rhs_wins_for_falsy_values(falsy_value: Any) -> None:
92+
"""A value set explicitly on the right-hand side wins even when it is falsy.
93+
94+
`load_catalog(name, **properties)` merges the configuration file into the properties
95+
passed by the caller, so turning an option off explicitly must not fall back to the
96+
value coming from the file.
97+
"""
98+
lhs: RecursiveDict = {"s3.path-style-access": "true"}
99+
rhs: RecursiveDict = {"s3.path-style-access": falsy_value}
100+
result = merge_config(lhs, rhs)
101+
assert result["s3.path-style-access"] == falsy_value
102+
103+
104+
def test_merge_config_lhs_wins_when_rhs_is_none() -> None:
105+
"""`None` on the right-hand side means "not set", so the left-hand side survives."""
106+
lhs: RecursiveDict = {"uri": "https://example.com"}
107+
rhs = cast(RecursiveDict, {"uri": None})
108+
result = merge_config(lhs, rhs)
109+
assert result["uri"] == "https://example.com"
110+
111+
90112
def test_from_configuration_files_get_typed_value(tmp_path_factory: pytest.TempPathFactory) -> None:
91113
config_path = str(tmp_path_factory.mktemp("config"))
92114
with open(f"{config_path}/.pyiceberg.yaml", "w", encoding=UTF8) as file:

0 commit comments

Comments
 (0)