agileconfig-python 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.
- agileconfig_python-0.1.0/LICENSE +21 -0
- agileconfig_python-0.1.0/PKG-INFO +98 -0
- agileconfig_python-0.1.0/README.md +54 -0
- agileconfig_python-0.1.0/agileconfig_python/__init__.py +5 -0
- agileconfig_python-0.1.0/agileconfig_python/config_loader.py +291 -0
- agileconfig_python-0.1.0/agileconfig_python.egg-info/PKG-INFO +98 -0
- agileconfig_python-0.1.0/agileconfig_python.egg-info/SOURCES.txt +10 -0
- agileconfig_python-0.1.0/agileconfig_python.egg-info/dependency_links.txt +1 -0
- agileconfig_python-0.1.0/agileconfig_python.egg-info/requires.txt +2 -0
- agileconfig_python-0.1.0/agileconfig_python.egg-info/top_level.txt +1 -0
- agileconfig_python-0.1.0/pyproject.toml +44 -0
- agileconfig_python-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgileConfig contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agileconfig-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for AgileConfig with WebSocket updates and environment fallback
|
|
5
|
+
Author: Wei Zheng
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 AgileConfig contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Classifier: Development Status :: 3 - Alpha
|
|
28
|
+
Classifier: Intended Audience :: Developers
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: requests>=2.31
|
|
42
|
+
Requires-Dist: websockets<16,>=15
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# agileconfig-python
|
|
46
|
+
|
|
47
|
+
Python client for [AgileConfig](https://github.com/dotnetcore/AgileConfig). The client loads configuration over HTTP, listens for reload notifications over WebSocket, and falls back to `os.environ` when the server is unavailable.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install agileconfig-python
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from agileconfig_python import AgileConfigLoader
|
|
59
|
+
|
|
60
|
+
loader = AgileConfigLoader(
|
|
61
|
+
url="wss://config.example.com/ws",
|
|
62
|
+
app_id="my-app",
|
|
63
|
+
secret="your-secret",
|
|
64
|
+
env="DEV",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
database_host = str(loader.get_var("host", prefix="database"))
|
|
68
|
+
loader.stop()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The initial HTTP request populates the cache. When AgileConfig publishes a reload event, the cache is refreshed. If the server cannot be reached within the configured timeout, `get_var_value` reads the variable name from the process environment and ignores its prefix.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
- `AgileConfigLoader(url, app_id, secret, env)` creates the singleton loader and starts its listener.
|
|
76
|
+
- `loader.get_var_value(var_name, prefix="")` returns the current value, or an environment fallback.
|
|
77
|
+
- `loader.get_var(var_name, prefix="")` returns a lazy string-like proxy that reads the latest value when converted with `str()`.
|
|
78
|
+
- `loader.stop()` closes the WebSocket and stops the listener thread.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m pip install --upgrade pip build
|
|
84
|
+
python -m pip install -e .
|
|
85
|
+
python -m unittest discover -s unit_tests -v
|
|
86
|
+
python -m build
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The live integration tests embedded in the source require an AgileConfig server and are not run by the offline unit-test command.
|
|
90
|
+
|
|
91
|
+
## Release checklist
|
|
92
|
+
|
|
93
|
+
1. Update `version` in `pyproject.toml`.
|
|
94
|
+
2. Run the unit tests and `python -m build`.
|
|
95
|
+
3. Inspect both artifacts with `python -m twine check dist/*`.
|
|
96
|
+
4. Upload to TestPyPI and install the uploaded version in a clean environment.
|
|
97
|
+
5. Create a Git tag matching the version, for example `v0.1.0`.
|
|
98
|
+
6. Publish to PyPI with a trusted publisher or `twine upload dist/*`.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# agileconfig-python
|
|
2
|
+
|
|
3
|
+
Python client for [AgileConfig](https://github.com/dotnetcore/AgileConfig). The client loads configuration over HTTP, listens for reload notifications over WebSocket, and falls back to `os.environ` when the server is unavailable.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install agileconfig-python
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from agileconfig_python import AgileConfigLoader
|
|
15
|
+
|
|
16
|
+
loader = AgileConfigLoader(
|
|
17
|
+
url="wss://config.example.com/ws",
|
|
18
|
+
app_id="my-app",
|
|
19
|
+
secret="your-secret",
|
|
20
|
+
env="DEV",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
database_host = str(loader.get_var("host", prefix="database"))
|
|
24
|
+
loader.stop()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The initial HTTP request populates the cache. When AgileConfig publishes a reload event, the cache is refreshed. If the server cannot be reached within the configured timeout, `get_var_value` reads the variable name from the process environment and ignores its prefix.
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
- `AgileConfigLoader(url, app_id, secret, env)` creates the singleton loader and starts its listener.
|
|
32
|
+
- `loader.get_var_value(var_name, prefix="")` returns the current value, or an environment fallback.
|
|
33
|
+
- `loader.get_var(var_name, prefix="")` returns a lazy string-like proxy that reads the latest value when converted with `str()`.
|
|
34
|
+
- `loader.stop()` closes the WebSocket and stops the listener thread.
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install --upgrade pip build
|
|
40
|
+
python -m pip install -e .
|
|
41
|
+
python -m unittest discover -s unit_tests -v
|
|
42
|
+
python -m build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The live integration tests embedded in the source require an AgileConfig server and are not run by the offline unit-test command.
|
|
46
|
+
|
|
47
|
+
## Release checklist
|
|
48
|
+
|
|
49
|
+
1. Update `version` in `pyproject.toml`.
|
|
50
|
+
2. Run the unit tests and `python -m build`.
|
|
51
|
+
3. Inspect both artifacts with `python -m twine check dist/*`.
|
|
52
|
+
4. Upload to TestPyPI and install the uploaded version in a clean environment.
|
|
53
|
+
5. Create a Git tag matching the version, for example `v0.1.0`.
|
|
54
|
+
6. Publish to PyPI with a trusted publisher or `twine upload dist/*`.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from websockets import ConnectionClosedError
|
|
11
|
+
from websockets.sync.client import connect as ws_connect
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
16
|
+
)
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
AGILE_CONFIG_TIMEOUT = 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigStrWithUpdate:
|
|
23
|
+
def __init__(self, agile_config_loader, prefix: str, var_name : str):
|
|
24
|
+
self.__prefix = prefix
|
|
25
|
+
self.__var_name = var_name
|
|
26
|
+
self.__agile_config_loader = agile_config_loader
|
|
27
|
+
|
|
28
|
+
def __str__(self):
|
|
29
|
+
return self.__agile_config_loader.get_var_value(self.__var_name, self.__prefix)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgileConfigLoader:
|
|
33
|
+
"""
|
|
34
|
+
Loads configuration from AgileConfig server via WebSocket.
|
|
35
|
+
Falls back to environment variables if AgileConfig is unavailable.
|
|
36
|
+
|
|
37
|
+
Singleton that starts background WebSocket listening on instantiation.
|
|
38
|
+
|
|
39
|
+
Requires environment variables:
|
|
40
|
+
- AGILE_CONFIG_URL: WebSocket URL (e.g., "ws://localhost:5000/ws" or "wss://config.example.com/ws")
|
|
41
|
+
- AGILE_CONFIG_APP_ID: Application ID for AgileConfig
|
|
42
|
+
- AGILE_CONFIG_SECRET: Secret key for AgileConfig
|
|
43
|
+
- AGILE_CONFIG_ENV: Environment (default: "DEV")
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_instance = None
|
|
47
|
+
|
|
48
|
+
def __new__(cls, *args, **kwargs):
|
|
49
|
+
"""Singleton pattern: ensure only one instance exists."""
|
|
50
|
+
if not cls._instance:
|
|
51
|
+
cls._instance = super().__new__(cls)
|
|
52
|
+
return cls._instance
|
|
53
|
+
|
|
54
|
+
def __init__(self, url: str, app_id: str, secret: str, env: str):
|
|
55
|
+
"""Initialize AgileConfigLoader from environment variables and start background listening."""
|
|
56
|
+
# Only initialize once
|
|
57
|
+
if not hasattr(self, '_initialized'):
|
|
58
|
+
self._initialized = True
|
|
59
|
+
|
|
60
|
+
self._url = url
|
|
61
|
+
self._app_id = app_id
|
|
62
|
+
self._secret = secret
|
|
63
|
+
self._env = env
|
|
64
|
+
|
|
65
|
+
self._ws_client: Optional[Any] = None
|
|
66
|
+
self._config_cache: Dict[str, Any] = {}
|
|
67
|
+
self._headers: Optional[Dict[str, Any]] = None
|
|
68
|
+
self._running_thread = None
|
|
69
|
+
self._use_os_env_fallback = False
|
|
70
|
+
|
|
71
|
+
self._start_event = threading.Event()
|
|
72
|
+
self._stop_event = threading.Event()
|
|
73
|
+
self._updated_event = threading.Event()
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
|
|
76
|
+
# Pre-compute auth headers (doesn't change across retries)
|
|
77
|
+
auth_string = f"{self._app_id}:{self._secret}"
|
|
78
|
+
auth_bytes = b64encode(auth_string.encode()).decode()
|
|
79
|
+
self._headers = {
|
|
80
|
+
"appid": self._app_id,
|
|
81
|
+
"env": self._env,
|
|
82
|
+
"Authorization": f"Basic {auth_bytes}",
|
|
83
|
+
}
|
|
84
|
+
self.start()
|
|
85
|
+
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
def clear_cache(self):
|
|
89
|
+
self._config_cache = {}
|
|
90
|
+
|
|
91
|
+
def _http_url_parser(self) -> str:
|
|
92
|
+
"""Derive the HTTP base URL from the configured WebSocket URL."""
|
|
93
|
+
url = self._url
|
|
94
|
+
if url.startswith("wss://"):
|
|
95
|
+
url = "https://" + url[len("wss://"):]
|
|
96
|
+
elif url.startswith("ws://"):
|
|
97
|
+
url = "http://" + url[len("ws://"):]
|
|
98
|
+
if url.endswith("/ws"):
|
|
99
|
+
url = url[: -len("/ws")]
|
|
100
|
+
return url.rstrip("/")
|
|
101
|
+
|
|
102
|
+
def _get_config_from_server(self):
|
|
103
|
+
url = f'{self._http_url_parser()}/api/config/app/{self._app_id}'
|
|
104
|
+
r = requests.get(url, headers=self._headers, params={'env': self._env}, timeout=5.0)
|
|
105
|
+
r.raise_for_status()
|
|
106
|
+
configs = {
|
|
107
|
+
f'{_["group"]}:{_["key"]}' if _["group"] else _["key"]: _['value']
|
|
108
|
+
for _ in r.json()
|
|
109
|
+
}
|
|
110
|
+
self._config_cache = configs
|
|
111
|
+
|
|
112
|
+
def _start_config_listener(self):
|
|
113
|
+
self._start_event.set()
|
|
114
|
+
while not self._stop_event.wait(1):
|
|
115
|
+
if not self._lock.acquire(timeout=1):
|
|
116
|
+
logger.warning('Failed to acquire lock for AgileConfig listener. Retrying...')
|
|
117
|
+
continue
|
|
118
|
+
logger.info('lock acquired before initiating websocket connection.')
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with ws_connect(
|
|
122
|
+
self._url,
|
|
123
|
+
additional_headers=self._headers,
|
|
124
|
+
ping_interval=30,
|
|
125
|
+
open_timeout=5,
|
|
126
|
+
close_timeout=5,
|
|
127
|
+
ping_timeout=5
|
|
128
|
+
) as client:
|
|
129
|
+
if not client.ping().wait(timeout=5):
|
|
130
|
+
raise TimeoutError('Initial ping to AgileConfig server timed out!')
|
|
131
|
+
self._ws_client = client
|
|
132
|
+
self._lock.release()
|
|
133
|
+
logger.info('lock released after a successful websocket connection')
|
|
134
|
+
|
|
135
|
+
self._get_config_from_server()
|
|
136
|
+
self._use_os_env_fallback = False
|
|
137
|
+
self._updated_event.set()
|
|
138
|
+
logger.info(f'Successfully retrieved config from AgileConfig server at {self._http_url_parser()}')
|
|
139
|
+
|
|
140
|
+
logger.info(f"Successfully connected to AgileConfig ws at {self._url}")
|
|
141
|
+
for msg in self._ws_client:
|
|
142
|
+
logger.info(f'Received message {msg} from {self._url}')
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(msg)
|
|
145
|
+
if isinstance(parsed, dict) and parsed.get('Action') == 'reload':
|
|
146
|
+
self._get_config_from_server()
|
|
147
|
+
logger.info('Successfully updated config.')
|
|
148
|
+
except (json.JSONDecodeError, TypeError):
|
|
149
|
+
pass
|
|
150
|
+
except TimeoutError as e:
|
|
151
|
+
logger.warning(f'Encountered timeout error: {e.__repr__()}. Will retry.')
|
|
152
|
+
except requests.exceptions.HTTPError as e:
|
|
153
|
+
logger.warning(
|
|
154
|
+
f'Failed to retrieve config from server with error {e.__repr__()}. Will retry.'
|
|
155
|
+
)
|
|
156
|
+
except ConnectionClosedError:
|
|
157
|
+
if self._stop_event.wait(1):
|
|
158
|
+
logger.info('Connection closed due to termination. Exiting.')
|
|
159
|
+
return
|
|
160
|
+
logger.warning(f'Connection unexpectedly closed. Will retry.')
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(
|
|
164
|
+
f'Encountered unexpected error while subscribing to AgileConfig server: {e.__repr__()}. '
|
|
165
|
+
f'Will retry.'
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
self._use_os_env_fallback = True
|
|
169
|
+
if self._lock.locked():
|
|
170
|
+
self._lock.release()
|
|
171
|
+
logger.info('lock released after a websocket termination.')
|
|
172
|
+
|
|
173
|
+
logger.info('AgileConfig listener stopped.')
|
|
174
|
+
self._start_event.clear()
|
|
175
|
+
|
|
176
|
+
#
|
|
177
|
+
#
|
|
178
|
+
#
|
|
179
|
+
# while not self._terminated:
|
|
180
|
+
# self._ready = False
|
|
181
|
+
# try:
|
|
182
|
+
# self._get_config_from_server()
|
|
183
|
+
# self._updated_event.set()
|
|
184
|
+
# logger.info(f'Successfully retrieved config from AgileConfig server at {self._url}')
|
|
185
|
+
# self._ready = True
|
|
186
|
+
# except requests.exceptions.HTTPError as e:
|
|
187
|
+
# logger.warning(
|
|
188
|
+
# f'Failed to retrieve config from server with error {e.__repr__()}. Will retry in 5 seconds.'
|
|
189
|
+
# )
|
|
190
|
+
# except Exception as e:
|
|
191
|
+
# logger.warning(
|
|
192
|
+
# f'Failed to retrieve config from server with unexpected error {e.__repr__()}. '
|
|
193
|
+
# f'Will retry in 5 seconds.'
|
|
194
|
+
# )
|
|
195
|
+
# if not self._ready:
|
|
196
|
+
# self._updated_event.clear()
|
|
197
|
+
# if self._stop_event.wait(5):
|
|
198
|
+
# return
|
|
199
|
+
# continue
|
|
200
|
+
#
|
|
201
|
+
# try:
|
|
202
|
+
# with ws_connect(
|
|
203
|
+
# self._url,
|
|
204
|
+
# additional_headers=self._headers,
|
|
205
|
+
# ping_interval=30,
|
|
206
|
+
# ) as client:
|
|
207
|
+
# if not client.ping().wait(timeout=1):
|
|
208
|
+
# raise TimeoutError('Initial ping to AgileConfig server timed out!')
|
|
209
|
+
# self._ws_client = client
|
|
210
|
+
# logger.info(f"Successfully connected to AgileConfig ws at {self._url}")
|
|
211
|
+
# for msg in self._ws_client:
|
|
212
|
+
# logger.info(f'Received message {msg} from {self._url}')
|
|
213
|
+
# if json.loads(msg)['Action'] == 'reload':
|
|
214
|
+
# self._get_config_from_server()
|
|
215
|
+
# logger.info('Successfully updated config.')
|
|
216
|
+
# print(f'asdasdzzzz, break out of loop')
|
|
217
|
+
# except TimeoutError as e:
|
|
218
|
+
# logger.warning(f'Encountered timeout error: {e.__repr__()}. Will retry in 5 seconds.')
|
|
219
|
+
# except requests.exceptions.HTTPError as e:
|
|
220
|
+
# logger.warning(
|
|
221
|
+
# f'Failed to retrieve config from server with error {e.__repr__()}. Will retry in 5 seconds.'
|
|
222
|
+
# )
|
|
223
|
+
# except ConnectionClosedError:
|
|
224
|
+
# if self._stop_event.wait(1):
|
|
225
|
+
# logger.info('Connection closed due to termination. Exiting.')
|
|
226
|
+
# return
|
|
227
|
+
# logger.warning(f'Connection unexpectedly closed. Will retry in 5 seconds.')
|
|
228
|
+
# except Exception as e:
|
|
229
|
+
# logger.warning(
|
|
230
|
+
# f'Encountered unexpected error while subscribing to AgileConfig server: {e.__repr__()}. '
|
|
231
|
+
# f'Will retry in 5 seconds.'
|
|
232
|
+
# )
|
|
233
|
+
# if self._stop_event.wait(5):
|
|
234
|
+
# return
|
|
235
|
+
|
|
236
|
+
def stop(self):
|
|
237
|
+
if not self._start_event.wait(5):
|
|
238
|
+
logger.info('Agile config loader skipped stop. Agile config listener is not running.')
|
|
239
|
+
return False
|
|
240
|
+
logger.info('stop called')
|
|
241
|
+
self._stop_event.set()
|
|
242
|
+
if not self._lock.acquire(timeout=10):
|
|
243
|
+
logger.info('Failed to stop')
|
|
244
|
+
return False
|
|
245
|
+
if self._ws_client:
|
|
246
|
+
self._ws_client.close()
|
|
247
|
+
if self._lock.locked():
|
|
248
|
+
self._lock.release()
|
|
249
|
+
if self._running_thread:
|
|
250
|
+
self._running_thread.join(timeout=5)
|
|
251
|
+
if self._running_thread.is_alive():
|
|
252
|
+
raise RuntimeError('Failed to stop AgileConfig listener thread within timeout.')
|
|
253
|
+
self._running_thread = None
|
|
254
|
+
self._ws_client = None
|
|
255
|
+
self._config_cache = {}
|
|
256
|
+
if self._updated_event.is_set():
|
|
257
|
+
self._updated_event.clear()
|
|
258
|
+
self._use_os_env_fallback = False
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
def start(self):
|
|
262
|
+
if self._running_thread:
|
|
263
|
+
logger.info(f'The AgileConfig instance is already started.')
|
|
264
|
+
return
|
|
265
|
+
if not self._url or not self._app_id:
|
|
266
|
+
logger.warning(
|
|
267
|
+
"AgileConfig not fully configured. Missing AGILE_CONFIG_URL or AGILE_CONFIG_APP_ID. "
|
|
268
|
+
"Will fall back to environment variables for all config lookups."
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
self._stop_event.clear()
|
|
273
|
+
self._running_thread = threading.Thread(target=self._start_config_listener, daemon=True)
|
|
274
|
+
self._running_thread.start()
|
|
275
|
+
|
|
276
|
+
def get_var_value(self, var_name: str, prefix =''):
|
|
277
|
+
if not self._use_os_env_fallback:
|
|
278
|
+
loaded = self._updated_event.wait(timeout=AGILE_CONFIG_TIMEOUT)
|
|
279
|
+
if not loaded:
|
|
280
|
+
self._use_os_env_fallback = True
|
|
281
|
+
|
|
282
|
+
if self._use_os_env_fallback:
|
|
283
|
+
logger.warning(
|
|
284
|
+
'AgileConfig connection was not established. Falling back to using os.environ. '
|
|
285
|
+
f'Prefix {prefix} will be ignored.'
|
|
286
|
+
)
|
|
287
|
+
return os.environ.get(var_name)
|
|
288
|
+
return self._config_cache.get(f'{prefix}:{var_name}')
|
|
289
|
+
|
|
290
|
+
def get_var(self, var_name: str, prefix =''):
|
|
291
|
+
return ConfigStrWithUpdate(self, prefix=prefix, var_name=var_name)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agileconfig-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for AgileConfig with WebSocket updates and environment fallback
|
|
5
|
+
Author: Wei Zheng
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 AgileConfig contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Classifier: Development Status :: 3 - Alpha
|
|
28
|
+
Classifier: Intended Audience :: Developers
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
38
|
+
Requires-Python: >=3.9
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: requests>=2.31
|
|
42
|
+
Requires-Dist: websockets<16,>=15
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# agileconfig-python
|
|
46
|
+
|
|
47
|
+
Python client for [AgileConfig](https://github.com/dotnetcore/AgileConfig). The client loads configuration over HTTP, listens for reload notifications over WebSocket, and falls back to `os.environ` when the server is unavailable.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install agileconfig-python
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from agileconfig_python import AgileConfigLoader
|
|
59
|
+
|
|
60
|
+
loader = AgileConfigLoader(
|
|
61
|
+
url="wss://config.example.com/ws",
|
|
62
|
+
app_id="my-app",
|
|
63
|
+
secret="your-secret",
|
|
64
|
+
env="DEV",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
database_host = str(loader.get_var("host", prefix="database"))
|
|
68
|
+
loader.stop()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The initial HTTP request populates the cache. When AgileConfig publishes a reload event, the cache is refreshed. If the server cannot be reached within the configured timeout, `get_var_value` reads the variable name from the process environment and ignores its prefix.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
- `AgileConfigLoader(url, app_id, secret, env)` creates the singleton loader and starts its listener.
|
|
76
|
+
- `loader.get_var_value(var_name, prefix="")` returns the current value, or an environment fallback.
|
|
77
|
+
- `loader.get_var(var_name, prefix="")` returns a lazy string-like proxy that reads the latest value when converted with `str()`.
|
|
78
|
+
- `loader.stop()` closes the WebSocket and stops the listener thread.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m pip install --upgrade pip build
|
|
84
|
+
python -m pip install -e .
|
|
85
|
+
python -m unittest discover -s unit_tests -v
|
|
86
|
+
python -m build
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The live integration tests embedded in the source require an AgileConfig server and are not run by the offline unit-test command.
|
|
90
|
+
|
|
91
|
+
## Release checklist
|
|
92
|
+
|
|
93
|
+
1. Update `version` in `pyproject.toml`.
|
|
94
|
+
2. Run the unit tests and `python -m build`.
|
|
95
|
+
3. Inspect both artifacts with `python -m twine check dist/*`.
|
|
96
|
+
4. Upload to TestPyPI and install the uploaded version in a clean environment.
|
|
97
|
+
5. Create a Git tag matching the version, for example `v0.1.0`.
|
|
98
|
+
6. Publish to PyPI with a trusted publisher or `twine upload dist/*`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
agileconfig_python/__init__.py
|
|
5
|
+
agileconfig_python/config_loader.py
|
|
6
|
+
agileconfig_python.egg-info/PKG-INFO
|
|
7
|
+
agileconfig_python.egg-info/SOURCES.txt
|
|
8
|
+
agileconfig_python.egg-info/dependency_links.txt
|
|
9
|
+
agileconfig_python.egg-info/requires.txt
|
|
10
|
+
agileconfig_python.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agileconfig_python
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agileconfig-python"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for AgileConfig with WebSocket updates and environment fallback"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Wei Zheng" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"requests>=2.31",
|
|
30
|
+
"websockets>=15,<16",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["agileconfig_python*"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools]
|
|
37
|
+
include-package-data = true
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
addopts = "-ra"
|
|
41
|
+
|
|
42
|
+
[tool.coverage.run]
|
|
43
|
+
branch = true
|
|
44
|
+
source = ["agileconfig_python"]
|