cfenv-kv-sync-python 0.1.0b1__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,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfenv-kv-sync-python
3
+ Version: 0.1.0b1
4
+ Summary: Python SDK for cfenv Cloudflare KV environment sync and hot updates
5
+ Author: cfenv contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+
11
+ # cfenv Python SDK
12
+
13
+ Python SDK for reading `cfenv` flat-mode environment values from Cloudflare KV and applying hot updates.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install cfenv-kv-sync-python
19
+ ```
20
+
21
+ For local development:
22
+
23
+ ```bash
24
+ pip install -e /path/to/cloudflare-kv-env/packages/python-sdk
25
+ ```
26
+
27
+ Recommended with `uv`:
28
+
29
+ ```bash
30
+ cd /path/to/cloudflare-kv-env/packages/python-sdk
31
+ uv sync
32
+ uv run python -m unittest discover -s tests -v
33
+ ```
34
+
35
+ ## Basic Usage
36
+
37
+ ```python
38
+ from cfenv_sdk import CfenvClient
39
+
40
+ client = CfenvClient(
41
+ account_id="...",
42
+ api_token="...",
43
+ namespace_id="...",
44
+ project="playheads",
45
+ environment="production",
46
+ )
47
+
48
+ snapshot = client.fetch_flat_env()
49
+ print(snapshot.entries)
50
+ ```
51
+
52
+ ## Export
53
+
54
+ ```python
55
+ dotenv_text = client.export_dotenv()
56
+ json_text = client.export_json()
57
+ ```
58
+
59
+ ## Hot Update
60
+
61
+ ```python
62
+ from cfenv_sdk import CfenvClient
63
+
64
+ client = CfenvClient(
65
+ account_id="...",
66
+ api_token="...",
67
+ namespace_id="...",
68
+ project="playheads",
69
+ environment="production",
70
+ )
71
+
72
+ def on_update(snapshot, reason):
73
+ print("updated", reason, snapshot.metadata.updated_at, snapshot.metadata.entries_count)
74
+ client.apply_to_process_env(overwrite=True)
75
+
76
+ def on_error(err):
77
+ print("hot update error:", err)
78
+
79
+ watcher = client.create_hot_updater(
80
+ on_update=on_update,
81
+ on_error=on_error,
82
+ interval_seconds=30,
83
+ max_interval_seconds=300,
84
+ bootstrap=True,
85
+ )
86
+ watcher.start()
87
+ ```
@@ -0,0 +1,77 @@
1
+ # cfenv Python SDK
2
+
3
+ Python SDK for reading `cfenv` flat-mode environment values from Cloudflare KV and applying hot updates.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install cfenv-kv-sync-python
9
+ ```
10
+
11
+ For local development:
12
+
13
+ ```bash
14
+ pip install -e /path/to/cloudflare-kv-env/packages/python-sdk
15
+ ```
16
+
17
+ Recommended with `uv`:
18
+
19
+ ```bash
20
+ cd /path/to/cloudflare-kv-env/packages/python-sdk
21
+ uv sync
22
+ uv run python -m unittest discover -s tests -v
23
+ ```
24
+
25
+ ## Basic Usage
26
+
27
+ ```python
28
+ from cfenv_sdk import CfenvClient
29
+
30
+ client = CfenvClient(
31
+ account_id="...",
32
+ api_token="...",
33
+ namespace_id="...",
34
+ project="playheads",
35
+ environment="production",
36
+ )
37
+
38
+ snapshot = client.fetch_flat_env()
39
+ print(snapshot.entries)
40
+ ```
41
+
42
+ ## Export
43
+
44
+ ```python
45
+ dotenv_text = client.export_dotenv()
46
+ json_text = client.export_json()
47
+ ```
48
+
49
+ ## Hot Update
50
+
51
+ ```python
52
+ from cfenv_sdk import CfenvClient
53
+
54
+ client = CfenvClient(
55
+ account_id="...",
56
+ api_token="...",
57
+ namespace_id="...",
58
+ project="playheads",
59
+ environment="production",
60
+ )
61
+
62
+ def on_update(snapshot, reason):
63
+ print("updated", reason, snapshot.metadata.updated_at, snapshot.metadata.entries_count)
64
+ client.apply_to_process_env(overwrite=True)
65
+
66
+ def on_error(err):
67
+ print("hot update error:", err)
68
+
69
+ watcher = client.create_hot_updater(
70
+ on_update=on_update,
71
+ on_error=on_error,
72
+ interval_seconds=30,
73
+ max_interval_seconds=300,
74
+ bootstrap=True,
75
+ )
76
+ watcher.start()
77
+ ```
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfenv-kv-sync-python
3
+ Version: 0.1.0b1
4
+ Summary: Python SDK for cfenv Cloudflare KV environment sync and hot updates
5
+ Author: cfenv contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: requests>=2.31.0
10
+
11
+ # cfenv Python SDK
12
+
13
+ Python SDK for reading `cfenv` flat-mode environment values from Cloudflare KV and applying hot updates.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install cfenv-kv-sync-python
19
+ ```
20
+
21
+ For local development:
22
+
23
+ ```bash
24
+ pip install -e /path/to/cloudflare-kv-env/packages/python-sdk
25
+ ```
26
+
27
+ Recommended with `uv`:
28
+
29
+ ```bash
30
+ cd /path/to/cloudflare-kv-env/packages/python-sdk
31
+ uv sync
32
+ uv run python -m unittest discover -s tests -v
33
+ ```
34
+
35
+ ## Basic Usage
36
+
37
+ ```python
38
+ from cfenv_sdk import CfenvClient
39
+
40
+ client = CfenvClient(
41
+ account_id="...",
42
+ api_token="...",
43
+ namespace_id="...",
44
+ project="playheads",
45
+ environment="production",
46
+ )
47
+
48
+ snapshot = client.fetch_flat_env()
49
+ print(snapshot.entries)
50
+ ```
51
+
52
+ ## Export
53
+
54
+ ```python
55
+ dotenv_text = client.export_dotenv()
56
+ json_text = client.export_json()
57
+ ```
58
+
59
+ ## Hot Update
60
+
61
+ ```python
62
+ from cfenv_sdk import CfenvClient
63
+
64
+ client = CfenvClient(
65
+ account_id="...",
66
+ api_token="...",
67
+ namespace_id="...",
68
+ project="playheads",
69
+ environment="production",
70
+ )
71
+
72
+ def on_update(snapshot, reason):
73
+ print("updated", reason, snapshot.metadata.updated_at, snapshot.metadata.entries_count)
74
+ client.apply_to_process_env(overwrite=True)
75
+
76
+ def on_error(err):
77
+ print("hot update error:", err)
78
+
79
+ watcher = client.create_hot_updater(
80
+ on_update=on_update,
81
+ on_error=on_error,
82
+ interval_seconds=30,
83
+ max_interval_seconds=300,
84
+ bootstrap=True,
85
+ )
86
+ watcher.start()
87
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ cfenv_kv_sync_python.egg-info/PKG-INFO
4
+ cfenv_kv_sync_python.egg-info/SOURCES.txt
5
+ cfenv_kv_sync_python.egg-info/dependency_links.txt
6
+ cfenv_kv_sync_python.egg-info/requires.txt
7
+ cfenv_kv_sync_python.egg-info/top_level.txt
8
+ cfenv_sdk/__init__.py
9
+ cfenv_sdk/client.py
10
+ tests/test_client.py
@@ -0,0 +1,7 @@
1
+ from .client import CfenvClient, FlatEnvMetadata, HotUpdateSnapshot
2
+
3
+ __all__ = [
4
+ "CfenvClient",
5
+ "FlatEnvMetadata",
6
+ "HotUpdateSnapshot",
7
+ ]
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Callable, Dict, Optional
11
+
12
+ import requests
13
+
14
+
15
+ class CfenvError(Exception):
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FlatEnvMetadata:
21
+ checksum: str
22
+ updated_at: str
23
+ updated_by: Optional[str]
24
+ entries_count: int
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class HotUpdateSnapshot:
29
+ project: str
30
+ environment: str
31
+ namespace_id: str
32
+ metadata: FlatEnvMetadata
33
+ entries: Dict[str, str]
34
+
35
+
36
+ def _canonicalize_entries(entries: Dict[str, str]) -> str:
37
+ parts = []
38
+ for key in sorted(entries):
39
+ # Match Node checksum logic: `${key}=${JSON.stringify(value)}`
40
+ parts.append(f"{key}={json.dumps(entries[key], ensure_ascii=True, separators=(',', ':'))}")
41
+ return "\n".join(parts)
42
+
43
+
44
+ def checksum_entries(entries: Dict[str, str]) -> str:
45
+ canonical = _canonicalize_entries(entries)
46
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
47
+
48
+
49
+ class CfenvClient:
50
+ def __init__(
51
+ self,
52
+ *,
53
+ account_id: str,
54
+ api_token: str,
55
+ namespace_id: str,
56
+ project: str,
57
+ environment: str,
58
+ key_prefix: str = "cfenv",
59
+ timeout_seconds: float = 15.0,
60
+ max_retries: int = 3,
61
+ retry_base_seconds: float = 0.5,
62
+ session: Optional[requests.Session] = None,
63
+ ) -> None:
64
+ if not account_id:
65
+ raise ValueError("account_id is required")
66
+ if not api_token:
67
+ raise ValueError("api_token is required")
68
+ if not namespace_id:
69
+ raise ValueError("namespace_id is required")
70
+ if not project:
71
+ raise ValueError("project is required")
72
+ if not environment:
73
+ raise ValueError("environment is required")
74
+
75
+ self.account_id = account_id
76
+ self.api_token = api_token
77
+ self.namespace_id = namespace_id
78
+ self.project = project
79
+ self.environment = environment
80
+ self.key_prefix = key_prefix
81
+ self.timeout_seconds = timeout_seconds
82
+ self.max_retries = max_retries
83
+ self.retry_base_seconds = retry_base_seconds
84
+ self.session = session or requests.Session()
85
+
86
+ @property
87
+ def _base_key(self) -> str:
88
+ return f"{self.key_prefix}:{self.project}:{self.environment}"
89
+
90
+ @property
91
+ def _vars_prefix(self) -> str:
92
+ return f"{self._base_key}:vars:"
93
+
94
+ @property
95
+ def _meta_key(self) -> str:
96
+ return f"{self._base_key}:meta"
97
+
98
+ @property
99
+ def _base_api(self) -> str:
100
+ return f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}"
101
+
102
+ def _headers(self) -> Dict[str, str]:
103
+ return {
104
+ "Authorization": f"Bearer {self.api_token}",
105
+ "User-Agent": "cfenv-kv-sync-python/0.1.0",
106
+ }
107
+
108
+ def _request(self, method: str, url: str, **kwargs) -> requests.Response:
109
+ last_error: Optional[Exception] = None
110
+ for attempt in range(self.max_retries + 1):
111
+ try:
112
+ response = self.session.request(
113
+ method=method,
114
+ url=url,
115
+ headers=self._headers(),
116
+ timeout=self.timeout_seconds,
117
+ **kwargs,
118
+ )
119
+ if response.status_code in (408, 429) or response.status_code >= 500:
120
+ if attempt < self.max_retries:
121
+ retry_after = response.headers.get("Retry-After")
122
+ if retry_after is not None:
123
+ wait_seconds = self._parse_retry_after(retry_after)
124
+ else:
125
+ wait_seconds = self.retry_base_seconds * (2 ** attempt)
126
+ time.sleep(wait_seconds)
127
+ continue
128
+ return response
129
+ except requests.RequestException as exc:
130
+ last_error = exc
131
+ if attempt >= self.max_retries:
132
+ break
133
+ time.sleep(self.retry_base_seconds * (2 ** attempt))
134
+
135
+ if last_error is not None:
136
+ raise CfenvError(f"Cloudflare API network error: {last_error}") from last_error
137
+ raise CfenvError("Cloudflare API request failed")
138
+
139
+ @staticmethod
140
+ def _parse_retry_after(value: str) -> float:
141
+ value = value.strip()
142
+ try:
143
+ return max(0.0, float(value))
144
+ except ValueError:
145
+ try:
146
+ retry_time = datetime.strptime(value, "%a, %d %b %Y %H:%M:%S %Z")
147
+ delta = (retry_time - datetime.utcnow()).total_seconds()
148
+ return max(0.0, delta)
149
+ except ValueError:
150
+ return 0.5
151
+
152
+ def _parse_json_envelope(self, response: requests.Response) -> dict:
153
+ try:
154
+ payload = response.json()
155
+ except ValueError as exc:
156
+ raise CfenvError(f"Cloudflare API returned non-JSON response ({response.status_code})") from exc
157
+
158
+ if response.status_code >= 400 or not payload.get("success", False):
159
+ errors = payload.get("errors", [])
160
+ messages = [item.get("message", "") for item in errors if isinstance(item, dict)]
161
+ message = "; ".join([m for m in messages if m]) or f"Cloudflare API request failed ({response.status_code})"
162
+ raise CfenvError(message)
163
+ return payload
164
+
165
+ def _get_value(self, key: str) -> Optional[str]:
166
+ url = f"{self._base_api}/storage/kv/namespaces/{self.namespace_id}/values/{requests.utils.quote(key, safe='')}"
167
+ response = self._request("GET", url)
168
+ if response.status_code == 404:
169
+ return None
170
+ if response.status_code >= 400:
171
+ raise CfenvError(f"Failed to read KV key {key} ({response.status_code})")
172
+ return response.text
173
+
174
+ def _list_keys(self, prefix: str, limit: int = 1000) -> list[str]:
175
+ keys: list[str] = []
176
+ cursor: Optional[str] = None
177
+ while True:
178
+ params = {"prefix": prefix, "limit": str(limit)}
179
+ if cursor:
180
+ params["cursor"] = cursor
181
+ url = f"{self._base_api}/storage/kv/namespaces/{self.namespace_id}/keys"
182
+ response = self._request("GET", url, params=params)
183
+ payload = self._parse_json_envelope(response)
184
+ result = payload.get("result", [])
185
+ for item in result:
186
+ name = item.get("name")
187
+ if isinstance(name, str):
188
+ keys.append(name)
189
+ info = payload.get("result_info") or {}
190
+ cursor = info.get("cursor")
191
+ if not cursor:
192
+ break
193
+ return keys
194
+
195
+ def fetch_flat_env(self) -> HotUpdateSnapshot:
196
+ raw_meta = self._get_value(self._meta_key)
197
+ if raw_meta is None:
198
+ raise CfenvError("No flat metadata found for this target")
199
+
200
+ try:
201
+ meta_obj = json.loads(raw_meta)
202
+ except ValueError as exc:
203
+ raise CfenvError("Invalid flat metadata payload") from exc
204
+
205
+ metadata = FlatEnvMetadata(
206
+ checksum=str(meta_obj.get("checksum", "")),
207
+ updated_at=str(meta_obj.get("updatedAt", "")),
208
+ updated_by=meta_obj.get("updatedBy"),
209
+ entries_count=int(meta_obj.get("entriesCount", 0)),
210
+ )
211
+ if not metadata.checksum:
212
+ raise CfenvError("Flat metadata missing checksum")
213
+
214
+ entries: Dict[str, str] = {}
215
+ for key in sorted(self._list_keys(self._vars_prefix)):
216
+ var_name = key[len(self._vars_prefix) :]
217
+ value = self._get_value(key)
218
+ if value is not None:
219
+ entries[var_name] = value
220
+
221
+ computed = checksum_entries(entries)
222
+ if computed != metadata.checksum:
223
+ raise CfenvError("Flat env checksum mismatch")
224
+
225
+ return HotUpdateSnapshot(
226
+ project=self.project,
227
+ environment=self.environment,
228
+ namespace_id=self.namespace_id,
229
+ metadata=metadata,
230
+ entries=entries,
231
+ )
232
+
233
+ def export_dotenv(self) -> str:
234
+ snapshot = self.fetch_flat_env()
235
+ lines = []
236
+ for key in sorted(snapshot.entries):
237
+ lines.append(f"{key}={json.dumps(snapshot.entries[key], ensure_ascii=True, separators=(',', ':'))}")
238
+ return "\n".join(lines) + "\n"
239
+
240
+ def export_json(self) -> str:
241
+ snapshot = self.fetch_flat_env()
242
+ return json.dumps(snapshot.entries, ensure_ascii=False, indent=2) + "\n"
243
+
244
+ def apply_to_process_env(self, overwrite: bool = True) -> HotUpdateSnapshot:
245
+ snapshot = self.fetch_flat_env()
246
+ for key, value in snapshot.entries.items():
247
+ if not overwrite and key in os.environ:
248
+ continue
249
+ os.environ[key] = value
250
+ return snapshot
251
+
252
+ def create_hot_updater(
253
+ self,
254
+ *,
255
+ on_update: Callable[[HotUpdateSnapshot, str], None],
256
+ on_error: Optional[Callable[[Exception], None]] = None,
257
+ interval_seconds: float = 30.0,
258
+ max_interval_seconds: float = 300.0,
259
+ bootstrap: bool = True,
260
+ ) -> "HotUpdater":
261
+ return HotUpdater(
262
+ client=self,
263
+ on_update=on_update,
264
+ on_error=on_error,
265
+ interval_seconds=interval_seconds,
266
+ max_interval_seconds=max_interval_seconds,
267
+ bootstrap=bootstrap,
268
+ )
269
+
270
+
271
+ class HotUpdater:
272
+ def __init__(
273
+ self,
274
+ *,
275
+ client: CfenvClient,
276
+ on_update: Callable[[HotUpdateSnapshot, str], None],
277
+ on_error: Optional[Callable[[Exception], None]],
278
+ interval_seconds: float,
279
+ max_interval_seconds: float,
280
+ bootstrap: bool,
281
+ ) -> None:
282
+ self.client = client
283
+ self.on_update = on_update
284
+ self.on_error = on_error
285
+ self.interval_seconds = max(1.0, interval_seconds)
286
+ self.max_interval_seconds = max(self.interval_seconds, max_interval_seconds)
287
+ self.bootstrap = bootstrap
288
+ self._running = False
289
+ self._thread: Optional[threading.Thread] = None
290
+ self._stop_event = threading.Event()
291
+ self._last_checksum: Optional[str] = None
292
+ self._consecutive_errors = 0
293
+
294
+ def start(self) -> None:
295
+ if self._running:
296
+ return
297
+ self._running = True
298
+ self._stop_event.clear()
299
+ self._thread = threading.Thread(target=self._run_loop, name="cfenv-hot-updater", daemon=True)
300
+ self._thread.start()
301
+
302
+ def stop(self, timeout: Optional[float] = None) -> None:
303
+ self._running = False
304
+ self._stop_event.set()
305
+ if self._thread is not None:
306
+ self._thread.join(timeout=timeout)
307
+
308
+ def is_running(self) -> bool:
309
+ return self._running
310
+
311
+ def _run_loop(self) -> None:
312
+ delay = self.interval_seconds
313
+ if self.bootstrap:
314
+ self._refresh("initial")
315
+
316
+ while not self._stop_event.wait(delay):
317
+ ok = self._refresh("changed")
318
+ if ok:
319
+ self._consecutive_errors = 0
320
+ delay = self.interval_seconds
321
+ else:
322
+ self._consecutive_errors += 1
323
+ delay = min(self.max_interval_seconds, self.interval_seconds * (2 ** min(self._consecutive_errors, 6)))
324
+
325
+ def _refresh(self, reason: str) -> bool:
326
+ try:
327
+ snapshot = self.client.fetch_flat_env()
328
+ if snapshot.metadata.checksum == self._last_checksum:
329
+ return True
330
+ self._last_checksum = snapshot.metadata.checksum
331
+ self.on_update(snapshot, reason)
332
+ return True
333
+ except Exception as exc: # noqa: BLE001
334
+ if self.on_error is not None:
335
+ self.on_error(exc)
336
+ return False
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cfenv-kv-sync-python"
7
+ version = "0.1.0b1"
8
+ description = "Python SDK for cfenv Cloudflare KV environment sync and hot updates"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "cfenv contributors" }
14
+ ]
15
+ dependencies = [
16
+ "requests>=2.31.0"
17
+ ]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["cfenv_sdk*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,123 @@
1
+ import os
2
+ import unittest
3
+
4
+ from cfenv_sdk.client import CfenvClient, CfenvError, checksum_entries
5
+
6
+
7
+ class FakeClient(CfenvClient):
8
+ def __init__(self):
9
+ super().__init__(
10
+ account_id="a",
11
+ api_token="t",
12
+ namespace_id="n",
13
+ project="demo",
14
+ environment="development",
15
+ )
16
+ self._store = {}
17
+
18
+ @property
19
+ def _base_key(self): # type: ignore[override]
20
+ return "cfenv:demo:development"
21
+
22
+ def _get_value(self, key): # type: ignore[override]
23
+ return self._store.get(key)
24
+
25
+ def _list_keys(self, prefix, limit=1000): # type: ignore[override]
26
+ return [k for k in sorted(self._store) if k.startswith(prefix)]
27
+
28
+
29
+ def set_flat_payload(client: FakeClient, entries: dict[str, str]) -> None:
30
+ checksum = checksum_entries(entries)
31
+ base = "cfenv:demo:development"
32
+ prefix = f"{base}:vars:"
33
+ for key in [k for k in client._store if k.startswith(prefix)]:
34
+ del client._store[key]
35
+
36
+ client._store[f"{base}:meta"] = (
37
+ '{"schema":1,"mode":"flat","checksum":"%s","updatedAt":"2026-01-01T00:00:00Z","updatedBy":"test","entriesCount":%d}'
38
+ % (checksum, len(entries))
39
+ )
40
+ for key, value in entries.items():
41
+ client._store[f"{base}:vars:{key}"] = value
42
+
43
+
44
+ class ClientTests(unittest.TestCase):
45
+ def test_checksum(self):
46
+ entries = {"B": "2", "A": "1"}
47
+ c1 = checksum_entries(entries)
48
+ c2 = checksum_entries({"A": "1", "B": "2"})
49
+ self.assertEqual(c1, c2)
50
+
51
+ def test_fetch_flat_env_success(self):
52
+ client = FakeClient()
53
+ entries = {"A": "1", "B": "2"}
54
+ set_flat_payload(client, entries)
55
+
56
+ snapshot = client.fetch_flat_env()
57
+ self.assertEqual(snapshot.entries, entries)
58
+ self.assertEqual(snapshot.metadata.entries_count, 2)
59
+
60
+ def test_fetch_flat_env_checksum_mismatch(self):
61
+ client = FakeClient()
62
+ base = "cfenv:demo:development"
63
+ client._store[f"{base}:meta"] = (
64
+ '{"schema":1,"mode":"flat","checksum":"bad","updatedAt":"2026-01-01T00:00:00Z","updatedBy":"test","entriesCount":1}'
65
+ )
66
+ client._store[f"{base}:vars:A"] = "1"
67
+
68
+ with self.assertRaises(CfenvError):
69
+ client.fetch_flat_env()
70
+
71
+ def test_export_and_apply_env(self):
72
+ client = FakeClient()
73
+ set_flat_payload(client, {"A": "1", "B": "2"})
74
+
75
+ self.assertEqual(client.export_dotenv(), 'A="1"\nB="2"\n')
76
+ self.assertEqual(client.export_json().strip(), '{\n "A": "1",\n "B": "2"\n}')
77
+
78
+ original = os.environ.get("CFENV_TEST_VAR")
79
+ os.environ["CFENV_TEST_VAR"] = "old"
80
+ set_flat_payload(client, {"CFENV_TEST_VAR": "new"})
81
+
82
+ try:
83
+ client.apply_to_process_env(overwrite=False)
84
+ self.assertEqual(os.environ.get("CFENV_TEST_VAR"), "old")
85
+
86
+ client.apply_to_process_env(overwrite=True)
87
+ self.assertEqual(os.environ.get("CFENV_TEST_VAR"), "new")
88
+ finally:
89
+ if original is None:
90
+ os.environ.pop("CFENV_TEST_VAR", None)
91
+ else:
92
+ os.environ["CFENV_TEST_VAR"] = original
93
+
94
+ def test_hot_updater_refreshes_only_on_checksum_change(self):
95
+ client = FakeClient()
96
+ set_flat_payload(client, {"A": "1"})
97
+
98
+ updates = []
99
+ errors = []
100
+
101
+ updater = client.create_hot_updater(
102
+ on_update=lambda snapshot, reason: updates.append((reason, snapshot.entries.copy())),
103
+ on_error=lambda exc: errors.append(exc),
104
+ bootstrap=False,
105
+ interval_seconds=1.0,
106
+ max_interval_seconds=2.0,
107
+ )
108
+
109
+ self.assertTrue(updater._refresh("initial"))
110
+ self.assertEqual(len(updates), 1)
111
+
112
+ self.assertTrue(updater._refresh("changed"))
113
+ self.assertEqual(len(updates), 1)
114
+
115
+ set_flat_payload(client, {"A": "2"})
116
+ self.assertTrue(updater._refresh("changed"))
117
+ self.assertEqual(len(updates), 2)
118
+ self.assertEqual(updates[1][1]["A"], "2")
119
+ self.assertEqual(len(errors), 0)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()