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.
- cfenv_kv_sync_python-0.1.0b1/PKG-INFO +87 -0
- cfenv_kv_sync_python-0.1.0b1/README.md +77 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_kv_sync_python.egg-info/PKG-INFO +87 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_kv_sync_python.egg-info/SOURCES.txt +10 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_kv_sync_python.egg-info/dependency_links.txt +1 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_kv_sync_python.egg-info/requires.txt +1 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_kv_sync_python.egg-info/top_level.txt +1 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_sdk/__init__.py +7 -0
- cfenv_kv_sync_python-0.1.0b1/cfenv_sdk/client.py +336 -0
- cfenv_kv_sync_python-0.1.0b1/pyproject.toml +21 -0
- cfenv_kv_sync_python-0.1.0b1/setup.cfg +4 -0
- cfenv_kv_sync_python-0.1.0b1/tests/test_client.py +123 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.31.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cfenv_sdk
|
|
@@ -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,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()
|