lium-core 0.1.0__tar.gz

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.
@@ -0,0 +1,56 @@
1
+ name: Build and Publish to PyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version:
7
+ description: 'Version to release'
8
+ required: true
9
+ type: string
10
+
11
+ jobs:
12
+ build:
13
+ name: Build Python distribution
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v4
20
+ with:
21
+ python-version: '3.11'
22
+
23
+ - name: Install dependencies
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install build
27
+
28
+ - name: Build package
29
+ run: python -m build --sdist --wheel --outdir dist/
30
+
31
+ - name: Upload artifact
32
+ uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+
37
+ approve-and-publish:
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ environment: release
41
+ permissions:
42
+ contents: read
43
+ id-token: write
44
+
45
+ steps:
46
+ - name: Download artifact
47
+ uses: actions/download-artifact@v4
48
+ with:
49
+ name: dist
50
+ path: dist/
51
+
52
+ - name: Publish package distributions to PyPI
53
+ uses: pypa/gh-action-pypi-publish@release/v1
54
+ with:
55
+ verbose: true
56
+ print-hash: true
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .idea/
9
+ .jbeval/
10
+ *.lock
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: lium-core
3
+ Version: 0.1.0
4
+ Summary: Shared core library for Lium platform
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pydantic>=2.0.0
7
+ Requires-Dist: requests>=2.31.0
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "lium-core"
3
+ version = "0.1.0"
4
+ description = "Shared core library for Lium platform"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "pydantic>=2.0.0",
8
+ "requests>=2.31.0",
9
+ ]
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src/lium_core"]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.0.0",
21
+ ]
File without changes
@@ -0,0 +1,9 @@
1
+ from lium_core.shared_config.client import SharedConfigClient
2
+ from lium_core.shared_config.defaults import DEFAULT_SHARED_CONFIG
3
+ from lium_core.shared_config.model import SharedConfig
4
+
5
+ __all__ = [
6
+ "SharedConfig",
7
+ "SharedConfigClient",
8
+ "DEFAULT_SHARED_CONFIG",
9
+ ]
@@ -0,0 +1,54 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+
5
+ import requests
6
+
7
+ from lium_core.shared_config.defaults import DEFAULT_SHARED_CONFIG
8
+ from lium_core.shared_config.model import SharedConfig
9
+ from lium_core.shared_config.utils import dict_diff
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SharedConfigClient:
15
+ def __init__(self, api_url: str, refresh_interval: int = 60):
16
+ self._api_url = api_url
17
+ self._refresh_interval = refresh_interval
18
+ self._lock = threading.Lock()
19
+ self._config: SharedConfig = self._fetch() or DEFAULT_SHARED_CONFIG
20
+ self._running = True
21
+ self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
22
+ self._thread.start()
23
+
24
+ def __getattr__(self, name: str):
25
+ """Delegate attribute access to the underlying frozen SharedConfig."""
26
+ return getattr(self._config, name)
27
+
28
+ def _fetch(self) -> SharedConfig | None:
29
+ """Fetch config from API, return None on failure."""
30
+ try:
31
+ response = requests.get(self._api_url, timeout=10)
32
+ response.raise_for_status()
33
+ return SharedConfig.model_validate(response.json())
34
+ except Exception:
35
+ logger.warning("Failed to fetch shared config from %s", self._api_url, exc_info=True)
36
+ return None
37
+
38
+ def _refresh_loop(self) -> None:
39
+ """Background daemon thread: sleep + fetch."""
40
+ logger.info("Started shared config refresh loop")
41
+ while self._running:
42
+ time.sleep(self._refresh_interval)
43
+ new_config = self._fetch()
44
+ with self._lock:
45
+ if new_config and self._config != new_config:
46
+ for change in dict_diff(self._config.model_dump(), new_config.model_dump()):
47
+ logger.info("Config changed %s", change)
48
+ self._config = new_config
49
+ else:
50
+ logger.debug("Shared config unchanged")
51
+
52
+ def stop(self) -> None:
53
+ """Stop background refresh."""
54
+ self._running = False
@@ -0,0 +1,124 @@
1
+ from lium_core.shared_config.model import SharedConfig
2
+
3
+ DEFAULT_SHARED_CONFIG = SharedConfig(
4
+ machine_prices={
5
+ "NVIDIA B300 SXM6 AC": 3.67,
6
+ "NVIDIA B200": 2.99,
7
+ "NVIDIA H200": 1.90,
8
+ "NVIDIA H200 NVL": 1.67,
9
+ "NVIDIA H100 80GB HBM3": 1.26,
10
+ "NVIDIA H100 NVL": 1.11,
11
+ "NVIDIA H100 PCIe": 1.11,
12
+ "NVIDIA H800 80GB HBM3": 0.88,
13
+ "NVIDIA H800 NVL": 0.80,
14
+ "NVIDIA H800 PCIe": 0.80,
15
+ "NVIDIA GeForce RTX 5090": 0.17,
16
+ "NVIDIA GeForce RTX 4090": 0.14,
17
+ "NVIDIA GeForce RTX 4090 D": 0.11,
18
+ "NVIDIA RTX 4000 Ada Generation": 0.16,
19
+ "NVIDIA RTX 6000 Ada Generation": 0.31,
20
+ "NVIDIA RTX PRO 6000 Blackwell Server Edition": 0.77,
21
+ "NVIDIA RTX PRO 6000 Blackwell Workstation Edition": 0.84,
22
+ "NVIDIA L4": 0.11,
23
+ "NVIDIA L40S": 0.34,
24
+ "NVIDIA L40": 0.29,
25
+ "NVIDIA RTX 2000 Ada Generation": 0.07,
26
+ "NVIDIA A100 80GB PCIe": 0.36,
27
+ "NVIDIA A100-SXM4-80GB": 0.43,
28
+ "NVIDIA RTX A6000": 0.24,
29
+ "NVIDIA RTX A5000": 0.16,
30
+ "NVIDIA RTX A4500": 0.13,
31
+ "NVIDIA RTX A4000": 0.12,
32
+ "NVIDIA A40": 0.12,
33
+ "NVIDIA A30": 0.10,
34
+ "NVIDIA GeForce RTX 3090": 0.13,
35
+ },
36
+ required_deposit_amount={
37
+ "NVIDIA B300 SXM6 AC": 0.274,
38
+ "NVIDIA B200": 0.223,
39
+ "NVIDIA H200": 0.158,
40
+ "NVIDIA H200 NVL": 0.131,
41
+ "NVIDIA H100 80GB HBM3": 0.103,
42
+ "NVIDIA H100 NVL": 0.086,
43
+ "NVIDIA H100 PCIe": 0.086,
44
+ "NVIDIA H800 80GB HBM3": 0.051,
45
+ "NVIDIA H800 NVL": 0.045,
46
+ "NVIDIA H800 PCIe": 0.045,
47
+ "NVIDIA GeForce RTX 5090": 0.014,
48
+ "NVIDIA GeForce RTX 4090": 0.010,
49
+ "NVIDIA GeForce RTX 4090 D": 0.008,
50
+ "NVIDIA RTX PRO 6000 Blackwell Server Edition": 0.0425,
51
+ "NVIDIA RTX PRO 6000 Blackwell Workstation Edition": 0.0459,
52
+ "NVIDIA RTX 6000 Ada Generation": 0.017,
53
+ "NVIDIA L4": 0.008,
54
+ "NVIDIA L40S": 0.027,
55
+ "NVIDIA L40": 0.024,
56
+ "NVIDIA A100 80GB PCIe": 0.027,
57
+ "NVIDIA A100-SXM4-80GB": 0.031,
58
+ "NVIDIA RTX A6000": 0.018,
59
+ "NVIDIA RTX A5000": 0.009,
60
+ "NVIDIA RTX A4500": 0.008,
61
+ "NVIDIA RTX A4000": 0.008,
62
+ "NVIDIA GeForce RTX 3090": 0.008,
63
+ },
64
+ gpu_architectures={
65
+ # Blackwell (sm_100/120)
66
+ "NVIDIA B200": {"arch": "blackwell", "min_cuda": 12.8, "compute_cap": "sm_100"},
67
+ "NVIDIA GeForce RTX 5090": {"arch": "blackwell", "min_cuda": 12.8, "compute_cap": "sm_120"},
68
+ # Hopper (sm_90)
69
+ "NVIDIA H100 80GB HBM3": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
70
+ "NVIDIA H100 NVL": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
71
+ "NVIDIA H100 PCIe": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
72
+ "NVIDIA H200": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
73
+ "NVIDIA H800 80GB HBM3": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
74
+ "NVIDIA H800 NVL": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
75
+ "NVIDIA H800 PCIe": {"arch": "hopper", "min_cuda": 11.8, "optimal_cuda": 12.2, "compute_cap": "sm_90"},
76
+ # Ada Lovelace (sm_89)
77
+ "NVIDIA GeForce RTX 4090": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
78
+ "NVIDIA GeForce RTX 4090 D": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
79
+ "NVIDIA L4": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
80
+ "NVIDIA L40": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
81
+ "NVIDIA L40S": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
82
+ "NVIDIA RTX 2000 Ada Generation": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
83
+ "NVIDIA RTX 4000 Ada Generation": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
84
+ "NVIDIA RTX 6000 Ada Generation": {"arch": "ada", "min_cuda": 11.8, "compute_cap": "sm_89"},
85
+ # Ampere (sm_86)
86
+ "NVIDIA A100 80GB PCIe": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
87
+ "NVIDIA A100-SXM4-80GB": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
88
+ "NVIDIA A30": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
89
+ "NVIDIA A40": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
90
+ "NVIDIA RTX A4000": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
91
+ "NVIDIA RTX A4500": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
92
+ "NVIDIA RTX A5000": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
93
+ "NVIDIA RTX A6000": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
94
+ "NVIDIA GeForce RTX 3090": {"arch": "ampere", "min_cuda": 11.0, "compute_cap": "sm_86"},
95
+ },
96
+ driver_cuda_map={
97
+ 450: 11.0,
98
+ 470: 11.4,
99
+ 495: 11.5,
100
+ 510: 11.6,
101
+ 515: 11.7,
102
+ 525: 12.0,
103
+ 530: 12.1,
104
+ 535: 12.2,
105
+ 545: 12.3,
106
+ 550: 12.4,
107
+ 555: 12.5,
108
+ 560: 12.6,
109
+ 565: 12.7,
110
+ 570: 12.8,
111
+ 575: 12.8,
112
+ 580: 13.0,
113
+ 590: 13.1,
114
+ },
115
+ machine_max_price_rate=3.0,
116
+ machine_min_price_rate=0.5,
117
+ rental_fees_rate=0.9,
118
+ collateral_days=7,
119
+ collateral_contract_address="0x7DCCb5659c70Ce2104A9bb79E9E257473ECbe628",
120
+ bittensor_netuid=51,
121
+ volume_gb_hour_price_usd=0.00005,
122
+ max_initial_port_count=200,
123
+ total_burn_emission=0.91,
124
+ )
@@ -0,0 +1,22 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class SharedConfig(BaseModel):
5
+ model_config = ConfigDict(frozen=True)
6
+
7
+ # Dicts
8
+ machine_prices: dict[str, float]
9
+ required_deposit_amount: dict[str, float]
10
+ gpu_architectures: dict[str, dict]
11
+ driver_cuda_map: dict[int, float]
12
+
13
+ # Scalars
14
+ machine_max_price_rate: float
15
+ machine_min_price_rate: float
16
+ rental_fees_rate: float
17
+ collateral_days: int
18
+ collateral_contract_address: str
19
+ bittensor_netuid: int
20
+ volume_gb_hour_price_usd: float
21
+ max_initial_port_count: int
22
+ total_burn_emission: float
@@ -0,0 +1,13 @@
1
+ def dict_diff(old: dict, new: dict, prefix: str = "") -> list[str]:
2
+ """Return list of human-readable strings describing differences."""
3
+ changes: list[str] = []
4
+ all_keys = old.keys() | new.keys()
5
+ for key in sorted(all_keys):
6
+ path = f"{prefix}.{key}" if prefix else key
7
+ old_val = old.get(key)
8
+ new_val = new.get(key)
9
+ if isinstance(old_val, dict) and isinstance(new_val, dict):
10
+ changes.extend(dict_diff(old_val, new_val, path))
11
+ elif old_val != new_val:
12
+ changes.append(f"[{path}]: {old_val} -> {new_val}")
13
+ return changes
@@ -0,0 +1,248 @@
1
+ import logging
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pydantic
5
+ import pytest
6
+ import requests
7
+
8
+ from lium_core.shared_config.client import SharedConfigClient
9
+ from lium_core.shared_config.defaults import DEFAULT_SHARED_CONFIG
10
+ from lium_core.shared_config.model import SharedConfig
11
+ from lium_core.shared_config.utils import dict_diff
12
+
13
+ API_URL = "http://fake-api/config"
14
+
15
+ SAMPLE_CONFIG_DATA = DEFAULT_SHARED_CONFIG.model_dump()
16
+
17
+ ALTERED_CONFIG_DATA = {
18
+ **SAMPLE_CONFIG_DATA,
19
+ "rental_fees_rate": 0.75,
20
+ "collateral_days": 14,
21
+ }
22
+
23
+
24
+ def _make_response(json_data: dict, status_code: int = 200) -> MagicMock:
25
+ resp = MagicMock(spec=requests.Response)
26
+ resp.status_code = status_code
27
+ resp.json.return_value = json_data
28
+ resp.raise_for_status.return_value = None
29
+ if status_code >= 400:
30
+ resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
31
+ return resp
32
+
33
+
34
+ def _build_client(mock_get: MagicMock) -> SharedConfigClient:
35
+ """Create client with patched threading so no background loop runs."""
36
+ with patch("lium_core.shared_config.client.threading"):
37
+ client = SharedConfigClient(api_url=API_URL, refresh_interval=60)
38
+ return client
39
+
40
+
41
+ # ==================== Model tests ====================
42
+
43
+
44
+ def test_shared_config_is_frozen() -> None:
45
+ with pytest.raises(pydantic.ValidationError):
46
+ DEFAULT_SHARED_CONFIG.rental_fees_rate = 0.5
47
+
48
+
49
+ def test_default_shared_config_has_all_fields() -> None:
50
+ assert len(DEFAULT_SHARED_CONFIG.machine_prices) > 0
51
+ assert len(DEFAULT_SHARED_CONFIG.required_deposit_amount) > 0
52
+ assert len(DEFAULT_SHARED_CONFIG.gpu_architectures) > 0
53
+ assert len(DEFAULT_SHARED_CONFIG.driver_cuda_map) > 0
54
+ assert DEFAULT_SHARED_CONFIG.machine_max_price_rate == 3.0
55
+ assert DEFAULT_SHARED_CONFIG.machine_min_price_rate == 0.5
56
+ assert DEFAULT_SHARED_CONFIG.rental_fees_rate == 0.9
57
+ assert DEFAULT_SHARED_CONFIG.collateral_days == 7
58
+ assert DEFAULT_SHARED_CONFIG.collateral_contract_address == "0x7DCCb5659c70Ce2104A9bb79E9E257473ECbe628"
59
+ assert DEFAULT_SHARED_CONFIG.bittensor_netuid == 51
60
+ assert DEFAULT_SHARED_CONFIG.volume_gb_hour_price_usd == 0.00005
61
+ assert DEFAULT_SHARED_CONFIG.max_initial_port_count == 200
62
+ assert DEFAULT_SHARED_CONFIG.total_burn_emission == 0.91
63
+
64
+
65
+ def test_shared_config_serializes_to_json() -> None:
66
+ data = DEFAULT_SHARED_CONFIG.model_dump()
67
+ assert isinstance(data, dict)
68
+ assert "machine_prices" in data
69
+ assert "gpu_architectures" in data
70
+ assert isinstance(data["gpu_architectures"]["NVIDIA B200"]["arch"], str)
71
+
72
+
73
+ # ==================== Utils tests (dict_diff) ====================
74
+
75
+
76
+ @pytest.mark.parametrize(
77
+ "old, new, expected",
78
+ [
79
+ pytest.param({}, {}, [], id="both_empty"),
80
+ pytest.param({"a": 1}, {"a": 1}, [], id="no_changes"),
81
+ pytest.param(
82
+ {"a": 1},
83
+ {"a": 2},
84
+ ["[a]: 1 -> 2"],
85
+ id="top_level_change",
86
+ ),
87
+ pytest.param(
88
+ {"a": {"b": 1}},
89
+ {"a": {"b": 2}},
90
+ ["[a.b]: 1 -> 2"],
91
+ id="nested_change",
92
+ ),
93
+ pytest.param(
94
+ {"a": {"b": {"c": 1}}},
95
+ {"a": {"b": {"c": 99}}},
96
+ ["[a.b.c]: 1 -> 99"],
97
+ id="deep_nested_change",
98
+ ),
99
+ pytest.param(
100
+ {},
101
+ {"a": 1},
102
+ ["[a]: None -> 1"],
103
+ id="key_added",
104
+ ),
105
+ pytest.param(
106
+ {"a": 1},
107
+ {},
108
+ ["[a]: 1 -> None"],
109
+ id="key_removed",
110
+ ),
111
+ pytest.param(
112
+ {"a": 1, "b": 2, "c": 3},
113
+ {"a": 10, "b": 20, "c": 30},
114
+ ["[a]: 1 -> 10", "[b]: 2 -> 20", "[c]: 3 -> 30"],
115
+ id="multiple_changes_sorted",
116
+ ),
117
+ pytest.param(
118
+ {"a": {"nested": 1}},
119
+ {"a": "flat"},
120
+ ["[a]: {'nested': 1} -> flat"],
121
+ id="dict_becomes_scalar",
122
+ ),
123
+ ],
124
+ )
125
+ def test_dict_diff(old: dict, new: dict, expected: list[str]) -> None:
126
+ assert dict_diff(old, new) == expected
127
+
128
+
129
+ # ==================== Client tests: _fetch ====================
130
+
131
+
132
+ def test_fetch_success() -> None:
133
+ with patch("lium_core.shared_config.client.requests.get", return_value=_make_response(SAMPLE_CONFIG_DATA)):
134
+ client = _build_client(MagicMock())
135
+
136
+ assert isinstance(client._config, SharedConfig)
137
+ assert client._config == DEFAULT_SHARED_CONFIG
138
+
139
+
140
+ def test_fetch_http_error() -> None:
141
+ with patch("lium_core.shared_config.client.requests.get", return_value=_make_response({}, status_code=500)):
142
+ client = _build_client(MagicMock())
143
+
144
+ assert client._config == DEFAULT_SHARED_CONFIG
145
+
146
+
147
+ def test_fetch_network_error() -> None:
148
+ with patch("lium_core.shared_config.client.requests.get", side_effect=requests.ConnectionError("no network")):
149
+ client = _build_client(MagicMock())
150
+
151
+ assert client._config == DEFAULT_SHARED_CONFIG
152
+
153
+
154
+ # ==================== Client tests: __init__ ====================
155
+
156
+
157
+ def test_init_with_successful_fetch() -> None:
158
+ with patch("lium_core.shared_config.client.requests.get", return_value=_make_response(ALTERED_CONFIG_DATA)):
159
+ client = _build_client(MagicMock())
160
+
161
+ assert client._config.rental_fees_rate == 0.75
162
+ assert client._config.collateral_days == 14
163
+
164
+
165
+ def test_init_fallback_to_default() -> None:
166
+ with patch("lium_core.shared_config.client.requests.get", side_effect=Exception("boom")):
167
+ client = _build_client(MagicMock())
168
+
169
+ assert client._config is DEFAULT_SHARED_CONFIG
170
+
171
+
172
+ # ==================== Client tests: _refresh_loop ====================
173
+
174
+
175
+ def test_refresh_updates_config_on_change() -> None:
176
+ mock_get = MagicMock(return_value=_make_response(SAMPLE_CONFIG_DATA))
177
+ with patch("lium_core.shared_config.client.requests.get", mock_get):
178
+ client = _build_client(mock_get)
179
+
180
+ assert client._config == DEFAULT_SHARED_CONFIG
181
+
182
+ mock_get.return_value = _make_response(ALTERED_CONFIG_DATA)
183
+
184
+ def _stop_after_one_iteration(_interval: int) -> None:
185
+ client._running = False
186
+
187
+ with (
188
+ patch("lium_core.shared_config.client.requests.get", mock_get),
189
+ patch("lium_core.shared_config.client.time.sleep", side_effect=_stop_after_one_iteration),
190
+ ):
191
+ client._running = True
192
+ client._refresh_loop()
193
+
194
+ assert client._config.rental_fees_rate == 0.75
195
+ assert client._config.collateral_days == 14
196
+
197
+
198
+ def test_refresh_skips_on_same_config(caplog: pytest.LogCaptureFixture) -> None:
199
+ mock_get = MagicMock(return_value=_make_response(SAMPLE_CONFIG_DATA))
200
+ with patch("lium_core.shared_config.client.requests.get", mock_get):
201
+ client = _build_client(mock_get)
202
+
203
+ def _stop_after_one_iteration(_interval: int) -> None:
204
+ client._running = False
205
+
206
+ with (
207
+ patch("lium_core.shared_config.client.requests.get", mock_get),
208
+ patch("lium_core.shared_config.client.time.sleep", side_effect=_stop_after_one_iteration),
209
+ caplog.at_level(logging.DEBUG, logger="lium_core.shared_config.client"),
210
+ ):
211
+ client._running = True
212
+ client._refresh_loop()
213
+
214
+ assert "unchanged" in caplog.text
215
+
216
+
217
+ def test_refresh_skips_on_fetch_failure() -> None:
218
+ mock_get = MagicMock(return_value=_make_response(SAMPLE_CONFIG_DATA))
219
+ with patch("lium_core.shared_config.client.requests.get", mock_get):
220
+ client = _build_client(mock_get)
221
+
222
+ original_config = client._config
223
+ mock_get.side_effect = requests.ConnectionError("down")
224
+
225
+ def _stop_after_one_iteration(_interval: int) -> None:
226
+ client._running = False
227
+
228
+ with (
229
+ patch("lium_core.shared_config.client.requests.get", mock_get),
230
+ patch("lium_core.shared_config.client.time.sleep", side_effect=_stop_after_one_iteration),
231
+ ):
232
+ client._running = True
233
+ client._refresh_loop()
234
+
235
+ assert client._config is original_config
236
+
237
+
238
+ # ==================== Client tests: __getattr__ ====================
239
+
240
+
241
+ def test_getattr_delegates_to_config() -> None:
242
+ mock_get = MagicMock(return_value=_make_response(SAMPLE_CONFIG_DATA))
243
+ with patch("lium_core.shared_config.client.requests.get", mock_get):
244
+ client = _build_client(mock_get)
245
+
246
+ assert client.bittensor_netuid == DEFAULT_SHARED_CONFIG.bittensor_netuid
247
+ assert client.rental_fees_rate == DEFAULT_SHARED_CONFIG.rental_fees_rate
248
+ assert client.machine_prices == DEFAULT_SHARED_CONFIG.machine_prices