nacos-toolkit 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,278 @@
1
+ Metadata-Version: 2.4
2
+ Name: nacos-toolkit
3
+ Version: 0.1.0
4
+ Summary: Nacos configuration parsing and management tool
5
+ Keywords: nacos,config,configuration,template,yaml
6
+ Author: nacos-toolkit contributors
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Dist: pyyaml>=6.0
15
+ Requires-Dist: nacos-sdk-python>=1.0.0
16
+ Requires-Dist: loguru>=0.7.0
17
+ Requires-Python: >=3.12
18
+ Project-URL: Homepage, https://pypi.org/project/nacos-toolkit/
19
+ Description-Content-Type: text/markdown
20
+
21
+ # nacos-toolkit
22
+
23
+ Nacos 配置解析与管理工具。
24
+
25
+ 支持从 Nacos 服务端拉取配置、`${VAR}` 模板变量渲染、多配置深度合并、本地配置文件读取。
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ uv add nacos-toolkit
31
+ ```
32
+
33
+ 或使用 pip:
34
+
35
+ ```bash
36
+ pip install nacos-toolkit
37
+ ```
38
+
39
+ ## 快速开始
40
+
41
+ ### 从 Nacos 获取配置
42
+
43
+ ```python
44
+ import asyncio
45
+ from nacos_toolkit import get_nacos_config
46
+
47
+ async def main():
48
+ result = await get_nacos_config(
49
+ connection={
50
+ "server_addr": "nacos-server:8848",
51
+ "namespace": "production",
52
+ "username": "nacos",
53
+ "password": "nacos",
54
+ },
55
+ base_configs=[
56
+ {"data_id": "common.yml", "group": "DEFAULT_GROUP"},
57
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
58
+ ],
59
+ )
60
+ print(result["config"])
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ **处理流程:**
66
+
67
+ 1. 按顺序拉取所有 `base_configs` 的内容
68
+ 2. 浅合并所有配置,作为模板变量上下文
69
+ 3. 仅处理最后一个配置文件,渲染其中的 `${VAR}` 模板
70
+ 4. 自动注入 `DEPLOY_ENV = namespace`
71
+
72
+ ### 带覆盖配置
73
+
74
+ ```python
75
+ result = await get_nacos_config(
76
+ connection={...},
77
+ base_configs=[
78
+ {"data_id": "common.yml", "group": "DEFAULT_GROUP"},
79
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
80
+ ],
81
+ override_config={
82
+ "data_id": "app-customized.yml",
83
+ "group": "DEFAULT_GROUP",
84
+ },
85
+ )
86
+ ```
87
+
88
+ 覆盖配置会与基础配置深度合并,覆盖配置的值优先。
89
+
90
+ ### Debug 模式
91
+
92
+ ```python
93
+ result = await get_nacos_config(
94
+ connection={...},
95
+ base_configs=[...],
96
+ debug=True,
97
+ )
98
+ print(result["config"]) # 处理后的配置
99
+ print(result["raw"]) # 合并后的原始配置(未经模板渲染)
100
+ ```
101
+
102
+ ## 配置处理工具
103
+
104
+ ### 处理 YAML/JSON 配置
105
+
106
+ ```python
107
+ from nacos_toolkit import NacosConfigUtils, NacosParser
108
+
109
+ # 处理 YAML 配置(默认格式)
110
+ config = NacosConfigUtils.process_configuration(
111
+ """
112
+ server:
113
+ host: ${HOST}
114
+ port: ${PORT}
115
+ database:
116
+ url: ${DB_HOST}:3306
117
+ """,
118
+ external_vars={
119
+ "HOST": "localhost",
120
+ "PORT": "8080",
121
+ "DB_HOST": "mysql-server",
122
+ },
123
+ )
124
+ # config = {"server": {"host": "localhost", "port": "8080"}, "database": {"url": "mysql-server:3306"}}
125
+
126
+ # 处理 JSON 配置
127
+ config = NacosConfigUtils.process_configuration(
128
+ '{"name": "${APP_NAME}"}',
129
+ fmt=NacosParser.JSON,
130
+ external_vars={"APP_NAME": "my-app"},
131
+ )
132
+ ```
133
+
134
+ **模板特性:**
135
+
136
+ - 支持 `${VAR}` 语法
137
+ - 支持点号嵌套引用:`${redis.hostname}`
138
+ - 支持嵌套模板解析:`${URL}` -> `${PROTO}://${HOST}` -> `https://example.com`
139
+ - 最大渲染深度 5 层,防止无限循环
140
+ - 未定义的变量保持原样 `${UNKNOWN}`
141
+
142
+ ### 合并自定义配置
143
+
144
+ ```python
145
+ base = {"host": "localhost", "port": 3000, "cors": {"whitelist": ["http://a.com"]}}
146
+
147
+ merged = NacosConfigUtils.process_and_merge_custom_config(
148
+ base,
149
+ """
150
+ port: 9999
151
+ cors:
152
+ whitelist:
153
+ - http://b.com
154
+ - http://c.com
155
+ """,
156
+ )
157
+ # merged = {"host": "localhost", "port": 9999, "cors": {"whitelist": ["http://b.com", "http://c.com"]}}
158
+ ```
159
+
160
+ **合并规则:**
161
+
162
+ - 字典深度合并
163
+ - 数组直接替换(不做元素合并)
164
+ - 自定义配置中可使用基础配置的变量
165
+
166
+ ### 逗号分隔字符串自动转数组
167
+
168
+ 默认会将 `cors.whitelist` 字段的逗号分隔字符串转为数组:
169
+
170
+ ```python
171
+ config = NacosConfigUtils.process_configuration(
172
+ "cors:\n whitelist: 'http://a.com, http://b.com'"
173
+ )
174
+ # config["cors"]["whitelist"] = ["http://a.com", "http://b.com"]
175
+
176
+ # 自定义需要转换的字段
177
+ config = NacosConfigUtils.process_configuration(
178
+ "tags: 'a, b, c'",
179
+ convert_array_fields=["tags"],
180
+ )
181
+ # config["tags"] = ["a", "b", "c"]
182
+ ```
183
+
184
+ 如果值是 YAML 数组格式则保持不变,不会重复处理。
185
+
186
+ ## 配置监听
187
+
188
+ ```python
189
+ from nacos_toolkit import setup_config_listener
190
+
191
+ def on_update(content: str):
192
+ print(f"配置已更新: {content}")
193
+
194
+ setup_config_listener(
195
+ nacos_config={
196
+ "server_addr": "nacos-server:8848",
197
+ "namespace": "production",
198
+ "username": "nacos",
199
+ "password": "nacos",
200
+ },
201
+ listen_requests=[
202
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
203
+ ],
204
+ callback=on_update,
205
+ )
206
+ ```
207
+
208
+ 不传 `callback` 时,默认自动更新缓存中的配置。
209
+
210
+ ## 本地配置文件
211
+
212
+ ```python
213
+ from nacos_toolkit import get_local_config, find_local_config, parse_config_file
214
+
215
+ # 自动查找并解析(按 .json -> .yaml -> .yml 优先级)
216
+ config = get_local_config(file_name="app", file_path="./config")
217
+
218
+ # 仅查找文件路径
219
+ path = find_local_config(file_name="app", file_path="./config")
220
+ # path = "/abs/path/config/app.yml" 或 None
221
+
222
+ # 解析指定文件
223
+ config = parse_config_file(file_path="/path/to/config.yml")
224
+ ```
225
+
226
+ ## 底层工具
227
+
228
+ ```python
229
+ from nacos_toolkit import NacosConfigUtils, ConfigMerger, TemplateEngine
230
+
231
+ # 深度合并
232
+ merged = ConfigMerger.merge({"a": 1, "b": {"x": 1}}, {"b": {"y": 2}, "c": 3})
233
+ # {"a": 1, "b": {"x": 1, "y": 2}, "c": 3}
234
+
235
+ # 嵌套属性访问
236
+ val = NacosConfigUtils.get_nested_property({"a": {"b": {"c": 42}}}, "a.b.c")
237
+ # 42
238
+
239
+ # 嵌套属性设置
240
+ obj = {}
241
+ NacosConfigUtils.set_nested_property(obj, "a.b.c", 42)
242
+ # obj = {"a": {"b": {"c": 42}}}
243
+
244
+ # 模板检测
245
+ TemplateEngine.contains_template("${HOST}") # True
246
+ TemplateEngine.contains_template("plain") # False
247
+ ```
248
+
249
+ ## 开发
250
+
251
+ ```bash
252
+ # 安装依赖
253
+ uv sync
254
+
255
+ # 运行测试
256
+ uv run pytest -v
257
+
258
+ # 代码检查
259
+ uv run ruff check .
260
+ ```
261
+
262
+ ## API 一览
263
+
264
+ | 函数 / 类 | 说明 |
265
+ |---|---|
266
+ | `await get_nacos_config(...)` | 从 Nacos 拉取并处理配置 |
267
+ | `setup_config_listener(...)` | 监听 Nacos 配置变更 |
268
+ | `get_local_config(...)` | 读取本地配置文件 |
269
+ | `NacosConfigUtils.process_configuration()` | 解析配置 + 渲染模板 |
270
+ | `NacosConfigUtils.process_and_merge_custom_config()` | 处理并合并自定义配置 |
271
+ | `NacosConfigUtils.merge_configurations()` | 深度合并两个配置 |
272
+ | `NacosConfigUtils.contains_template()` | 检测字符串是否包含模板 |
273
+ | `NacosConfigUtils.convert_string_fields_to_arrays()` | 逗号字符串转数组 |
274
+ | `NacosConfigUtils.get_nested_property()` | 点号路径读取嵌套属性 |
275
+ | `NacosConfigUtils.set_nested_property()` | 点号路径设置嵌套属性 |
276
+ | `find_local_config(...)` | 查找本地配置文件路径 |
277
+ | `parse_config_file(...)` | 解析 JSON/YAML 文件 |
278
+ | `NacosParser.YAML / .JSON` | 配置格式枚举 |
@@ -0,0 +1,258 @@
1
+ # nacos-toolkit
2
+
3
+ Nacos 配置解析与管理工具。
4
+
5
+ 支持从 Nacos 服务端拉取配置、`${VAR}` 模板变量渲染、多配置深度合并、本地配置文件读取。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ uv add nacos-toolkit
11
+ ```
12
+
13
+ 或使用 pip:
14
+
15
+ ```bash
16
+ pip install nacos-toolkit
17
+ ```
18
+
19
+ ## 快速开始
20
+
21
+ ### 从 Nacos 获取配置
22
+
23
+ ```python
24
+ import asyncio
25
+ from nacos_toolkit import get_nacos_config
26
+
27
+ async def main():
28
+ result = await get_nacos_config(
29
+ connection={
30
+ "server_addr": "nacos-server:8848",
31
+ "namespace": "production",
32
+ "username": "nacos",
33
+ "password": "nacos",
34
+ },
35
+ base_configs=[
36
+ {"data_id": "common.yml", "group": "DEFAULT_GROUP"},
37
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
38
+ ],
39
+ )
40
+ print(result["config"])
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ **处理流程:**
46
+
47
+ 1. 按顺序拉取所有 `base_configs` 的内容
48
+ 2. 浅合并所有配置,作为模板变量上下文
49
+ 3. 仅处理最后一个配置文件,渲染其中的 `${VAR}` 模板
50
+ 4. 自动注入 `DEPLOY_ENV = namespace`
51
+
52
+ ### 带覆盖配置
53
+
54
+ ```python
55
+ result = await get_nacos_config(
56
+ connection={...},
57
+ base_configs=[
58
+ {"data_id": "common.yml", "group": "DEFAULT_GROUP"},
59
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
60
+ ],
61
+ override_config={
62
+ "data_id": "app-customized.yml",
63
+ "group": "DEFAULT_GROUP",
64
+ },
65
+ )
66
+ ```
67
+
68
+ 覆盖配置会与基础配置深度合并,覆盖配置的值优先。
69
+
70
+ ### Debug 模式
71
+
72
+ ```python
73
+ result = await get_nacos_config(
74
+ connection={...},
75
+ base_configs=[...],
76
+ debug=True,
77
+ )
78
+ print(result["config"]) # 处理后的配置
79
+ print(result["raw"]) # 合并后的原始配置(未经模板渲染)
80
+ ```
81
+
82
+ ## 配置处理工具
83
+
84
+ ### 处理 YAML/JSON 配置
85
+
86
+ ```python
87
+ from nacos_toolkit import NacosConfigUtils, NacosParser
88
+
89
+ # 处理 YAML 配置(默认格式)
90
+ config = NacosConfigUtils.process_configuration(
91
+ """
92
+ server:
93
+ host: ${HOST}
94
+ port: ${PORT}
95
+ database:
96
+ url: ${DB_HOST}:3306
97
+ """,
98
+ external_vars={
99
+ "HOST": "localhost",
100
+ "PORT": "8080",
101
+ "DB_HOST": "mysql-server",
102
+ },
103
+ )
104
+ # config = {"server": {"host": "localhost", "port": "8080"}, "database": {"url": "mysql-server:3306"}}
105
+
106
+ # 处理 JSON 配置
107
+ config = NacosConfigUtils.process_configuration(
108
+ '{"name": "${APP_NAME}"}',
109
+ fmt=NacosParser.JSON,
110
+ external_vars={"APP_NAME": "my-app"},
111
+ )
112
+ ```
113
+
114
+ **模板特性:**
115
+
116
+ - 支持 `${VAR}` 语法
117
+ - 支持点号嵌套引用:`${redis.hostname}`
118
+ - 支持嵌套模板解析:`${URL}` -> `${PROTO}://${HOST}` -> `https://example.com`
119
+ - 最大渲染深度 5 层,防止无限循环
120
+ - 未定义的变量保持原样 `${UNKNOWN}`
121
+
122
+ ### 合并自定义配置
123
+
124
+ ```python
125
+ base = {"host": "localhost", "port": 3000, "cors": {"whitelist": ["http://a.com"]}}
126
+
127
+ merged = NacosConfigUtils.process_and_merge_custom_config(
128
+ base,
129
+ """
130
+ port: 9999
131
+ cors:
132
+ whitelist:
133
+ - http://b.com
134
+ - http://c.com
135
+ """,
136
+ )
137
+ # merged = {"host": "localhost", "port": 9999, "cors": {"whitelist": ["http://b.com", "http://c.com"]}}
138
+ ```
139
+
140
+ **合并规则:**
141
+
142
+ - 字典深度合并
143
+ - 数组直接替换(不做元素合并)
144
+ - 自定义配置中可使用基础配置的变量
145
+
146
+ ### 逗号分隔字符串自动转数组
147
+
148
+ 默认会将 `cors.whitelist` 字段的逗号分隔字符串转为数组:
149
+
150
+ ```python
151
+ config = NacosConfigUtils.process_configuration(
152
+ "cors:\n whitelist: 'http://a.com, http://b.com'"
153
+ )
154
+ # config["cors"]["whitelist"] = ["http://a.com", "http://b.com"]
155
+
156
+ # 自定义需要转换的字段
157
+ config = NacosConfigUtils.process_configuration(
158
+ "tags: 'a, b, c'",
159
+ convert_array_fields=["tags"],
160
+ )
161
+ # config["tags"] = ["a", "b", "c"]
162
+ ```
163
+
164
+ 如果值是 YAML 数组格式则保持不变,不会重复处理。
165
+
166
+ ## 配置监听
167
+
168
+ ```python
169
+ from nacos_toolkit import setup_config_listener
170
+
171
+ def on_update(content: str):
172
+ print(f"配置已更新: {content}")
173
+
174
+ setup_config_listener(
175
+ nacos_config={
176
+ "server_addr": "nacos-server:8848",
177
+ "namespace": "production",
178
+ "username": "nacos",
179
+ "password": "nacos",
180
+ },
181
+ listen_requests=[
182
+ {"data_id": "app.yml", "group": "DEFAULT_GROUP"},
183
+ ],
184
+ callback=on_update,
185
+ )
186
+ ```
187
+
188
+ 不传 `callback` 时,默认自动更新缓存中的配置。
189
+
190
+ ## 本地配置文件
191
+
192
+ ```python
193
+ from nacos_toolkit import get_local_config, find_local_config, parse_config_file
194
+
195
+ # 自动查找并解析(按 .json -> .yaml -> .yml 优先级)
196
+ config = get_local_config(file_name="app", file_path="./config")
197
+
198
+ # 仅查找文件路径
199
+ path = find_local_config(file_name="app", file_path="./config")
200
+ # path = "/abs/path/config/app.yml" 或 None
201
+
202
+ # 解析指定文件
203
+ config = parse_config_file(file_path="/path/to/config.yml")
204
+ ```
205
+
206
+ ## 底层工具
207
+
208
+ ```python
209
+ from nacos_toolkit import NacosConfigUtils, ConfigMerger, TemplateEngine
210
+
211
+ # 深度合并
212
+ merged = ConfigMerger.merge({"a": 1, "b": {"x": 1}}, {"b": {"y": 2}, "c": 3})
213
+ # {"a": 1, "b": {"x": 1, "y": 2}, "c": 3}
214
+
215
+ # 嵌套属性访问
216
+ val = NacosConfigUtils.get_nested_property({"a": {"b": {"c": 42}}}, "a.b.c")
217
+ # 42
218
+
219
+ # 嵌套属性设置
220
+ obj = {}
221
+ NacosConfigUtils.set_nested_property(obj, "a.b.c", 42)
222
+ # obj = {"a": {"b": {"c": 42}}}
223
+
224
+ # 模板检测
225
+ TemplateEngine.contains_template("${HOST}") # True
226
+ TemplateEngine.contains_template("plain") # False
227
+ ```
228
+
229
+ ## 开发
230
+
231
+ ```bash
232
+ # 安装依赖
233
+ uv sync
234
+
235
+ # 运行测试
236
+ uv run pytest -v
237
+
238
+ # 代码检查
239
+ uv run ruff check .
240
+ ```
241
+
242
+ ## API 一览
243
+
244
+ | 函数 / 类 | 说明 |
245
+ |---|---|
246
+ | `await get_nacos_config(...)` | 从 Nacos 拉取并处理配置 |
247
+ | `setup_config_listener(...)` | 监听 Nacos 配置变更 |
248
+ | `get_local_config(...)` | 读取本地配置文件 |
249
+ | `NacosConfigUtils.process_configuration()` | 解析配置 + 渲染模板 |
250
+ | `NacosConfigUtils.process_and_merge_custom_config()` | 处理并合并自定义配置 |
251
+ | `NacosConfigUtils.merge_configurations()` | 深度合并两个配置 |
252
+ | `NacosConfigUtils.contains_template()` | 检测字符串是否包含模板 |
253
+ | `NacosConfigUtils.convert_string_fields_to_arrays()` | 逗号字符串转数组 |
254
+ | `NacosConfigUtils.get_nested_property()` | 点号路径读取嵌套属性 |
255
+ | `NacosConfigUtils.set_nested_property()` | 点号路径设置嵌套属性 |
256
+ | `find_local_config(...)` | 查找本地配置文件路径 |
257
+ | `parse_config_file(...)` | 解析 JSON/YAML 文件 |
258
+ | `NacosParser.YAML / .JSON` | 配置格式枚举 |
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "nacos-toolkit"
3
+ version = "0.1.0"
4
+ description = "Nacos configuration parsing and management tool"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "nacos-toolkit contributors" }
9
+ ]
10
+ requires-python = ">=3.12"
11
+ keywords = ["nacos", "config", "configuration", "template", "yaml"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = [
21
+ "pyyaml>=6.0",
22
+ "nacos-sdk-python>=1.0.0",
23
+ "loguru>=0.7.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://pypi.org/project/nacos-toolkit/"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "pytest-asyncio>=1.3.0",
33
+ "ruff>=0.9.0",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.11.3,<0.12.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.ruff]
41
+ line-length = 120
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "W"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
@@ -0,0 +1,20 @@
1
+ from nacos_toolkit.local_config import find_local_config, get_local_config, parse_config_file
2
+ from nacos_toolkit.manager import NacosConfigManager, get_nacos_config, setup_config_listener
3
+ from nacos_toolkit.merger import ConfigMerger
4
+ from nacos_toolkit.parser import ConfigParser, NacosParser
5
+ from nacos_toolkit.template import TemplateEngine
6
+ from nacos_toolkit.utils import NacosConfigUtils
7
+
8
+ __all__ = [
9
+ "ConfigMerger",
10
+ "ConfigParser",
11
+ "NacosConfigManager",
12
+ "NacosConfigUtils",
13
+ "NacosParser",
14
+ "TemplateEngine",
15
+ "find_local_config",
16
+ "get_local_config",
17
+ "get_nacos_config",
18
+ "parse_config_file",
19
+ "setup_config_listener",
20
+ ]
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+ from loguru import logger
9
+
10
+ _CONFIG_EXTENSIONS = [".json", ".yaml", ".yml"]
11
+
12
+
13
+ def find_local_config(*, file_name: str, file_path: str) -> str | None:
14
+ dir_path = Path(file_path).resolve()
15
+ for ext in _CONFIG_EXTENSIONS:
16
+ config_path = dir_path / f"{file_name}{ext}"
17
+ if config_path.exists():
18
+ return str(config_path)
19
+ return None
20
+
21
+
22
+ def parse_config_file(*, file_path: str) -> dict[str, Any]:
23
+ p = Path(file_path)
24
+ ext = p.suffix.lower()
25
+ content = p.read_text(encoding="utf-8")
26
+
27
+ if ext == ".json":
28
+ return json.loads(content)
29
+ if ext in (".yaml", ".yml"):
30
+ return yaml.safe_load(content)
31
+ raise ValueError(f"Unsupported file format: {ext}")
32
+
33
+
34
+ def get_local_config(*, file_name: str, file_path: str) -> dict[str, Any] | None:
35
+ if not file_name:
36
+ return None
37
+
38
+ local_path = find_local_config(file_name=file_name, file_path=file_path)
39
+ if not local_path:
40
+ logger.warning(f"No local configuration found for {file_name}")
41
+ return None
42
+
43
+ return parse_config_file(file_path=local_path)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import yaml
6
+ from loguru import logger
7
+
8
+ from nacos_toolkit.parser import NacosParser
9
+ from nacos_toolkit.utils import NacosConfigUtils
10
+
11
+
12
+ class NacosConfigManager:
13
+ _instance: NacosConfigManager | None = None
14
+
15
+ def __init__(self) -> None:
16
+ self._client: Any = None
17
+ self._config_cache: dict[str, Any] | None = None
18
+ self._raw_config: dict[str, Any] | None = None
19
+
20
+ @classmethod
21
+ def get_instance(cls) -> NacosConfigManager:
22
+ if cls._instance is None:
23
+ cls._instance = cls()
24
+ return cls._instance
25
+
26
+ def _create_client(self, config: dict[str, str]) -> Any:
27
+ import nacos
28
+
29
+ return nacos.NacosClient(
30
+ server_addresses=config["server_addr"],
31
+ namespace=config["namespace"],
32
+ username=config["username"],
33
+ password=config["password"],
34
+ )
35
+
36
+ def _init_client(self, config: dict[str, str]) -> Any:
37
+ if self._client is None:
38
+ self._client = self._create_client(config)
39
+ return self._client
40
+
41
+ async def get_config(
42
+ self,
43
+ connection: dict[str, str],
44
+ base_configs: list[dict[str, str]],
45
+ override_config: dict[str, str] | None = None,
46
+ ) -> dict[str, Any]:
47
+ if self._config_cache is not None:
48
+ return self._config_cache
49
+
50
+ client = self._init_client(connection)
51
+
52
+ try:
53
+ config_contents: list[str] = []
54
+ for cfg in base_configs:
55
+ content = await client.get_config(cfg["data_id"], cfg["group"])
56
+ config_contents.append(content)
57
+
58
+ all_data: dict[str, Any] = {}
59
+ for content in config_contents:
60
+ parsed = yaml.safe_load(content)
61
+ if isinstance(parsed, dict):
62
+ all_data.update(parsed)
63
+
64
+ last_content = config_contents[-1]
65
+ last_config = yaml.safe_load(last_content)
66
+ if not isinstance(last_config, dict):
67
+ last_config = {}
68
+
69
+ self._raw_config = all_data
70
+
71
+ self._config_cache = NacosConfigUtils.process_configuration(
72
+ last_config,
73
+ fmt=NacosParser.JSON,
74
+ external_vars={**all_data, "DEPLOY_ENV": connection["namespace"]},
75
+ )
76
+
77
+ if override_config and override_config.get("data_id"):
78
+ custom_content = await client.get_config(override_config["data_id"], override_config["group"])
79
+ fmt = _determine_format(override_config["data_id"])
80
+ self._config_cache = NacosConfigUtils.process_and_merge_custom_config(
81
+ self._config_cache,
82
+ custom_content,
83
+ fmt=fmt,
84
+ external_vars={**all_data, "DEPLOY_ENV": connection["namespace"]},
85
+ )
86
+
87
+ return self._config_cache
88
+
89
+ except Exception as e:
90
+ logger.error(f"Failed to fetch Nacos config: {e}")
91
+ raise
92
+
93
+ def clear_cache(self) -> None:
94
+ self._config_cache = None
95
+ self._raw_config = None
96
+
97
+ def get_raw_config(self) -> dict[str, Any] | None:
98
+ return self._raw_config
99
+
100
+
101
+ async def get_nacos_config(
102
+ *,
103
+ connection: dict[str, str],
104
+ base_configs: list[dict[str, str]],
105
+ override_config: dict[str, str] | None = None,
106
+ debug: bool = False,
107
+ ) -> dict[str, Any]:
108
+ mgr = NacosConfigManager.get_instance()
109
+ config = await mgr.get_config(connection, base_configs, override_config)
110
+ result: dict[str, Any] = {"config": config}
111
+ if debug:
112
+ result["raw"] = mgr.get_raw_config()
113
+ return result
114
+
115
+
116
+ def setup_config_listener(
117
+ *,
118
+ nacos_config: dict[str, str],
119
+ listen_requests: list[dict[str, str]],
120
+ callback: Any = None,
121
+ ) -> None:
122
+ mgr = NacosConfigManager.get_instance()
123
+ client = mgr._init_client(nacos_config)
124
+
125
+ for req in listen_requests:
126
+ data_id = req["data_id"]
127
+ group = req["group"]
128
+
129
+ def _on_change(content: str, _data_id: str = data_id) -> None:
130
+ logger.info(f"[Nacos] Config updated: {_data_id}")
131
+ if callback:
132
+ callback(content)
133
+ else:
134
+ parsed = yaml.safe_load(content)
135
+ if isinstance(parsed, dict) and mgr._config_cache is not None:
136
+ mgr._config_cache.update(parsed)
137
+
138
+ client.subscribe(data_id, group, _on_change)
139
+
140
+
141
+ def _determine_format(data_id: str) -> NacosParser:
142
+ if data_id.endswith(".json"):
143
+ return NacosParser.JSON
144
+ return NacosParser.YAML
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from typing import Any
5
+
6
+
7
+ class ConfigMerger:
8
+ @staticmethod
9
+ def merge(base: dict[str, Any], custom: dict[str, Any] | None) -> dict[str, Any]:
10
+ if custom is None:
11
+ custom = {}
12
+ return _deep_merge(copy.deepcopy(base), custom)
13
+
14
+
15
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
16
+ for key, value in override.items():
17
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
18
+ base[key] = _deep_merge(base[key], value)
19
+ else:
20
+ base[key] = copy.deepcopy(value)
21
+ return base
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ import yaml
8
+ from loguru import logger
9
+
10
+
11
+ class NacosParser(StrEnum):
12
+ YAML = ".yml"
13
+ JSON = ".json"
14
+
15
+
16
+ class ConfigParser:
17
+ @staticmethod
18
+ def parse(raw: str | dict, fmt: NacosParser) -> dict[str, Any]:
19
+ try:
20
+ if fmt == NacosParser.JSON:
21
+ if isinstance(raw, str):
22
+ return json.loads(raw)
23
+ return dict(raw)
24
+ result = yaml.safe_load(raw)
25
+ if not isinstance(result, dict):
26
+ return {}
27
+ return result
28
+ except Exception:
29
+ logger.warning("Failed to parse config, returning empty dict")
30
+ return {}
File without changes
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import deque
5
+ from typing import Any
6
+
7
+ _TEMPLATE_PATTERN = re.compile(r"\$\{([^}]+)\}")
8
+
9
+ MAX_RENDER_DEPTH = 5
10
+
11
+
12
+ def _get_nested_property(obj: Any, path: str) -> Any:
13
+ keys = path.split(".")
14
+ result = obj
15
+ for key in keys:
16
+ if result is None:
17
+ return None
18
+ if isinstance(result, dict):
19
+ result = result.get(key)
20
+ else:
21
+ return None
22
+ return result
23
+
24
+
25
+ def _set_nested_property(obj: dict, path: str, value: Any) -> None:
26
+ keys = path.split(".")
27
+ current = obj
28
+ for key in keys[:-1]:
29
+ if key not in current or not isinstance(current[key], dict):
30
+ current[key] = {}
31
+ current = current[key]
32
+ current[keys[-1]] = value
33
+
34
+
35
+ class TemplateEngine:
36
+ @staticmethod
37
+ def contains_template(text: str) -> bool:
38
+ return bool(_TEMPLATE_PATTERN.search(text))
39
+
40
+ @staticmethod
41
+ def is_text_only(value: Any) -> bool:
42
+ if not isinstance(value, str):
43
+ return True
44
+ return not _TEMPLATE_PATTERN.search(value)
45
+
46
+ @staticmethod
47
+ def render_text(text: str, context: dict[str, Any]) -> str:
48
+ current = text
49
+ for _ in range(MAX_RENDER_DEPTH):
50
+ previous = current
51
+
52
+ def _replace(m: re.Match) -> str:
53
+ key = m.group(1)
54
+ val = _get_nested_property(context, key)
55
+ if val is None:
56
+ return m.group(0)
57
+ return str(val)
58
+
59
+ current = _TEMPLATE_PATTERN.sub(_replace, current)
60
+ if current == previous:
61
+ break
62
+ return current
63
+
64
+ @staticmethod
65
+ def render(config: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
66
+ # Extract all template params first
67
+ params = _extract_template_params(config)
68
+ enriched_context = {**context}
69
+
70
+ # Resolve each param in context
71
+ for param in params:
72
+ val = _get_nested_property(context, param)
73
+ while isinstance(val, str) and not TemplateEngine.is_text_only(val):
74
+ new_val = TemplateEngine.render_text(val, context)
75
+ if new_val == val:
76
+ break
77
+ val = new_val
78
+ _set_nested_property(enriched_context, param, val)
79
+
80
+ # BFS traversal to render all string values
81
+ root: dict[str, Any] = {**config}
82
+ seen: set[int] = set()
83
+ queue: deque[tuple[dict, str, Any]] = deque()
84
+ queue.append((root, "", root))
85
+
86
+ while queue:
87
+ parent, key, value = queue.popleft()
88
+
89
+ if isinstance(value, str):
90
+ rendered = TemplateEngine.render_text(value, enriched_context)
91
+ if key:
92
+ parent[key] = rendered
93
+ elif isinstance(value, list):
94
+ new_array = []
95
+ for item in value:
96
+ if isinstance(item, str):
97
+ new_array.append(TemplateEngine.render_text(item, enriched_context))
98
+ elif isinstance(item, dict):
99
+ copy = {**item}
100
+ queue.append((copy, "", copy))
101
+ new_array.append(copy)
102
+ else:
103
+ new_array.append(item)
104
+ if key:
105
+ parent[key] = new_array
106
+ elif isinstance(value, dict):
107
+ obj_id = id(value)
108
+ if obj_id in seen:
109
+ continue
110
+ seen.add(obj_id)
111
+ for k, v in value.items():
112
+ queue.append((value, k, v))
113
+
114
+ return root
115
+
116
+
117
+ def _extract_template_params(config: Any) -> set[str]:
118
+ params: set[str] = set()
119
+
120
+ def _traverse(obj: Any) -> None:
121
+ if isinstance(obj, str):
122
+ for m in _TEMPLATE_PATTERN.finditer(obj):
123
+ params.add(m.group(1))
124
+ elif isinstance(obj, list):
125
+ for item in obj:
126
+ _traverse(item)
127
+ elif isinstance(obj, dict):
128
+ for v in obj.values():
129
+ _traverse(v)
130
+
131
+ _traverse(config)
132
+ return params
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from nacos_toolkit.merger import ConfigMerger
6
+ from nacos_toolkit.parser import ConfigParser, NacosParser
7
+ from nacos_toolkit.template import TemplateEngine
8
+
9
+
10
+ class NacosConfigUtils:
11
+ @staticmethod
12
+ def process_configuration(
13
+ raw_config: str | dict,
14
+ *,
15
+ fmt: NacosParser = NacosParser.YAML,
16
+ external_vars: dict[str, Any] | None = None,
17
+ convert_array_fields: list[str] | None = None,
18
+ ) -> dict[str, Any]:
19
+ if external_vars is None:
20
+ external_vars = {}
21
+ if convert_array_fields is None:
22
+ convert_array_fields = ["cors.whitelist"]
23
+
24
+ parsed = ConfigParser.parse(raw_config, fmt)
25
+ context = {**external_vars, **parsed}
26
+ result = TemplateEngine.render(parsed, context)
27
+ return NacosConfigUtils.convert_string_fields_to_arrays(result, convert_array_fields)
28
+
29
+ @staticmethod
30
+ def process_and_merge_custom_config(
31
+ base_config: dict[str, Any],
32
+ custom_config: str | dict,
33
+ *,
34
+ fmt: NacosParser = NacosParser.YAML,
35
+ external_vars: dict[str, Any] | None = None,
36
+ convert_array_fields: list[str] | None = None,
37
+ ) -> dict[str, Any]:
38
+ processed_base = {**base_config}
39
+ merged_vars = {**(external_vars or {}), **processed_base}
40
+
41
+ processed_custom = NacosConfigUtils.process_configuration(
42
+ custom_config,
43
+ fmt=fmt,
44
+ external_vars=merged_vars,
45
+ convert_array_fields=convert_array_fields,
46
+ )
47
+ return ConfigMerger.merge(processed_base, processed_custom)
48
+
49
+ @staticmethod
50
+ def merge_configurations(base: dict[str, Any], custom: dict[str, Any] | None) -> dict[str, Any]:
51
+ return ConfigMerger.merge(base, custom)
52
+
53
+ @staticmethod
54
+ def contains_template(text: str) -> bool:
55
+ return TemplateEngine.contains_template(text)
56
+
57
+ @staticmethod
58
+ def convert_string_fields_to_arrays(config: dict[str, Any], field_paths: list[str]) -> dict[str, Any]:
59
+ result = {**config}
60
+ for path in field_paths:
61
+ value = NacosConfigUtils.get_nested_property(result, path)
62
+ if isinstance(value, str) and "," in value:
63
+ NacosConfigUtils.set_nested_property(result, path, [item.strip() for item in value.split(",")])
64
+ return result
65
+
66
+ @staticmethod
67
+ def get_nested_property(obj: Any, path: str) -> Any:
68
+ keys = path.split(".")
69
+ result = obj
70
+ for key in keys:
71
+ if result is None:
72
+ return None
73
+ if isinstance(result, dict):
74
+ result = result.get(key)
75
+ else:
76
+ return None
77
+ return result
78
+
79
+ @staticmethod
80
+ def set_nested_property(obj: dict, path: str, value: Any) -> None:
81
+ keys = path.split(".")
82
+ current = obj
83
+ for key in keys[:-1]:
84
+ if key not in current or not isinstance(current[key], dict):
85
+ current[key] = {}
86
+ current = current[key]
87
+ current[keys[-1]] = value