Skip to content

Commit 5be58e2

Browse files
authored
feat(config): Add global config validation (#2664)
1 parent 22d7daf commit 5be58e2

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

cognite/client/config.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from cognite.client._version import __api_subversion__
1010
from cognite.client.credentials import CredentialProvider
11-
from cognite.client.utils._auxiliary import load_resource_to_dict
11+
from cognite.client.utils._auxiliary import is_finite, is_positive, load_resource_to_dict
1212
from cognite.client.utils._concurrency import ConcurrencySettings
1313
from cognite.client.utils._importing import local_import
1414

@@ -78,6 +78,22 @@ def __init__(self) -> None:
7878
self.file_upload_chunk_size: int | None = None
7979
self.silence_feature_preview_warnings: bool = False
8080

81+
def __setattr__(self, name: str, value: Any) -> None:
82+
# Why __setattr__ instead of just more use of @property? It is to avoid breaking a bunch of existing
83+
# inspection code (which would then need special handling). Setting global config options is a rare
84+
# one-off type event, so overhead is no issue.
85+
match name:
86+
case "max_retries" | "max_retries_connect" | "max_retry_backoff" if not is_finite(value):
87+
raise ValueError(f"{name} must be a non-negative integer, got {value!r}")
88+
89+
case "max_connection_pool_size" if not is_positive(value):
90+
raise ValueError(f"max_connection_pool_size must be a positive integer, got {value!r}")
91+
92+
case "file_download_chunk_size" | "file_upload_chunk_size" if value is not None and not is_positive(value):
93+
raise ValueError(f"{name} must be a positive integer or None, got {value!r}")
94+
95+
super().__setattr__(name, value)
96+
8197
@property
8298
def max_workers(self) -> int:
8399
return self._max_workers
@@ -144,7 +160,15 @@ def apply_settings(self, settings: dict[str, Any] | str) -> None:
144160
if not isinstance(loaded["default_client_config"], ClientConfig):
145161
loaded["default_client_config"] = ClientConfig.load(loaded["default_client_config"])
146162

147-
current_settings.update(loaded)
163+
# Snapshot before applying so a mid-loop validation error can be rolled back atomically:
164+
snapshot = {key: getattr(self, key) for key in loaded}
165+
try:
166+
for key, val in loaded.items():
167+
setattr(self, key, val)
168+
except (ValueError, TypeError):
169+
for key, val in snapshot.items():
170+
setattr(self, key, val)
171+
raise
148172
# Deprecated, stored using a property, hence the special treatment:
149173
if maybe_max_workers is not None:
150174
self.max_workers = maybe_max_workers

tests/tests_unit/test_config.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections import OrderedDict
34
from contextlib import nullcontext as does_not_raise
45

56
import pytest
@@ -57,6 +58,60 @@ def test_apply_settings(self, monkeypatch: MonkeyPatch, client_config: ClientCon
5758
assert isinstance(gc.default_client_config.credentials, Token)
5859
assert gc.default_client_config.project == "test-project"
5960

61+
@pytest.mark.parametrize(
62+
"attr, value",
63+
[
64+
("max_retries", 0),
65+
("max_retries", 10),
66+
("max_retries_connect", 0),
67+
("max_retries_connect", 3),
68+
("max_retry_backoff", 0),
69+
("max_retry_backoff", 60),
70+
("max_connection_pool_size", 1),
71+
("max_connection_pool_size", 20),
72+
("file_download_chunk_size", None),
73+
("file_download_chunk_size", 1024),
74+
("file_upload_chunk_size", None),
75+
("file_upload_chunk_size", 65536),
76+
],
77+
)
78+
def test_validated_attrs_valid(self, monkeypatch: MonkeyPatch, attr: str, value: object) -> None:
79+
# raising=True ensures the attribute actually exists on global_config, catching typos in parameters
80+
monkeypatch.setattr(global_config, attr, value, raising=True)
81+
assert getattr(global_config, attr) == value
82+
83+
@pytest.mark.parametrize(
84+
"attr, value, match",
85+
[
86+
("max_retries", -1, "non-negative integer"),
87+
("max_retries", 1.5, "non-negative integer"),
88+
("max_retries", None, "non-negative integer"),
89+
("max_retries_connect", -1, "non-negative integer"),
90+
("max_retry_backoff", -1, "non-negative integer"),
91+
("max_connection_pool_size", 0, "positive integer"),
92+
("max_connection_pool_size", -1, "positive integer"),
93+
("file_download_chunk_size", 0, "positive integer or None"),
94+
("file_download_chunk_size", -1, "positive integer or None"),
95+
("file_upload_chunk_size", 0, "positive integer or None"),
96+
],
97+
)
98+
def test_validated_attrs_invalid(self, attr: str, value: object, match: str) -> None:
99+
with pytest.raises(ValueError, match=match):
100+
setattr(global_config, attr, value)
101+
102+
def test_apply_settings_invalid_value_is_rolled_back(self) -> None:
103+
original_retries = global_config.max_retries
104+
original_retries_connect = global_config.max_retries_connect
105+
# OrderedDict guarantees max_retries (valid) is applied before max_retries_connect (invalid),
106+
# so the rollback has something to actually undo:
107+
with pytest.raises(ValueError, match="non-negative integer"):
108+
global_config.apply_settings(
109+
OrderedDict([("max_retries", original_retries + 1), ("max_retries_connect", -1)])
110+
)
111+
112+
assert global_config.max_retries == original_retries
113+
assert global_config.max_retries_connect == original_retries_connect
114+
60115
def test_load_non_existent_attr(self) -> None:
61116
settings = {
62117
"max_workers": 0, # use a nonsensical value to ensure that it is not applied without assuming other tests kept the default value

0 commit comments

Comments
 (0)