apollo-config-client 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,30 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ build/
7
+ dist/
8
+ .eggs/
9
+
10
+ # Virtual envs
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Test / coverage
16
+ .pytest_cache/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # Local config
21
+ .env
22
+ .env.*
23
+ !.env.example
24
+
25
+ # Apollo local cache
26
+ .apollo_cache/
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jiafan
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,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: apollo-config-client
3
+ Version: 0.1.0
4
+ Summary: A lightweight, synchronous Apollo config-center client: long-polling hot reload, local cache failover, and access-key HMAC signing. Only depends on requests.
5
+ Project-URL: Homepage, https://github.com/Jiafan/apollo-config-client
6
+ Project-URL: Repository, https://github.com/Jiafan/apollo-config-client
7
+ Project-URL: Documentation, https://github.com/Jiafan/apollo-config-client#readme
8
+ Author: Jiafan
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: apollo,config,configuration,hot-reload,long-polling
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: requests>=2.31.0
22
+ Provides-Extra: all
23
+ Requires-Dist: oracledb>=2.0.0; extra == 'all'
24
+ Requires-Dist: pydantic>=2.5.0; extra == 'all'
25
+ Requires-Dist: python-dotenv>=1.0.0; extra == 'all'
26
+ Provides-Extra: dotenv
27
+ Requires-Dist: python-dotenv>=1.0.0; extra == 'dotenv'
28
+ Provides-Extra: models
29
+ Requires-Dist: pydantic>=2.5.0; extra == 'models'
30
+ Provides-Extra: oracle
31
+ Requires-Dist: oracledb>=2.0.0; extra == 'oracle'
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
34
+ Requires-Dist: requests-mock>=1.11.0; extra == 'test'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # apollo-config-client
38
+
39
+ 一个**轻量、同步、贴近 Apollo 官方 HTTP API** 的 Python 配置中心客户端。
40
+
41
+ > 直接基于 Apollo 开放的 HTTP 接口实现,不依赖任何第三方 Apollo 专用客户端,版本兼容性最好,且对热更新逻辑完全可控。官方 Java / .NET 客户端底层使用的也是同一套接口,可稳定对接 Apollo 1.6 – 2.4.1。
42
+
43
+ ## 特性
44
+
45
+ | 能力 | 说明 |
46
+ |------|------|
47
+ | 长轮询热更新 | 后台守护线程长轮询 `notifications/v2`,配置发布后**秒级生效**,并给出 key 级 diff |
48
+ | 本地容灾缓存 | 每次成功拉取都落盘;Apollo 全部不可用时,应用仍可用上一次的配置启动 |
49
+ | 访问密钥签名 | 兼容 Apollo 1.6+ 的 `Access Key` HMAC-SHA1 签名(匿名访问同样支持) |
50
+ | 类型化读取 | `get_typed(namespace, Model)` 用任意模型类(如 pydantic)解析配置 |
51
+ | 极简依赖 | **核心仅依赖 `requests`**;pydantic 模型与 Oracle 助手都是可选 extra |
52
+
53
+ ## 安装
54
+
55
+ ```bash
56
+ pip install apollo-config-client # 核心(仅 requests)
57
+ pip install apollo-config-client[models] # + pydantic 领域模型 DatabaseConfig / LdapConfig
58
+ pip install apollo-config-client[oracle] # + init_oracle_thick_mode 助手
59
+ pip install apollo-config-client[all] # 以上全部
60
+ ```
61
+
62
+ > 发布前请先在 https://pypi.org 确认包名 `apollo-config-client` 可用;若被占用,备选 `apolloc` / `apollo-config`。
63
+
64
+ ## 快速开始
65
+
66
+ ```python
67
+ from apollo_config import create_config_manager_from_env
68
+
69
+ # 读环境变量 APOLLO_APP_ID / APOLLO_CONFIG_URL / APOLLO_NAMESPACES ...
70
+ cm = create_config_manager_from_env()
71
+
72
+ # 普通读取
73
+ cm.get_configs("application")
74
+ cm.get_value("feature_flag", "off")
75
+
76
+ # 订阅 namespace 变更,配置一发布即刻拿到 key 级 diff
77
+ @cm.on_namespace_change("database")
78
+ def _on_db_change(namespace, changes):
79
+ # 这里只做「置空 / 标记失效」等轻量操作,
80
+ # 真正的资源重建放到请求路径上惰性执行(避免回调里做重 IO)
81
+ rebuild_connection_pool_lazily()
82
+ ```
83
+
84
+ 显式参数创建(不走环境变量):
85
+
86
+ ```python
87
+ from apollo_config import create_config_manager
88
+
89
+ cm = create_config_manager(
90
+ app_id="my-app",
91
+ config_url="http://apollo-config-service:8080",
92
+ namespaces=["application", "database"],
93
+ access_key_secret="38fdae497a324263a5ad81aa387deee3", # 可选
94
+ )
95
+ ```
96
+
97
+ ### 用 pydantic 模型读取(可选 extra `[models]`)
98
+
99
+ ```python
100
+ from apollo_config.contrib import DatabaseConfig
101
+
102
+ cfg = cm.get_typed("database", DatabaseConfig)
103
+ engine = create_engine(cfg.connection_url(), pool_size=cfg.pool_size)
104
+ ```
105
+
106
+ ## 设计要点
107
+
108
+ 1. **直接调用 Apollo 开放 HTTP API**(官方客户端同款接口):
109
+ - 配置拉取:`GET {config_url}/configs/{appId}/{cluster}/{namespace}`
110
+ - 变更通知:`GET {config_url}/notifications/v2`(HTTP 长轮询,秒级生效)
111
+ 2. **热更新机制**:后台守护线程长轮询 `notifications/v2`,一旦 Apollo 发布新配置,
112
+ 客户端拉取最新值并计算 key 级 diff,触发业务注册的回调。
113
+ 3. **本地容灾缓存**:每次成功拉取都会落盘到缓存目录;即使 Apollo 全部不可用,
114
+ 应用也能用上一次的配置正常启动。
115
+ 4. **访问密钥签名**:配置 `access_key_secret` 后,对 `configs` 与 `notifications/v2`
116
+ 两类请求统一做 HMAC-SHA1 加签;留空则走匿名访问,行为完全一致。
117
+
118
+ ## 环境变量(用于 `create_config_manager_from_env`)
119
+
120
+ | 变量 | 说明 | 默认 |
121
+ |------|------|------|
122
+ | `APOLLO_APP_ID` | 应用 AppId | `apollo-app` |
123
+ | `APOLLO_CONFIG_URL` | Config Service 地址(非 Portal 的 8070) | `http://localhost:8080` |
124
+ | `APOLLO_CLUSTER` | 集群 | `default` |
125
+ | `APOLLO_NAMESPACES` | 逗号分隔的 namespace | `application` |
126
+ | `APOLLO_ENV` | 环境名 | 空 |
127
+ | `APOLLO_ACCESS_KEY_SECRET` | 访问密钥(可选) | 空 |
128
+ | `APOLLO_CACHE_DIR` | 本地缓存目录 | `.apollo_cache` |
129
+ | `ORACLE_INSTANT_CLIENT` | Oracle Instant Client 路径(仅 11g 需要) | 空 |
130
+
131
+ ## 示例应用
132
+
133
+ `examples/` 下提供 Flask / FastAPI 两个完整示例(DB 连接池热重建):
134
+
135
+ ```bash
136
+ cd examples
137
+ pip install -r requirements.txt # 会以 editable 方式安装本库
138
+ cp ../.env.example .env # 按需修改 Apollo 地址 / AppId
139
+ python flask_app.py # http://localhost:5000/config/database
140
+ # 或 python fastapi_app.py
141
+ ```
142
+
143
+ ## 与 flask_apollo_lab 的关系
144
+
145
+ 本项目(库)原是从 `flask_apollo_lab` 中抽离出的通用部分(`apollo/` + `config/`)。
146
+ `flask_apollo_lab` 作为**消费方 / dogfooding 示例**保留,可改为 `pip install apollo-config-client`
147
+ 并 `from apollo_config import ...`,从而把"拷贝两个包"变成一行导入。
148
+
149
+ ## 生产建议
150
+
151
+ - **脱敏**:`password` / `bind_password` 等建议标记为 Apollo「私密配置」,对外接口务必脱敏(见 `public_dict()`)。
152
+ - **优雅关闭**:进程退出前调用 `config_manager.stop()` 停止长轮询线程(示例在 `settings.py` 用 `atexit` 注册)。
153
+ - **回调要快**:变更回调里只做「置空 / 标记失效」这类轻量操作,真正的重建放到请求路径上惰性执行。
154
+ - **本地缓存目录** `.apollo_cache/` 应加入 `.gitignore`,不要提交到代码库。
155
+
156
+ ## Roadmap
157
+
158
+ - [ ] 多 Config Service 端点 / HA 故障转移(当前为单 `config_url`)
159
+ - [ ] 可选的 async wrapper(基于 `httpx` 或线程池),不进核心
160
+ - [ ] 配置变更事件的同步原语(如 `watch()` 返回最新值的上下文管理器)
161
+
162
+ ## License
163
+
164
+ [MIT](LICENSE)
@@ -0,0 +1,128 @@
1
+ # apollo-config-client
2
+
3
+ 一个**轻量、同步、贴近 Apollo 官方 HTTP API** 的 Python 配置中心客户端。
4
+
5
+ > 直接基于 Apollo 开放的 HTTP 接口实现,不依赖任何第三方 Apollo 专用客户端,版本兼容性最好,且对热更新逻辑完全可控。官方 Java / .NET 客户端底层使用的也是同一套接口,可稳定对接 Apollo 1.6 – 2.4.1。
6
+
7
+ ## 特性
8
+
9
+ | 能力 | 说明 |
10
+ |------|------|
11
+ | 长轮询热更新 | 后台守护线程长轮询 `notifications/v2`,配置发布后**秒级生效**,并给出 key 级 diff |
12
+ | 本地容灾缓存 | 每次成功拉取都落盘;Apollo 全部不可用时,应用仍可用上一次的配置启动 |
13
+ | 访问密钥签名 | 兼容 Apollo 1.6+ 的 `Access Key` HMAC-SHA1 签名(匿名访问同样支持) |
14
+ | 类型化读取 | `get_typed(namespace, Model)` 用任意模型类(如 pydantic)解析配置 |
15
+ | 极简依赖 | **核心仅依赖 `requests`**;pydantic 模型与 Oracle 助手都是可选 extra |
16
+
17
+ ## 安装
18
+
19
+ ```bash
20
+ pip install apollo-config-client # 核心(仅 requests)
21
+ pip install apollo-config-client[models] # + pydantic 领域模型 DatabaseConfig / LdapConfig
22
+ pip install apollo-config-client[oracle] # + init_oracle_thick_mode 助手
23
+ pip install apollo-config-client[all] # 以上全部
24
+ ```
25
+
26
+ > 发布前请先在 https://pypi.org 确认包名 `apollo-config-client` 可用;若被占用,备选 `apolloc` / `apollo-config`。
27
+
28
+ ## 快速开始
29
+
30
+ ```python
31
+ from apollo_config import create_config_manager_from_env
32
+
33
+ # 读环境变量 APOLLO_APP_ID / APOLLO_CONFIG_URL / APOLLO_NAMESPACES ...
34
+ cm = create_config_manager_from_env()
35
+
36
+ # 普通读取
37
+ cm.get_configs("application")
38
+ cm.get_value("feature_flag", "off")
39
+
40
+ # 订阅 namespace 变更,配置一发布即刻拿到 key 级 diff
41
+ @cm.on_namespace_change("database")
42
+ def _on_db_change(namespace, changes):
43
+ # 这里只做「置空 / 标记失效」等轻量操作,
44
+ # 真正的资源重建放到请求路径上惰性执行(避免回调里做重 IO)
45
+ rebuild_connection_pool_lazily()
46
+ ```
47
+
48
+ 显式参数创建(不走环境变量):
49
+
50
+ ```python
51
+ from apollo_config import create_config_manager
52
+
53
+ cm = create_config_manager(
54
+ app_id="my-app",
55
+ config_url="http://apollo-config-service:8080",
56
+ namespaces=["application", "database"],
57
+ access_key_secret="38fdae497a324263a5ad81aa387deee3", # 可选
58
+ )
59
+ ```
60
+
61
+ ### 用 pydantic 模型读取(可选 extra `[models]`)
62
+
63
+ ```python
64
+ from apollo_config.contrib import DatabaseConfig
65
+
66
+ cfg = cm.get_typed("database", DatabaseConfig)
67
+ engine = create_engine(cfg.connection_url(), pool_size=cfg.pool_size)
68
+ ```
69
+
70
+ ## 设计要点
71
+
72
+ 1. **直接调用 Apollo 开放 HTTP API**(官方客户端同款接口):
73
+ - 配置拉取:`GET {config_url}/configs/{appId}/{cluster}/{namespace}`
74
+ - 变更通知:`GET {config_url}/notifications/v2`(HTTP 长轮询,秒级生效)
75
+ 2. **热更新机制**:后台守护线程长轮询 `notifications/v2`,一旦 Apollo 发布新配置,
76
+ 客户端拉取最新值并计算 key 级 diff,触发业务注册的回调。
77
+ 3. **本地容灾缓存**:每次成功拉取都会落盘到缓存目录;即使 Apollo 全部不可用,
78
+ 应用也能用上一次的配置正常启动。
79
+ 4. **访问密钥签名**:配置 `access_key_secret` 后,对 `configs` 与 `notifications/v2`
80
+ 两类请求统一做 HMAC-SHA1 加签;留空则走匿名访问,行为完全一致。
81
+
82
+ ## 环境变量(用于 `create_config_manager_from_env`)
83
+
84
+ | 变量 | 说明 | 默认 |
85
+ |------|------|------|
86
+ | `APOLLO_APP_ID` | 应用 AppId | `apollo-app` |
87
+ | `APOLLO_CONFIG_URL` | Config Service 地址(非 Portal 的 8070) | `http://localhost:8080` |
88
+ | `APOLLO_CLUSTER` | 集群 | `default` |
89
+ | `APOLLO_NAMESPACES` | 逗号分隔的 namespace | `application` |
90
+ | `APOLLO_ENV` | 环境名 | 空 |
91
+ | `APOLLO_ACCESS_KEY_SECRET` | 访问密钥(可选) | 空 |
92
+ | `APOLLO_CACHE_DIR` | 本地缓存目录 | `.apollo_cache` |
93
+ | `ORACLE_INSTANT_CLIENT` | Oracle Instant Client 路径(仅 11g 需要) | 空 |
94
+
95
+ ## 示例应用
96
+
97
+ `examples/` 下提供 Flask / FastAPI 两个完整示例(DB 连接池热重建):
98
+
99
+ ```bash
100
+ cd examples
101
+ pip install -r requirements.txt # 会以 editable 方式安装本库
102
+ cp ../.env.example .env # 按需修改 Apollo 地址 / AppId
103
+ python flask_app.py # http://localhost:5000/config/database
104
+ # 或 python fastapi_app.py
105
+ ```
106
+
107
+ ## 与 flask_apollo_lab 的关系
108
+
109
+ 本项目(库)原是从 `flask_apollo_lab` 中抽离出的通用部分(`apollo/` + `config/`)。
110
+ `flask_apollo_lab` 作为**消费方 / dogfooding 示例**保留,可改为 `pip install apollo-config-client`
111
+ 并 `from apollo_config import ...`,从而把"拷贝两个包"变成一行导入。
112
+
113
+ ## 生产建议
114
+
115
+ - **脱敏**:`password` / `bind_password` 等建议标记为 Apollo「私密配置」,对外接口务必脱敏(见 `public_dict()`)。
116
+ - **优雅关闭**:进程退出前调用 `config_manager.stop()` 停止长轮询线程(示例在 `settings.py` 用 `atexit` 注册)。
117
+ - **回调要快**:变更回调里只做「置空 / 标记失效」这类轻量操作,真正的重建放到请求路径上惰性执行。
118
+ - **本地缓存目录** `.apollo_cache/` 应加入 `.gitignore`,不要提交到代码库。
119
+
120
+ ## Roadmap
121
+
122
+ - [ ] 多 Config Service 端点 / HA 故障转移(当前为单 `config_url`)
123
+ - [ ] 可选的 async wrapper(基于 `httpx` 或线程池),不进核心
124
+ - [ ] 配置变更事件的同步原语(如 `watch()` 返回最新值的上下文管理器)
125
+
126
+ ## License
127
+
128
+ [MIT](LICENSE)
@@ -0,0 +1,44 @@
1
+ """apollo-config-client:一个轻量、同步、贴近 Apollo 官方 HTTP API 的 Python 配置客户端。
2
+
3
+ 特性:
4
+ - 长轮询(long polling)热更新,秒级生效,带 key 级 diff 回调。
5
+ - 本地磁盘缓存,Apollo 不可用时应用仍可用上一次配置启动(容灾)。
6
+ - 访问密钥(Access Key)HMAC-SHA1 签名,兼容 Apollo 1.6+。
7
+ - 极简依赖:核心仅依赖 `requests`;pydantic 模型与 Oracle 助手为可选 extra。
8
+
9
+ 典型用法::
10
+
11
+ from apollo_config import create_config_manager_from_env
12
+
13
+ cm = create_config_manager_from_env()
14
+ cm.get_configs("application") # 读取普通 namespace
15
+ cm.get_value("feature_flag", "off") # 读取单个 key
16
+
17
+ @cm.on_namespace_change("database")
18
+ def _on_db_change(ns, changes):
19
+ ... # 配置变更时惰性重建连接池等可变资源
20
+
21
+ 公开 API:
22
+ - :class:`ApolloClient`:底层 HTTP 客户端(长轮询 / 缓存 / 签名)。
23
+ - :class:`ConfigManager`:按 namespace 订阅变更的二次封装 + 通用读取。
24
+ - :func:`create_config_manager`:显式参数创建。
25
+ - :func:`create_config_manager_from_env`:读环境变量创建(可选 dotenv)。
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from apollo_config.client import ApolloClient
30
+ from apollo_config.manager import ConfigManager
31
+ from apollo_config._factory import (
32
+ create_config_manager,
33
+ create_config_manager_from_env,
34
+ )
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "ApolloClient",
40
+ "ConfigManager",
41
+ "create_config_manager",
42
+ "create_config_manager_from_env",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,90 @@
1
+ """工厂函数:构建 ConfigManager。
2
+
3
+ 库只提供工厂,不提供全局单例(避免与应用框架的生命周期冲突)。
4
+ 应用侧(如 Flask / FastAPI 的 settings 模块)可自行决定是否缓存为单例。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import List, Optional
10
+
11
+ from apollo_config.manager import ConfigManager
12
+
13
+
14
+ def _env_namespaces() -> List[str]:
15
+ """运行时读取 APOLLO_NAMESPACES,避免模块导入时缓存导致后续设置不生效。"""
16
+ return [
17
+ n.strip()
18
+ for n in os.getenv("APOLLO_NAMESPACES", "application").split(",")
19
+ if n.strip()
20
+ ]
21
+
22
+
23
+ def create_config_manager(
24
+ app_id: str,
25
+ config_url: str,
26
+ cluster: str = "default",
27
+ namespaces: Optional[List[str]] = None,
28
+ env: Optional[str] = None,
29
+ access_key_secret: Optional[str] = None,
30
+ cache_dir: str = ".apollo_cache",
31
+ auto_start: bool = True,
32
+ poll_timeout: int = 70,
33
+ request_timeout: int = 5,
34
+ poll_interval: float = 2.0,
35
+ max_retry_backoff: float = 30.0,
36
+ ) -> ConfigManager:
37
+ """以显式参数创建 ConfigManager。
38
+
39
+ ``auto_start=True``(默认)时立即拉取一次全量配置并启动后台长轮询线程。
40
+ """
41
+ mgr = ConfigManager(
42
+ app_id=app_id,
43
+ config_url=config_url,
44
+ cluster=cluster,
45
+ namespaces=namespaces,
46
+ env=env,
47
+ access_key_secret=access_key_secret,
48
+ cache_dir=cache_dir,
49
+ poll_timeout=poll_timeout,
50
+ request_timeout=request_timeout,
51
+ poll_interval=poll_interval,
52
+ max_retry_backoff=max_retry_backoff,
53
+ )
54
+ if auto_start:
55
+ mgr.start()
56
+ return mgr
57
+
58
+
59
+ def create_config_manager_from_env(auto_start: bool = True) -> ConfigManager:
60
+ """从环境变量构建 ConfigManager。
61
+
62
+ 读取以下环境变量::
63
+
64
+ APOLLO_APP_ID 应用 AppId(默认 "apollo-app")
65
+ APOLLO_CONFIG_URL Config Service 地址(默认 http://localhost:8080)
66
+ APOLLO_CLUSTER 集群(默认 "default")
67
+ APOLLO_NAMESPACES 逗号分隔的 namespace 列表(默认 "application")
68
+ APOLLO_ENV 环境名(默认 None)
69
+ APOLLO_ACCESS_KEY_SECRET 访问密钥(默认 None,留空走匿名)
70
+ APOLLO_CACHE_DIR 本地缓存目录(默认 ".apollo_cache")
71
+
72
+ 若已安装 ``python-dotenv``,则自动调用 ``load_dotenv()``(未安装则跳过,不报错)。
73
+ """
74
+ try:
75
+ from dotenv import load_dotenv
76
+
77
+ load_dotenv()
78
+ except ImportError:
79
+ pass
80
+
81
+ return create_config_manager(
82
+ app_id=os.getenv("APOLLO_APP_ID", "apollo-app"),
83
+ config_url=os.getenv("APOLLO_CONFIG_URL", "http://localhost:8080"),
84
+ cluster=os.getenv("APOLLO_CLUSTER", "default"),
85
+ namespaces=_env_namespaces(),
86
+ env=os.getenv("APOLLO_ENV"),
87
+ access_key_secret=os.getenv("APOLLO_ACCESS_KEY_SECRET"),
88
+ cache_dir=os.getenv("APOLLO_CACHE_DIR", ".apollo_cache"),
89
+ auto_start=auto_start,
90
+ )
@@ -0,0 +1,248 @@
1
+ """Apollo 配置中心 HTTP 客户端(兼容 Apollo 2.x,含长轮询热更新与本地容灾缓存)。
2
+
3
+ 直接基于 Apollo 开放的 HTTP 接口实现,不依赖任何第三方 Apollo 专用客户端:
4
+ - 配置拉取: GET {config_url}/configs/{appId}/{cluster}/{namespace}
5
+ - 变更通知: GET {config_url}/notifications/v2 (HTTP 长轮询)
6
+ 官方 Java / .NET 客户端底层使用的也是同一套接口,因此本实现可稳定对接 Apollo 2.4.1。
7
+
8
+ 特性:
9
+ 1. 长轮询(long polling)监听配置变更,秒级生效(热更新)。
10
+ 2. 变更回调:拿到 namespace 的 key 级 diff,方便业务层做热重建。
11
+ 3. 本地缓存:每次成功拉取都会落盘,Apollo 不可用时应用仍能用上次配置启动。
12
+ 4. 线程安全的运行时配置快照,供业务直接读取。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import logging
21
+ import threading
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Dict, List, Optional
25
+
26
+ import requests
27
+ from requests import Request
28
+
29
+ logger = logging.getLogger("apollo")
30
+
31
+ # 变更回调签名: (namespace: str, changes: Dict[key, {"old":..., "new":...}])
32
+ ChangeCallback = Callable[[str, Dict[str, Any]], None]
33
+
34
+
35
+ class ApolloClient:
36
+ def __init__(
37
+ self,
38
+ app_id: str,
39
+ config_url: str,
40
+ cluster: str = "default",
41
+ namespaces: Optional[List[str]] = None,
42
+ env: Optional[str] = None,
43
+ client_ip: Optional[str] = None,
44
+ access_key_secret: Optional[str] = None,
45
+ cache_dir: str = ".apollo_cache",
46
+ poll_timeout: int = 70,
47
+ request_timeout: int = 5,
48
+ poll_interval: float = 2.0,
49
+ max_retry_backoff: float = 30.0,
50
+ ) -> None:
51
+ self.app_id = app_id
52
+ self.config_url = config_url.rstrip("/")
53
+ self.cluster = cluster
54
+ self.env = env
55
+ self.namespaces = namespaces or ["application"]
56
+ self.client_ip = client_ip
57
+ # 访问密钥(可选):配了则对所有请求做 HMAC-SHA1 签名,否则走匿名访问。
58
+ self.access_key_secret = access_key_secret
59
+ self.cache_dir = Path(cache_dir)
60
+ self.poll_timeout = poll_timeout # 略大于 Apollo 服务端长轮询超时(默认 60s)
61
+ self.request_timeout = request_timeout
62
+ self.poll_interval = poll_interval
63
+ self.max_retry_backoff = max_retry_backoff
64
+
65
+ # 运行时状态(加锁保证线程安全)
66
+ self._lock = threading.RLock()
67
+ self._configs: Dict[str, Dict[str, str]] = {}
68
+ self._release_keys: Dict[str, str] = {}
69
+ self._notification_ids: Dict[str, int] = {ns: -1 for ns in self.namespaces}
70
+
71
+ self._callbacks: List[ChangeCallback] = []
72
+ self._running = False
73
+ self._thread: Optional[threading.Thread] = None
74
+
75
+ # 启动前先装载本地缓存,保证 Apollo 不可用时应用仍可启动
76
+ for ns in self.namespaces:
77
+ cached = self._load_cache(ns)
78
+ if cached:
79
+ with self._lock:
80
+ self._configs[ns] = cached.get("configurations", {})
81
+ self._release_keys[ns] = cached.get("releaseKey", "")
82
+ logger.info("从本地缓存恢复 namespace=%s 的配置", ns)
83
+
84
+ # ------------------------- 对外 API -------------------------
85
+ def register_callback(self, cb: ChangeCallback) -> None:
86
+ """注册全局配置变更回调。"""
87
+ self._callbacks.append(cb)
88
+
89
+ def get_configs(self, namespace: str = "application") -> Dict[str, str]:
90
+ with self._lock:
91
+ return dict(self._configs.get(namespace, {}))
92
+
93
+ def get_value(self, key: str, default: Optional[str] = None, namespace: str = "application") -> Optional[str]:
94
+ return self.get_configs(namespace).get(key, default)
95
+
96
+ def start(self) -> None:
97
+ """拉取一次全量配置并启动后台长轮询线程。"""
98
+ if self._running:
99
+ return
100
+ for ns in self.namespaces:
101
+ try:
102
+ self._fetch_namespace(ns)
103
+ except Exception as exc: # noqa: BLE001
104
+ logger.warning("启动时拉取 namespace=%s 失败(将使用本地缓存): %s", ns, exc)
105
+ self._running = True
106
+ self._thread = threading.Thread(target=self._long_poll, name="apollo-long-poll", daemon=True)
107
+ self._thread.start()
108
+ logger.info("Apollo 长轮询已启动, appId=%s namespaces=%s", self.app_id, self.namespaces)
109
+
110
+ def stop(self) -> None:
111
+ self._running = False
112
+ if self._thread:
113
+ self._thread.join(timeout=5)
114
+
115
+ # ------------------------- 内部实现 -------------------------
116
+ def _auth_headers(self, path_with_query: str) -> Dict[str, str]:
117
+ """Apollo 1.6+ 客户端访问密钥签名。
118
+
119
+ 明文 = 毫秒时间戳 + "\\n" + pathWithQuery;HMAC-SHA1 + Base64。
120
+ 通过 Timestamp / Authorization 两个头传递(无独立 Signature 头)。
121
+ 未配置 access_key_secret 时返回空 dict(匿名访问)。
122
+ """
123
+ if not self.access_key_secret:
124
+ return {}
125
+ timestamp = str(int(time.time() * 1000))
126
+ plain = f"{timestamp}\n{path_with_query}"
127
+ digest = hmac.new(
128
+ self.access_key_secret.encode("utf-8"), plain.encode("utf-8"), hashlib.sha1
129
+ ).digest()
130
+ signature = base64.b64encode(digest).decode("utf-8")
131
+ return {
132
+ "Timestamp": timestamp,
133
+ "Authorization": f"Apollo {self.app_id}:{signature}",
134
+ }
135
+
136
+ def _signed_get(self, url: str, params: Dict[str, Any], timeout: int) -> "requests.Response":
137
+ """构造带签名的 GET 请求。
138
+
139
+ 用 Request.prepare() 得到最终 pathWithQuery,确保签名用的 path 与
140
+ 实际发出的 URL 完全一致(含查询串的编码)。
141
+ """
142
+ prepared = Request("GET", url, params=params).prepare()
143
+ headers = self._auth_headers(prepared.path_url)
144
+ return requests.get(prepared.url, headers=headers, timeout=timeout)
145
+
146
+ def _fetch_namespace(self, namespace: str) -> Dict[str, Any]:
147
+ url = f"{self.config_url}/configs/{self.app_id}/{self.cluster}/{namespace}"
148
+ params: Dict[str, Any] = {}
149
+ release_key = self._release_keys.get(namespace)
150
+ if release_key:
151
+ params["releaseKey"] = release_key
152
+ if self.client_ip:
153
+ params["ip"] = self.client_ip
154
+
155
+ resp = self._signed_get(url, params, self.request_timeout)
156
+ resp.raise_for_status()
157
+ data = resp.json()
158
+ new_configs = data.get("configurations", {})
159
+ new_release = data.get("releaseKey", "")
160
+
161
+ with self._lock:
162
+ old_configs = self._configs.get(namespace, {})
163
+ changes = self._diff(old_configs, new_configs)
164
+ self._configs[namespace] = new_configs
165
+ self._release_keys[namespace] = new_release
166
+
167
+ self._save_cache(namespace, new_release, new_configs)
168
+
169
+ if changes:
170
+ logger.info("namespace=%s 配置变更: %s", namespace, list(changes.keys()))
171
+ for cb in self._callbacks:
172
+ try:
173
+ cb(namespace, changes)
174
+ except Exception as exc: # noqa: BLE001
175
+ logger.exception("配置变更回调执行失败: %s", exc)
176
+ return changes
177
+
178
+ @staticmethod
179
+ def _diff(old: Dict[str, str], new: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
180
+ changes: Dict[str, Dict[str, Any]] = {}
181
+ for k in set(old) | set(new):
182
+ ov, nv = old.get(k), new.get(k)
183
+ if ov != nv:
184
+ changes[k] = {"old": ov, "new": nv}
185
+ return changes
186
+
187
+ def _long_poll(self) -> None:
188
+ backoff = self.poll_interval
189
+ while self._running:
190
+ try:
191
+ notifications = [
192
+ {"namespaceName": ns, "notificationId": self._notification_ids.get(ns, -1)}
193
+ for ns in self.namespaces
194
+ ]
195
+ url = f"{self.config_url}/notifications/v2"
196
+ params = {
197
+ "appId": self.app_id,
198
+ "cluster": self.cluster,
199
+ "notifications": json.dumps(notifications),
200
+ }
201
+ resp = self._signed_get(url, params, self.poll_timeout)
202
+ if resp.status_code == 304:
203
+ continue
204
+ resp.raise_for_status()
205
+ items = resp.json() or []
206
+ if not items:
207
+ backoff = self.poll_interval
208
+ continue
209
+ for item in items:
210
+ ns = item["namespaceName"]
211
+ nid = item["notificationId"]
212
+ if nid > self._notification_ids.get(ns, -1):
213
+ self._notification_ids[ns] = nid
214
+ try:
215
+ self._fetch_namespace(ns)
216
+ except Exception as exc: # noqa: BLE001
217
+ logger.warning("长轮询触发后拉取 namespace=%s 失败: %s", ns, exc)
218
+ backoff = self.poll_interval
219
+ except requests.exceptions.Timeout:
220
+ # 长轮询超时(服务端 60s 无变更)是正常现象,立即继续下一轮
221
+ continue
222
+ except Exception as exc: # noqa: BLE001
223
+ logger.warning("Apollo 长轮询异常, %ss 后重试: %s", backoff, exc)
224
+ time.sleep(backoff)
225
+ backoff = min(backoff * 2, self.max_retry_backoff)
226
+
227
+ # ------------------------- 本地缓存 -------------------------
228
+ def _cache_path(self, namespace: str) -> Path:
229
+ d = self.cache_dir / self.app_id / (self.env or "default")
230
+ d.mkdir(parents=True, exist_ok=True)
231
+ safe = namespace.replace("/", "_")
232
+ return d / f"{safe}.json"
233
+
234
+ def _save_cache(self, namespace: str, release_key: str, configs: Dict[str, str]) -> None:
235
+ try:
236
+ payload = {"releaseKey": release_key, "configurations": configs}
237
+ self._cache_path(namespace).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
238
+ except OSError as exc: # noqa: BLE001
239
+ logger.warning("写入本地缓存失败 namespace=%s: %s", namespace, exc)
240
+
241
+ def _load_cache(self, namespace: str) -> Optional[Dict[str, Any]]:
242
+ try:
243
+ p = self._cache_path(namespace)
244
+ if p.exists():
245
+ return json.loads(p.read_text(encoding="utf-8"))
246
+ except (OSError, json.JSONDecodeError) as exc: # noqa: BLE001
247
+ logger.warning("读取本地缓存失败 namespace=%s: %s", namespace, exc)
248
+ return None
@@ -0,0 +1,145 @@
1
+ """可选领域辅助(需要对应的 extra 依赖)。
2
+
3
+ 安装方式::
4
+
5
+ pip install apollo-config-client[models] # DatabaseConfig / LdapConfig(依赖 pydantic)
6
+ pip install apollo-config-client[oracle] # init_oracle_thick_mode(依赖 oracledb)
7
+ pip install apollo-config-client[all] # 以上全部
8
+
9
+ 核心库(``apollo_config``)刻意不依赖这些领域模型;这里仅作为「拿来即用」的参考实现,
10
+ 你完全可以用自己的项目模型调用 ``ConfigManager.get_typed(namespace, YourModel)``。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ from typing import Any, Dict
17
+
18
+ try:
19
+ from pydantic import BaseModel, ConfigDict
20
+ except ImportError as exc: # pragma: no cover
21
+ raise ImportError(
22
+ "apollo_config.contrib 的模型需要 pydantic,请执行 "
23
+ "pip install apollo-config-client[models]"
24
+ ) from exc
25
+
26
+ logger = logging.getLogger("apollo.contrib")
27
+
28
+
29
+ class DatabaseConfig(BaseModel):
30
+ model_config = ConfigDict(extra="ignore")
31
+
32
+ # 数据库类型 / 驱动:默认 Oracle 11g + python-oracledb
33
+ dialect: str = "oracle" # oracle / postgresql / mysql
34
+ driver: str = "oracledb" # Oracle: oracledb(推荐) / cx_oracle
35
+ host: str
36
+ port: int = 1521 # Oracle 默认监听端口 1521
37
+ username: str
38
+ password: str
39
+ # Oracle 连接标识:service_name 与 sid 二选一(11g 常用 service_name)
40
+ service_name: str | None = None
41
+ sid: str | None = None
42
+ pool_size: int = 10
43
+ echo: bool = False
44
+
45
+ @classmethod
46
+ def from_namespace(cls, configs: Dict[str, Any]) -> "DatabaseConfig":
47
+ # Apollo 所有值均为字符串,pydantic 在 lax 模式下自动做类型转换
48
+ return cls.model_validate(dict(configs))
49
+
50
+ def connection_url(self, redact: bool = False) -> str:
51
+ """生成 SQLAlchemy 连接串;redact=True 时隐藏密码(用于日志输出)。"""
52
+ pwd = "***" if redact else self.password
53
+ if self.dialect == "oracle":
54
+ base = f"oracle+{self.driver}://{self.username}:{pwd}@{self.host}:{self.port}"
55
+ if self.service_name:
56
+ return f"{base}/?service_name={self.service_name}"
57
+ if self.sid:
58
+ return f"{base}/{self.sid}"
59
+ return base
60
+ if self.dialect == "postgresql":
61
+ db = self.service_name or self.sid or ""
62
+ return f"postgresql+psycopg2://{self.username}:{pwd}@{self.host}:{self.port}/{db}"
63
+ if self.dialect == "mysql":
64
+ db = self.service_name or ""
65
+ return f"mysql+pymysql://{self.username}:{pwd}@{self.host}:{self.port}/{db}"
66
+ raise ValueError(f"unsupported dialect: {self.dialect}")
67
+
68
+ def public_dict(self) -> Dict[str, Any]:
69
+ """对外暴露配置时脱敏(不返回密码)。"""
70
+ return {
71
+ "dialect": self.dialect,
72
+ "driver": self.driver,
73
+ "host": self.host,
74
+ "port": self.port,
75
+ "username": self.username,
76
+ "service_name": self.service_name,
77
+ "sid": self.sid,
78
+ "pool_size": self.pool_size,
79
+ "echo": self.echo,
80
+ }
81
+
82
+
83
+ class LdapConfig(BaseModel):
84
+ model_config = ConfigDict(extra="ignore")
85
+
86
+ server: str
87
+ port: int = 389
88
+ use_ssl: bool = False
89
+ bind_dn: str
90
+ bind_password: str
91
+ base_dn: str
92
+ user_search_base: str
93
+ user_search_filter: str = "(uid={user})"
94
+ timeout: int = 5
95
+
96
+ @classmethod
97
+ def from_namespace(cls, configs: Dict[str, Any]) -> "LdapConfig":
98
+ return cls.model_validate(dict(configs))
99
+
100
+ def public_dict(self) -> Dict[str, Any]:
101
+ return {
102
+ "server": self.server,
103
+ "port": self.port,
104
+ "use_ssl": self.use_ssl,
105
+ "bind_dn": self.bind_dn,
106
+ "base_dn": self.base_dn,
107
+ "user_search_base": self.user_search_base,
108
+ "user_search_filter": self.user_search_filter,
109
+ "timeout": self.timeout,
110
+ }
111
+
112
+
113
+ def init_oracle_thick_mode(lib_dir: str | None = None) -> None:
114
+ """启用 Oracle thick 模式;重复调用安全(第二次直接跳过)。
115
+
116
+ 重要:Oracle 11g 无法使用 python-oracledb 的 thin 模式(thin 要求 Oracle DB ≥ 12.1),
117
+ 必须在创建任何连接前用 Instant Client 启用 thick 模式。
118
+
119
+ Instant Client 路径通过环境变量 ``ORACLE_INSTANT_CLIENT`` 指定,例如::
120
+
121
+ /opt/lib/instantclient-basiclite-arm-23.26.1.0.0
122
+ """
123
+ _INITIALIZED = getattr(init_oracle_thick_mode, "_initialized", False)
124
+ if _INITIALIZED:
125
+ return
126
+
127
+ try:
128
+ import oracledb
129
+ except ImportError:
130
+ logger.warning("未安装 oracledb,跳过 Oracle 驱动初始化(连接 11g 将失败)")
131
+ init_oracle_thick_mode._initialized = True
132
+ return
133
+
134
+ lib_dir = lib_dir or os.getenv("ORACLE_INSTANT_CLIENT")
135
+ if not lib_dir:
136
+ logger.info("未配置 ORACLE_INSTANT_CLIENT,使用 oracledb thin 模式(仅支持 Oracle >= 12.1,11g 无法连接)")
137
+ init_oracle_thick_mode._initialized = True
138
+ return
139
+
140
+ try:
141
+ oracledb.init_oracle_client(lib_dir=lib_dir)
142
+ logger.info("Oracle thick 模式已启用, lib_dir=%s", lib_dir)
143
+ except Exception as exc: # 已初始化会抛 DPI-1049,忽略即可
144
+ logger.info("Oracle 驱动初始化跳过(可能已初始化): %s", exc)
145
+ init_oracle_thick_mode._initialized = True
@@ -0,0 +1,94 @@
1
+ """ConfigManager:在 ApolloClient 之上做二次封装。
2
+
3
+ 职责:
4
+ - 聚合多个 namespace 的 ApolloClient;
5
+ - 提供「按 namespace 订阅变更」的回调(装饰器风格);
6
+ - 提供通用的配置读取;领域模型(如 DatabaseConfig)通过 get_typed() 接入,
7
+ 库本身不耦合任何业务模型,保持核心仅依赖 requests。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections import defaultdict
13
+ from typing import Any, Callable, Dict, List, Type, TypeVar
14
+
15
+ from apollo_config.client import ApolloClient
16
+
17
+ logger = logging.getLogger("apollo.config")
18
+
19
+ # 回调签名: (namespace: str, changes: Dict[key, {"old":..., "new":...}])
20
+ NamespaceCallback = Callable[[str, Dict], None]
21
+ T = TypeVar("T")
22
+
23
+
24
+ class ConfigManager:
25
+ def __init__(
26
+ self,
27
+ app_id: str,
28
+ config_url: str,
29
+ cluster: str = "default",
30
+ namespaces: List[str] | None = None,
31
+ env: str | None = None,
32
+ access_key_secret: str | None = None,
33
+ cache_dir: str = ".apollo_cache",
34
+ poll_timeout: int = 70,
35
+ request_timeout: int = 5,
36
+ poll_interval: float = 2.0,
37
+ max_retry_backoff: float = 30.0,
38
+ ) -> None:
39
+ self._client = ApolloClient(
40
+ app_id=app_id,
41
+ config_url=config_url,
42
+ cluster=cluster,
43
+ namespaces=namespaces,
44
+ env=env,
45
+ access_key_secret=access_key_secret,
46
+ cache_dir=cache_dir,
47
+ poll_timeout=poll_timeout,
48
+ request_timeout=request_timeout,
49
+ poll_interval=poll_interval,
50
+ max_retry_backoff=max_retry_backoff,
51
+ )
52
+ self._ns_callbacks: Dict[str, List[NamespaceCallback]] = defaultdict(list)
53
+ self._client.register_callback(self._dispatch)
54
+
55
+ # ------------------------- 生命周期 -------------------------
56
+ def start(self) -> None:
57
+ self._client.start()
58
+
59
+ def stop(self) -> None:
60
+ self._client.stop()
61
+
62
+ # ------------------------- 变更订阅 -------------------------
63
+ def _dispatch(self, namespace: str, changes: dict) -> None:
64
+ for cb in self._ns_callbacks.get(namespace, []):
65
+ try:
66
+ cb(namespace, changes)
67
+ except Exception as exc: # noqa: BLE001
68
+ logger.exception("namespace=%s 变更回调失败: %s", namespace, exc)
69
+
70
+ def on_namespace_change(self, namespace: str):
71
+ """装饰器 / 注册器:订阅某个 namespace 的变更。回调签名 (namespace, changes)。"""
72
+ def decorator(cb: NamespaceCallback) -> NamespaceCallback:
73
+ self._ns_callbacks[namespace].append(cb)
74
+ return cb
75
+ return decorator
76
+
77
+ # ------------------------- 通用读取 -------------------------
78
+ def get_value(self, key: str, default: str | None = None, namespace: str = "application") -> str | None:
79
+ return self._client.get_value(key, default, namespace)
80
+
81
+ def get_configs(self, namespace: str = "application") -> Dict[str, str]:
82
+ return self._client.get_configs(namespace)
83
+
84
+ def get_typed(self, namespace: str, model_cls: Type[T]) -> T:
85
+ """用任意模型类(如 pydantic.BaseModel 子类)解析某个 namespace 的配置。
86
+
87
+ 例::
88
+
89
+ cfg = cm.get_typed("database", DatabaseConfig)
90
+
91
+ 库不绑定任何模型库:只要 ``model_cls`` 提供 ``model_validate(dict)`` 类方法即可
92
+ (pydantic v2 天然满足)。
93
+ """
94
+ return model_cls.model_validate(dict(self.get_configs(namespace)))
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apollo-config-client"
7
+ version = "0.1.0"
8
+ description = "A lightweight, synchronous Apollo config-center client: long-polling hot reload, local cache failover, and access-key HMAC signing. Only depends on requests."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Jiafan" }]
13
+ keywords = ["apollo", "config", "configuration", "hot-reload", "long-polling"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Operating System :: OS Independent",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.31.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Jiafan/apollo-config-client"
30
+ Repository = "https://github.com/Jiafan/apollo-config-client"
31
+ Documentation = "https://github.com/Jiafan/apollo-config-client#readme"
32
+
33
+ [project.optional-dependencies]
34
+ models = ["pydantic>=2.5.0"]
35
+ oracle = ["oracledb>=2.0.0"]
36
+ dotenv = ["python-dotenv>=1.0.0"]
37
+ all = ["pydantic>=2.5.0", "oracledb>=2.0.0", "python-dotenv>=1.0.0"]
38
+ test = ["pytest>=8.0.0", "requests-mock>=1.11.0"]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["apollo_config"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = [
45
+ "apollo_config",
46
+ "tests",
47
+ "README.md",
48
+ "LICENSE",
49
+ ]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = "-q"
@@ -0,0 +1,20 @@
1
+ from apollo_config.client import ApolloClient
2
+
3
+
4
+ def test_cache_roundtrip(tmp_path):
5
+ c = ApolloClient(app_id="app", config_url="http://x", cache_dir=str(tmp_path))
6
+ c._save_cache("application", "rk1", {"a": "1", "b": "2"})
7
+
8
+ loaded = c._load_cache("application")
9
+ assert loaded == {"releaseKey": "rk1", "configurations": {"a": "1", "b": "2"}}
10
+
11
+
12
+ def test_cache_missing_returns_none(tmp_path):
13
+ c = ApolloClient(app_id="app", config_url="http://x", cache_dir=str(tmp_path))
14
+ assert c._load_cache("nope") is None
15
+
16
+
17
+ def test_cache_path_isolation_by_env(tmp_path):
18
+ c1 = ApolloClient(app_id="app", config_url="http://x", env="DEV", cache_dir=str(tmp_path))
19
+ c2 = ApolloClient(app_id="app", config_url="http://x", env="PROD", cache_dir=str(tmp_path))
20
+ assert c1._cache_path("application").parent != c2._cache_path("application").parent
File without changes
@@ -0,0 +1,16 @@
1
+ from apollo_config.client import ApolloClient
2
+
3
+
4
+ def test_diff_add_update_delete():
5
+ old = {"a": "1", "b": "2"}
6
+ new = {"b": "20", "c": "3"}
7
+ changes = ApolloClient._diff(old, new)
8
+
9
+ assert set(changes) == {"a", "b", "c"}
10
+ assert changes["a"] == {"old": "1", "new": None}
11
+ assert changes["b"] == {"old": "2", "new": "20"}
12
+ assert changes["c"] == {"old": None, "new": "3"}
13
+
14
+
15
+ def test_diff_empty_when_equal():
16
+ assert ApolloClient._diff({"a": "1"}, {"a": "1"}) == {}
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+ from apollo_config._factory import create_config_manager, create_config_manager_from_env
4
+
5
+
6
+ def test_from_env_reads_variables(monkeypatch, tmp_path):
7
+ monkeypatch.setenv("APOLLO_APP_ID", "env-app")
8
+ monkeypatch.setenv("APOLLO_CONFIG_URL", "http://env:8080")
9
+ monkeypatch.setenv("APOLLO_CLUSTER", "cluster-a")
10
+ monkeypatch.setenv("APOLLO_NAMESPACES", "application,database")
11
+
12
+ mgr = create_config_manager_from_env(auto_start=False)
13
+ assert mgr._client.app_id == "env-app"
14
+ assert mgr._client.config_url == "http://env:8080"
15
+ assert mgr._client.cluster == "cluster-a"
16
+ assert set(mgr._client.namespaces) == {"application", "database"}
17
+
18
+
19
+ def test_from_env_anonymous_by_default(monkeypatch, tmp_path):
20
+ monkeypatch.setenv("APOLLO_APP_ID", "env-app")
21
+ monkeypatch.setenv("APOLLO_CONFIG_URL", "http://env:8080")
22
+ monkeypatch.delenv("APOLLO_ACCESS_KEY_SECRET", raising=False)
23
+
24
+ mgr = create_config_manager_from_env(auto_start=False)
25
+ assert mgr._client.access_key_secret is None
26
+
27
+
28
+ def test_create_config_manager_explicit():
29
+ mgr = create_config_manager(
30
+ app_id="a", config_url="http://x", namespaces=["application"],
31
+ auto_start=False,
32
+ )
33
+ assert mgr._client.app_id == "a"
34
+ assert mgr._client.namespaces == ["application"]
@@ -0,0 +1,74 @@
1
+ import time
2
+
3
+ import requests_mock
4
+
5
+ from apollo_config.client import ApolloClient
6
+
7
+
8
+ def test_start_loads_config_and_fires_callback(tmp_path):
9
+ cfg = {
10
+ "appId": "app", "cluster": "default", "namespaceName": "application",
11
+ "configurations": {"k": "v"}, "releaseKey": "rk1",
12
+ }
13
+ captured = []
14
+ with requests_mock.Mocker() as m:
15
+ m.get("http://x/configs/app/default/application", json=cfg)
16
+ m.get("http://x/notifications/v2", json=[])
17
+ c = ApolloClient(
18
+ app_id="app", config_url="http://x", cache_dir=str(tmp_path),
19
+ poll_timeout=1, poll_interval=0.1, max_retry_backoff=1,
20
+ )
21
+ c.register_callback(lambda ns, ch: captured.append((ns, ch)))
22
+ c.start()
23
+ time.sleep(0.3)
24
+ c.stop()
25
+
26
+ assert c.get_configs("application") == {"k": "v"}
27
+ assert captured, "初始加载应触发一次变更回调"
28
+
29
+
30
+ def test_signed_requests_carry_auth_headers(tmp_path):
31
+ with requests_mock.Mocker() as m:
32
+ m.get("http://x/configs/app/default/application",
33
+ json={"configurations": {}, "releaseKey": ""})
34
+ m.get("http://x/notifications/v2", json=[])
35
+ c = ApolloClient(
36
+ app_id="app", config_url="http://x", access_key_secret="secret",
37
+ cache_dir=str(tmp_path), poll_timeout=1, poll_interval=0.1,
38
+ )
39
+ c.start()
40
+ time.sleep(0.2)
41
+ c.stop()
42
+
43
+ reqs = m.request_history
44
+ assert any(r.headers.get("Authorization", "").startswith("Apollo app:") for r in reqs)
45
+ assert any("Timestamp" in r.headers for r in reqs)
46
+
47
+
48
+ def test_long_poll_detects_notification_change(tmp_path):
49
+ cfg_initial = {"configurations": {"k": "v1"}, "releaseKey": "rk1"}
50
+ cfg_updated = {"configurations": {"k": "v2"}, "releaseKey": "rk2"}
51
+
52
+ captured = []
53
+ with requests_mock.Mocker() as m:
54
+ m.get("http://x/configs/app/default/application", [
55
+ {"json": cfg_initial},
56
+ {"json": cfg_updated},
57
+ ])
58
+ # 第一次长轮询返回一次变更通知,随后保持无变更
59
+ m.get("http://x/notifications/v2", [
60
+ {"json": [{"namespaceName": "application", "notificationId": 2}]},
61
+ {"json": []},
62
+ ])
63
+ c = ApolloClient(
64
+ app_id="app", config_url="http://x", cache_dir=str(tmp_path),
65
+ poll_timeout=1, poll_interval=0.1, max_retry_backoff=1,
66
+ )
67
+ c.register_callback(lambda ns, ch: captured.append((ns, ch)))
68
+ c.start()
69
+ time.sleep(1.0)
70
+ c.stop()
71
+
72
+ assert c.get_configs("application") == {"k": "v2"}
73
+ # 初始加载一次 + 通知触发一次
74
+ assert len(captured) >= 2
@@ -0,0 +1,27 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ from unittest import mock
5
+
6
+ from apollo_config.client import ApolloClient
7
+
8
+
9
+ def test_anonymous_has_no_auth_headers():
10
+ c = ApolloClient(app_id="app", config_url="http://x")
11
+ assert c._auth_headers("/configs/app/default/application") == {}
12
+
13
+
14
+ def test_signed_headers_shape_and_value():
15
+ c = ApolloClient(app_id="app", config_url="http://x", access_key_secret="secret")
16
+ fixed_ts = 1600000000.0
17
+ with mock.patch("time.time", return_value=fixed_ts):
18
+ headers = c._auth_headers("/configs/app/default/application")
19
+
20
+ assert headers["Timestamp"] == "1600000000000"
21
+ assert headers["Authorization"].startswith("Apollo app:")
22
+
23
+ plain = "1600000000000\n/configs/app/default/application"
24
+ expected = base64.b64encode(
25
+ hmac.new(b"secret", plain.encode("utf-8"), hashlib.sha1).digest()
26
+ ).decode("utf-8")
27
+ assert headers["Authorization"] == f"Apollo app:{expected}"