nacos-toolkit 0.1.0__py3-none-any.whl
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.
- nacos_toolkit/__init__.py +20 -0
- nacos_toolkit/local_config.py +43 -0
- nacos_toolkit/manager.py +144 -0
- nacos_toolkit/merger.py +21 -0
- nacos_toolkit/parser.py +30 -0
- nacos_toolkit/py.typed +0 -0
- nacos_toolkit/template.py +132 -0
- nacos_toolkit/utils.py +87 -0
- nacos_toolkit-0.1.0.dist-info/METADATA +278 -0
- nacos_toolkit-0.1.0.dist-info/RECORD +11 -0
- nacos_toolkit-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|
nacos_toolkit/manager.py
ADDED
|
@@ -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
|
nacos_toolkit/merger.py
ADDED
|
@@ -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
|
nacos_toolkit/parser.py
ADDED
|
@@ -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 {}
|
nacos_toolkit/py.typed
ADDED
|
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
|
nacos_toolkit/utils.py
ADDED
|
@@ -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
|
|
@@ -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,11 @@
|
|
|
1
|
+
nacos_toolkit/__init__.py,sha256=raqZbWVrWwWgV0UnTa4eVbRxOEk6aHK3ArFQRmbjqGE,665
|
|
2
|
+
nacos_toolkit/local_config.py,sha256=QJlE1T2aqvE-E-PAc4VcLT0PiP0OnoYfG571K3e0Q5Y,1187
|
|
3
|
+
nacos_toolkit/manager.py,sha256=c0Cw80-HMFK_p3hVRWNy2zUuAuhJVDdumu3l9IDlFlo,4601
|
|
4
|
+
nacos_toolkit/merger.py,sha256=VyjnPqwyVSSHB0vi6Ikhj5ACpemdlAEP0jMd_KBMtKo,642
|
|
5
|
+
nacos_toolkit/parser.py,sha256=M7f6sKS-Zp7wHjeFG_1-Ql5CoDDUJZZVFSQq8pMd1Hg,741
|
|
6
|
+
nacos_toolkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
nacos_toolkit/template.py,sha256=lpwRZDhbZBJBfO0r6AtM2x6RI5ZXaP3QOFI2pUQ67N8,4143
|
|
8
|
+
nacos_toolkit/utils.py,sha256=66fTRiAVr8Uews4LauNRDNZjFMQDdRxR2y9wNdEP5Eg,3083
|
|
9
|
+
nacos_toolkit-0.1.0.dist-info/WHEEL,sha256=bEhYrD-rjlF0iRRHiAnfJ0mEjMsRwm29hhDD7yRgWCY,80
|
|
10
|
+
nacos_toolkit-0.1.0.dist-info/METADATA,sha256=zq9Ha5xMyRySijnTCfTOre3L5T8JVkgQX_jLYG7yFoc,7238
|
|
11
|
+
nacos_toolkit-0.1.0.dist-info/RECORD,,
|