deeptrade-quant 0.15.2__tar.gz → 0.16.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.
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/CHANGELOG.md +31 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/PKG-INFO +7 -3
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/README.md +6 -2
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/__init__.py +1 -1
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli_config.py +193 -1
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/config.py +5 -1
- deeptrade_quant-0.16.0/deeptrade/core/config_io.py +296 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/pyproject.toml +1 -1
- deeptrade_quant-0.16.0/tests/cli/test_config_cmd.py +200 -0
- deeptrade_quant-0.16.0/tests/core/test_config_io.py +181 -0
- deeptrade_quant-0.15.2/tests/cli/test_config_cmd.py +0 -88
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/.gitignore +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/LICENSE +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli_data.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli_db_llm_cache.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli_plugin.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/cli_run.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/config_migrations.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/db.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/dep_installer.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/fingerprint.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/github_fetch.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/llm_client.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/llm_manager.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/llm_replay.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/logging_config.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260524_001_extend_tushare_calls.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260525_001_llm_replay_cache.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260525_002_extend_llm_calls.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260525_003_run_metadata.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260601_001_report_uploads.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/20260601_002_ths_concepts.sql +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/migrations/core/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/parallel.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/paths.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/plugin_manager.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/plugin_source.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/process_lock.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/registry.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/report_uploader.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/run_metadata.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/run_status.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/secrets.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/ths_concepts.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/core/tushare_client.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/base.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/errors.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/events.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/llm.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/plugins_api/metadata.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/deeptrade/theme.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_db_llm_cache.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_logging.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_plugin_cmd.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_routing.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_run_cmd.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/conftest.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_config.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_config_migrations.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_config_replay_keys.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_db.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_fingerprint.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_github_fetch.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_llm_client.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_llm_client_replay.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_llm_client_streaming.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_llm_manager.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_llm_replay.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_parallel_ordered.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_paths.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_plugin_dependencies.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_plugin_install.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_plugin_security.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_plugin_source.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_plugin_upgrade.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_registry.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_report_uploader.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_run_metadata_api.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_secrets.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_ths_concepts.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_tushare_classifier.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_tushare_client.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/core/test_tushare_retry_r1.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/plugins_api/test_api_version_2.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/plugins_api/test_cache_overrides_plumbing.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/plugins_api/test_errors.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/plugins_api/test_protocol.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/test_smoke.py +0 -0
- {deeptrade_quant-0.15.2 → deeptrade_quant-0.16.0}/tests/test_version_consistency.py +0 -0
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DeepTrade. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and SemVer.
|
|
4
4
|
|
|
5
|
+
## [v0.16.0] — 2026-05-30 — 框架层配置导入 / 导出
|
|
6
|
+
|
|
7
|
+
新增 `deeptrade config export` / `deeptrade config import`,用于备份、迁移、审计和复制框架层配置。范围严格限定为 `ConfigService` 管理的框架配置:`app_config` 中的非敏感键,以及 `secret_store` 中由 `is_secret_key()` 路由的框架 secret;不导出插件安装、插件私有配置、插件业务表、运行记录、LLM 调用日志、Tushare 同步状态或报告文件。
|
|
8
|
+
|
|
9
|
+
默认导出持久化配置且不写出 secret 明文,只记录 secret 的存在状态。显式使用 `--include-secrets` 时才会把 token / API key 明文写入文件;`--effective` 可导出当前进程解析后的有效值(包含默认值和环境变量覆盖),`--include-defaults` 可在持久化导出中补齐默认值。
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **服务层** `deeptrade.core.config_io`:
|
|
14
|
+
- `ConfigExportOptions` / `ConfigImportOptions` / `ConfigImportPlan`
|
|
15
|
+
- `ConfigIOService.export_payload()` / `plan_import()` / `apply_import_plan()`
|
|
16
|
+
- 文件格式 `schema_version=1`,顶层包含 `tool="deeptrade"`、`exported_at`、`mode`、`config`、`secrets`。
|
|
17
|
+
- **CLI**:
|
|
18
|
+
- `deeptrade config export [PATH] [--format json|yaml] [--include-secrets] [--effective] [--include-defaults] [--force]`
|
|
19
|
+
- `deeptrade config import PATH [--format json|yaml] [--mode merge|replace] [--include-secrets] [--dry-run] [--yes]`
|
|
20
|
+
- `PATH=-` 导出到 stdout 时只输出 payload,摘要走 stderr,便于脚本管道消费。
|
|
21
|
+
- **导入安全语义**:
|
|
22
|
+
- `merge` 默认只覆盖文件中出现的配置项;缺失项保持本地值。
|
|
23
|
+
- `replace --yes` 会删除所有框架层持久化配置和框架层 secret,再写入文件内容。
|
|
24
|
+
- 默认忽略文件中的 secret value;只有 `--include-secrets` 且 value 为非空字符串时才写入。
|
|
25
|
+
- 拒绝 masked secret(如 `********1234`)导入。
|
|
26
|
+
- 校验 `tool` / `schema_version` / key 命名空间 / Pydantic 配置值 / LLM provider 名称及环境变量归一化冲突。
|
|
27
|
+
- **测试**:
|
|
28
|
+
- 核心单元测试覆盖默认不泄露 secret、显式导出 secret、persisted/effective 差异、merge/replace、dry-run 计划、secret 导入开关、非法 key 和 provider 名称冲突。
|
|
29
|
+
- CLI 测试覆盖文件导出、已存在文件保护、stdout JSON、dry-run 不写入、导入 secret 后仍脱敏展示、replace 必须显式 `--yes`。
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
|
|
33
|
+
- 非敏感配置写入仍复用 `ConfigService.set()` / `delete()`,保持 Pydantic 校验和现有路由。
|
|
34
|
+
- Secret 写入仍复用 `ConfigService.set(secret_key, value)`,保持 keyring 优先和 plaintext fallback 行为;跨 keyring 与 DuckDB 的事务一致性不做额外承诺。
|
|
35
|
+
|
|
5
36
|
## [v0.15.2] — 2026-05-27 — `set-llm` 新增 OpenRouter base_url 预设
|
|
6
37
|
|
|
7
38
|
给交互式 `deeptrade config set-llm` 的 base_url 预设表(`cli_config.py::_DEFAULT_BASE_URLS`)补一条 OpenRouter。OpenRouter 是标准 OpenAI 兼容端点,本来就能用——base_url 不命中 `_TRANSPORT_BY_BASE_URL` 路由表,自动落到 `GenericOpenAITransport`(`thinking` 标志按契约静默丢弃)。这次纯属录入便利:provider 名取 `openrouter`(或 `openrouter-xxx`,`name.split("-")[0]` 仍命中)时自动带出 base_url,省去手填。无新增能力、无 transport 代码、不动配置 schema。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deeptrade-quant
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/ty19880929/deeptrade
|
|
6
6
|
Project-URL: Repository, https://github.com/ty19880929/deeptrade
|
|
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
|
|
43
43
|
> 📖 **在线文档**:[deeptrade.tiey.ai](https://deeptrade.tiey.ai) — 用户手册 + 开发者手册 + 官方插件目录
|
|
44
44
|
|
|
45
|
-
[](#) [](#) [](LICENSE) [](#) [](#) [](LICENSE) [](CHANGELOG.md)
|
|
46
46
|
|
|
47
47
|
## ✨ 主要特性
|
|
48
48
|
|
|
@@ -85,6 +85,8 @@ deeptrade config set-llm # 交互式增/改/删 LLM provider(
|
|
|
85
85
|
deeptrade config list-llm # 列出已配置且可用的 provider
|
|
86
86
|
deeptrade config test-llm # 对所有 provider 做连通性自检(也可加 <name> 单测)
|
|
87
87
|
deeptrade config show # 表格展示当前配置(密钥脱敏)
|
|
88
|
+
deeptrade config export deeptrade-config.json
|
|
89
|
+
deeptrade config import deeptrade-config.json --dry-run
|
|
88
90
|
|
|
89
91
|
# 可选:报告上传(v0.11+,全局开关默认关闭)
|
|
90
92
|
deeptrade config set report.upload.enabled true
|
|
@@ -94,6 +96,8 @@ deeptrade config set report.upload.token <bearer-token> # 可选;空串走
|
|
|
94
96
|
|
|
95
97
|
> **报告上传(v0.11+)**:框架提供 `ReportUploader` 公共服务,插件通过 `ctx.make_report_uploader().upload(path, plugin_name=..., trade_date=...)` 把执行报告 JSON 推到统一端点。配置键 `report.upload.{enabled,url,timeout}` 走 `app_config`,`report.upload.token` 走 `secret_store`;每次调用(含 skipped)都在 `report_uploads` 表留一行审计,token / Authorization 永不落库。上传失败永远只 WARN,不阻断 run。
|
|
96
98
|
|
|
99
|
+
> **配置导入/导出**:`deeptrade config export [PATH]` 默认只导出框架层持久化配置和 secret 存在状态,不导出密钥明文;`--include-secrets` 会把 token / API key 明文写入文件,文件应按敏感凭据保存。导入使用 `deeptrade config import PATH --dry-run` 预览,默认 `--mode merge`;`--mode replace --yes` 会先删除所有框架层持久化配置和框架层 secret,再写入文件内容。
|
|
100
|
+
|
|
97
101
|
> **LLM 复现性 & 缓存(v0.12+)**:相同 `(plugin_id, stage, provider, model, system+user prompt, profile, schema_version, input_fingerprint)` 下,重复执行直接命中已验证响应,不再向 provider 发起调用。
|
|
98
102
|
>
|
|
99
103
|
> - 配置键 `llm.replay.{enabled,write,ttl_days}` 全部走 `app_config`,默认 `enabled=true / write=true / ttl_days=null`(不过期)。
|
|
@@ -155,7 +159,7 @@ deeptrade volume-anomaly stats # 收益统计聚合
|
|
|
155
159
|
| `deeptrade init [--no-prompts]` | 建库 + 应用 core migrations |
|
|
156
160
|
| `deeptrade db init` / `db upgrade` | 显式建库 / 应用待执行迁移 |
|
|
157
161
|
| `deeptrade db llm-cache {list, purge, inspect}` (v0.13+) | LLM replay cache 横向运维:`list` 按 plugin/stage 过滤;`purge --yes --plugin/--stage/--before` 至少一个过滤器;`inspect <cache_key 前缀>` |
|
|
158
|
-
| `deeptrade config {show, set, set-tushare, set-llm, list-llm, test-llm}` | 全局配置 |
|
|
162
|
+
| `deeptrade config {show, set, set-tushare, set-llm, list-llm, test-llm, export, import}` | 全局配置 |
|
|
159
163
|
| `deeptrade plugin search [keyword] [--no-cache]` | 浏览官方注册表 |
|
|
160
164
|
| `deeptrade plugin install <SOURCE> [--ref <REF>] [--no-deps] [--reinstall-deps] [-y]` | 注册表短名 / GitHub URL / 本地路径;依赖按 PEP 508 自动解析装入框架解释器 |
|
|
161
165
|
| `deeptrade plugin list` / `info <id>` | 列表 / 详情(未安装时回退注册表条目) |
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> 📖 **在线文档**:[deeptrade.tiey.ai](https://deeptrade.tiey.ai) — 用户手册 + 开发者手册 + 官方插件目录
|
|
6
6
|
|
|
7
|
-
[](#) [](#) [](LICENSE) [](#) [](#) [](LICENSE) [](CHANGELOG.md)
|
|
8
8
|
|
|
9
9
|
## ✨ 主要特性
|
|
10
10
|
|
|
@@ -47,6 +47,8 @@ deeptrade config set-llm # 交互式增/改/删 LLM provider(
|
|
|
47
47
|
deeptrade config list-llm # 列出已配置且可用的 provider
|
|
48
48
|
deeptrade config test-llm # 对所有 provider 做连通性自检(也可加 <name> 单测)
|
|
49
49
|
deeptrade config show # 表格展示当前配置(密钥脱敏)
|
|
50
|
+
deeptrade config export deeptrade-config.json
|
|
51
|
+
deeptrade config import deeptrade-config.json --dry-run
|
|
50
52
|
|
|
51
53
|
# 可选:报告上传(v0.11+,全局开关默认关闭)
|
|
52
54
|
deeptrade config set report.upload.enabled true
|
|
@@ -56,6 +58,8 @@ deeptrade config set report.upload.token <bearer-token> # 可选;空串走
|
|
|
56
58
|
|
|
57
59
|
> **报告上传(v0.11+)**:框架提供 `ReportUploader` 公共服务,插件通过 `ctx.make_report_uploader().upload(path, plugin_name=..., trade_date=...)` 把执行报告 JSON 推到统一端点。配置键 `report.upload.{enabled,url,timeout}` 走 `app_config`,`report.upload.token` 走 `secret_store`;每次调用(含 skipped)都在 `report_uploads` 表留一行审计,token / Authorization 永不落库。上传失败永远只 WARN,不阻断 run。
|
|
58
60
|
|
|
61
|
+
> **配置导入/导出**:`deeptrade config export [PATH]` 默认只导出框架层持久化配置和 secret 存在状态,不导出密钥明文;`--include-secrets` 会把 token / API key 明文写入文件,文件应按敏感凭据保存。导入使用 `deeptrade config import PATH --dry-run` 预览,默认 `--mode merge`;`--mode replace --yes` 会先删除所有框架层持久化配置和框架层 secret,再写入文件内容。
|
|
62
|
+
|
|
59
63
|
> **LLM 复现性 & 缓存(v0.12+)**:相同 `(plugin_id, stage, provider, model, system+user prompt, profile, schema_version, input_fingerprint)` 下,重复执行直接命中已验证响应,不再向 provider 发起调用。
|
|
60
64
|
>
|
|
61
65
|
> - 配置键 `llm.replay.{enabled,write,ttl_days}` 全部走 `app_config`,默认 `enabled=true / write=true / ttl_days=null`(不过期)。
|
|
@@ -117,7 +121,7 @@ deeptrade volume-anomaly stats # 收益统计聚合
|
|
|
117
121
|
| `deeptrade init [--no-prompts]` | 建库 + 应用 core migrations |
|
|
118
122
|
| `deeptrade db init` / `db upgrade` | 显式建库 / 应用待执行迁移 |
|
|
119
123
|
| `deeptrade db llm-cache {list, purge, inspect}` (v0.13+) | LLM replay cache 横向运维:`list` 按 plugin/stage 过滤;`purge --yes --plugin/--stage/--before` 至少一个过滤器;`inspect <cache_key 前缀>` |
|
|
120
|
-
| `deeptrade config {show, set, set-tushare, set-llm, list-llm, test-llm}` | 全局配置 |
|
|
124
|
+
| `deeptrade config {show, set, set-tushare, set-llm, list-llm, test-llm, export, import}` | 全局配置 |
|
|
121
125
|
| `deeptrade plugin search [keyword] [--no-cache]` | 浏览官方注册表 |
|
|
122
126
|
| `deeptrade plugin install <SOURCE> [--ref <REF>] [--no-deps] [--reinstall-deps] [-y]` | 注册表短名 / GitHub URL / 本地路径;依赖按 PEP 508 自动解析装入框架解释器 |
|
|
123
127
|
| `deeptrade plugin list` / `info <id>` | 列表 / 详情(未安装时回退注册表条目) |
|
|
@@ -11,10 +11,13 @@ v0.6 — multi-provider LLM (DESIGN §0.7 / §10):
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
|
|
15
17
|
|
|
16
18
|
import questionary
|
|
17
19
|
import typer
|
|
20
|
+
import yaml
|
|
18
21
|
from rich.console import Console
|
|
19
22
|
from rich.table import Table
|
|
20
23
|
|
|
@@ -23,12 +26,20 @@ from deeptrade.core.config import (
|
|
|
23
26
|
ConfigService,
|
|
24
27
|
known_keys,
|
|
25
28
|
)
|
|
29
|
+
from deeptrade.core.config_io import (
|
|
30
|
+
ConfigExportOptions,
|
|
31
|
+
ConfigImportOptions,
|
|
32
|
+
ConfigImportPlan,
|
|
33
|
+
ConfigIOError,
|
|
34
|
+
ConfigIOService,
|
|
35
|
+
)
|
|
26
36
|
from deeptrade.core.db import Database
|
|
27
37
|
|
|
28
38
|
if TYPE_CHECKING: # pragma: no cover
|
|
29
39
|
pass
|
|
30
40
|
|
|
31
41
|
app = typer.Typer(help="查看 / 编辑配置", no_args_is_help=True)
|
|
42
|
+
ConfigFileFormat: TypeAlias = Literal["json", "yaml"]
|
|
32
43
|
|
|
33
44
|
|
|
34
45
|
# ---------------------------------------------------------------------------
|
|
@@ -42,6 +53,49 @@ def _open_service() -> tuple[Database, ConfigService]:
|
|
|
42
53
|
return db, ConfigService(db)
|
|
43
54
|
|
|
44
55
|
|
|
56
|
+
def _infer_format(
|
|
57
|
+
path: str,
|
|
58
|
+
explicit: str | None,
|
|
59
|
+
*,
|
|
60
|
+
for_import: bool,
|
|
61
|
+
) -> ConfigFileFormat:
|
|
62
|
+
if explicit is not None:
|
|
63
|
+
if explicit not in {"json", "yaml"}:
|
|
64
|
+
raise typer.BadParameter("--format must be json or yaml")
|
|
65
|
+
return cast(ConfigFileFormat, explicit)
|
|
66
|
+
suffix = Path(path).suffix.lower()
|
|
67
|
+
if suffix == ".json" or path == "-":
|
|
68
|
+
return "json"
|
|
69
|
+
if suffix in {".yaml", ".yml"}:
|
|
70
|
+
return "yaml"
|
|
71
|
+
if for_import:
|
|
72
|
+
raise typer.BadParameter("--format is required when PATH has no .json/.yaml suffix")
|
|
73
|
+
return "json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _dump_payload(payload: dict[str, Any], fmt: ConfigFileFormat) -> str:
|
|
77
|
+
if fmt == "json":
|
|
78
|
+
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
79
|
+
return yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_payload(text: str, fmt: ConfigFileFormat) -> dict[str, Any]:
|
|
83
|
+
loaded = json.loads(text) if fmt == "json" else yaml.safe_load(text)
|
|
84
|
+
if not isinstance(loaded, dict):
|
|
85
|
+
raise ConfigIOError("config file must contain an object")
|
|
86
|
+
return loaded
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _print_import_plan(plan: ConfigImportPlan, *, source: str, mode: str) -> None:
|
|
90
|
+
typer.echo(f"导入预览:{source}")
|
|
91
|
+
typer.echo(f"模式:{mode}")
|
|
92
|
+
typer.echo(f"将写入非敏感配置:{len(plan.set_config)} 项")
|
|
93
|
+
typer.echo(f"将写入 secret:{len(plan.set_secrets)} 项")
|
|
94
|
+
typer.echo(f"将删除配置:{len(plan.delete_config) + len(plan.delete_secrets)} 项")
|
|
95
|
+
for warning in plan.warnings:
|
|
96
|
+
typer.echo(f"警告:{warning}", err=True)
|
|
97
|
+
|
|
98
|
+
|
|
45
99
|
# ---------------------------------------------------------------------------
|
|
46
100
|
# show
|
|
47
101
|
# ---------------------------------------------------------------------------
|
|
@@ -77,6 +131,144 @@ def cmd_show() -> None:
|
|
|
77
131
|
db.close()
|
|
78
132
|
|
|
79
133
|
|
|
134
|
+
@app.command("export")
|
|
135
|
+
def cmd_export(
|
|
136
|
+
path: str = typer.Argument(
|
|
137
|
+
"deeptrade-config.json",
|
|
138
|
+
help="输出路径;使用 '-' 写到 stdout",
|
|
139
|
+
),
|
|
140
|
+
fmt: str | None = typer.Option(
|
|
141
|
+
None,
|
|
142
|
+
"--format",
|
|
143
|
+
help="输出格式:json 或 yaml;默认按扩展名推断",
|
|
144
|
+
),
|
|
145
|
+
include_secrets: bool = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--include-secrets",
|
|
148
|
+
help="导出 secret 明文;导出文件将包含敏感密钥",
|
|
149
|
+
),
|
|
150
|
+
effective: bool = typer.Option(
|
|
151
|
+
False,
|
|
152
|
+
"--effective",
|
|
153
|
+
help="导出当前进程解析后的有效配置,包含默认值和环境变量覆盖",
|
|
154
|
+
),
|
|
155
|
+
include_defaults: bool = typer.Option(
|
|
156
|
+
False,
|
|
157
|
+
"--include-defaults",
|
|
158
|
+
help="持久化导出时补齐 AppConfig 默认值,不包含环境变量覆盖",
|
|
159
|
+
),
|
|
160
|
+
force: bool = typer.Option(False, "--force", help="允许覆盖已存在文件"),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""导出框架层配置。默认不导出 secret 明文。"""
|
|
163
|
+
if effective and include_defaults:
|
|
164
|
+
typer.echo("--effective 不能与 --include-defaults 同时使用", err=True)
|
|
165
|
+
raise typer.Exit(2)
|
|
166
|
+
file_format = _infer_format(path, fmt, for_import=False)
|
|
167
|
+
output_path = None if path == "-" else Path(path)
|
|
168
|
+
if output_path is not None and output_path.exists() and not force:
|
|
169
|
+
typer.echo(f"目标文件已存在:{output_path};使用 --force 覆盖", err=True)
|
|
170
|
+
raise typer.Exit(2)
|
|
171
|
+
|
|
172
|
+
db, svc = _open_service()
|
|
173
|
+
try:
|
|
174
|
+
payload = ConfigIOService(db, svc).export_payload(
|
|
175
|
+
ConfigExportOptions(
|
|
176
|
+
include_secrets=include_secrets,
|
|
177
|
+
include_defaults=include_defaults,
|
|
178
|
+
effective=effective,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
text = _dump_payload(payload, file_format)
|
|
182
|
+
if output_path is None:
|
|
183
|
+
typer.echo(text, nl=False)
|
|
184
|
+
err = True
|
|
185
|
+
target = "stdout"
|
|
186
|
+
else:
|
|
187
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
output_path.write_text(text, encoding="utf-8")
|
|
189
|
+
err = False
|
|
190
|
+
target = str(output_path)
|
|
191
|
+
|
|
192
|
+
if include_secrets:
|
|
193
|
+
plaintext = sum(1 for item in payload["secrets"].values() if item.get("value"))
|
|
194
|
+
typer.echo(f"警告:导出文件包含 {plaintext} 项明文 secret,请妥善保存。", err=True)
|
|
195
|
+
typer.echo(f"已导出框架配置:{target}", err=err)
|
|
196
|
+
typer.echo(f"非敏感配置:{len(payload['config'])} 项", err=err)
|
|
197
|
+
present = sum(1 for item in payload["secrets"].values() if item.get("present"))
|
|
198
|
+
plaintext = sum(1 for item in payload["secrets"].values() if item.get("value"))
|
|
199
|
+
typer.echo(f"Secret:{present} 项已记录存在状态,{plaintext} 项导出明文", err=err)
|
|
200
|
+
for warning in payload.get("warnings", []):
|
|
201
|
+
typer.echo(f"警告:{warning}", err=True)
|
|
202
|
+
finally:
|
|
203
|
+
db.close()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command("import")
|
|
207
|
+
def cmd_import(
|
|
208
|
+
path: str = typer.Argument(..., help="输入配置文件路径"),
|
|
209
|
+
fmt: str | None = typer.Option(
|
|
210
|
+
None,
|
|
211
|
+
"--format",
|
|
212
|
+
help="输入格式:json 或 yaml;默认按扩展名推断",
|
|
213
|
+
),
|
|
214
|
+
mode: str = typer.Option(
|
|
215
|
+
"merge",
|
|
216
|
+
"--mode",
|
|
217
|
+
help="导入模式:merge 只覆盖文件内项目;replace 先删除框架层持久化配置",
|
|
218
|
+
),
|
|
219
|
+
include_secrets: bool = typer.Option(
|
|
220
|
+
False,
|
|
221
|
+
"--include-secrets",
|
|
222
|
+
help="允许导入文件中的 secret 明文;默认忽略 secret value",
|
|
223
|
+
),
|
|
224
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="只解析、校验并展示计划,不写入"),
|
|
225
|
+
yes: bool = typer.Option(False, "--yes", help="确认 replace 模式的破坏性删除"),
|
|
226
|
+
) -> None:
|
|
227
|
+
"""导入框架层配置。默认忽略文件中的 secret 明文。"""
|
|
228
|
+
if mode not in {"merge", "replace"}:
|
|
229
|
+
typer.echo("--mode 必须是 merge 或 replace", err=True)
|
|
230
|
+
raise typer.Exit(2)
|
|
231
|
+
if mode == "replace" and not yes and not dry_run:
|
|
232
|
+
typer.echo("replace 模式会删除所有框架层持久化配置和 secret;请加 --yes 确认", err=True)
|
|
233
|
+
raise typer.Exit(2)
|
|
234
|
+
|
|
235
|
+
file_format = _infer_format(path, fmt, for_import=True)
|
|
236
|
+
source_path = Path(path)
|
|
237
|
+
try:
|
|
238
|
+
payload = _load_payload(source_path.read_text(encoding="utf-8"), file_format)
|
|
239
|
+
except (OSError, json.JSONDecodeError, yaml.YAMLError, ConfigIOError) as e:
|
|
240
|
+
typer.echo(f"读取配置文件失败:{e}", err=True)
|
|
241
|
+
raise typer.Exit(2) from e
|
|
242
|
+
|
|
243
|
+
db, svc = _open_service()
|
|
244
|
+
try:
|
|
245
|
+
service = ConfigIOService(db, svc)
|
|
246
|
+
options = ConfigImportOptions(
|
|
247
|
+
include_secrets=include_secrets,
|
|
248
|
+
mode=cast(Literal["merge", "replace"], mode),
|
|
249
|
+
dry_run=dry_run,
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
plan = service.plan_import(payload, options)
|
|
253
|
+
except (ConfigIOError, ValueError) as e:
|
|
254
|
+
typer.echo(f"配置文件无效:{e}", err=True)
|
|
255
|
+
raise typer.Exit(2) from e
|
|
256
|
+
|
|
257
|
+
if dry_run:
|
|
258
|
+
_print_import_plan(plan, source=path, mode=mode)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
service.apply_import_plan(plan)
|
|
262
|
+
typer.echo(f"已导入框架配置:{path}")
|
|
263
|
+
typer.echo(f"写入非敏感配置:{len(plan.set_config)} 项")
|
|
264
|
+
typer.echo(f"写入 secret:{len(plan.set_secrets)} 项")
|
|
265
|
+
typer.echo(f"删除配置:{len(plan.delete_config) + len(plan.delete_secrets)} 项")
|
|
266
|
+
for warning in plan.warnings:
|
|
267
|
+
typer.echo(f"警告:{warning}", err=True)
|
|
268
|
+
finally:
|
|
269
|
+
db.close()
|
|
270
|
+
|
|
271
|
+
|
|
80
272
|
# ---------------------------------------------------------------------------
|
|
81
273
|
# set
|
|
82
274
|
# ---------------------------------------------------------------------------
|
|
@@ -32,7 +32,7 @@ from typing import Any, Literal
|
|
|
32
32
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
33
33
|
|
|
34
34
|
from deeptrade.core.db import Database
|
|
35
|
-
from deeptrade.core.secrets import SecretStore
|
|
35
|
+
from deeptrade.core.secrets import SecretRecord, SecretStore
|
|
36
36
|
from deeptrade.plugins_api.llm import StageProfile
|
|
37
37
|
|
|
38
38
|
# ---------------------------------------------------------------------------
|
|
@@ -337,6 +337,10 @@ class ConfigService:
|
|
|
337
337
|
accessible OS keyring."""
|
|
338
338
|
return sum(1 for r in self._secrets.list_records() if r.method == "plaintext")
|
|
339
339
|
|
|
340
|
+
def list_secret_records(self) -> list[SecretRecord]:
|
|
341
|
+
"""Return secret metadata rows without forcing keyring reads."""
|
|
342
|
+
return self._secrets.list_records()
|
|
343
|
+
|
|
340
344
|
# --- read ----------------------------------------------------------
|
|
341
345
|
|
|
342
346
|
def get(self, key: str) -> Any:
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Import/export support for framework-level configuration.
|
|
2
|
+
|
|
3
|
+
This module intentionally handles only framework-owned configuration:
|
|
4
|
+
``app_config`` non-secret rows and ``secret_store`` entries routed by
|
|
5
|
+
``ConfigService``. It does not snapshot plugin installs, plugin data, reports,
|
|
6
|
+
runtime logs, or environment variables unless explicitly asked for the
|
|
7
|
+
effective runtime view.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any, Literal, Self
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
from deeptrade.core.config import (
|
|
20
|
+
AppConfig,
|
|
21
|
+
ConfigService,
|
|
22
|
+
env_var_for,
|
|
23
|
+
is_secret_key,
|
|
24
|
+
known_keys,
|
|
25
|
+
)
|
|
26
|
+
from deeptrade.core.db import Database
|
|
27
|
+
|
|
28
|
+
SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigIOError(ValueError):
|
|
32
|
+
"""Raised when a config import/export payload is invalid."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigExportOptions(BaseModel):
|
|
36
|
+
include_secrets: bool = False
|
|
37
|
+
include_defaults: bool = False
|
|
38
|
+
effective: bool = False
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def _defaults_not_effective(self) -> Self:
|
|
42
|
+
if self.include_defaults and self.effective:
|
|
43
|
+
raise ValueError("--include-defaults cannot be combined with --effective")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConfigImportOptions(BaseModel):
|
|
48
|
+
include_secrets: bool = False
|
|
49
|
+
mode: Literal["merge", "replace"] = "merge"
|
|
50
|
+
dry_run: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConfigImportPlan(BaseModel):
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
|
|
56
|
+
set_config: dict[str, Any] = Field(default_factory=dict)
|
|
57
|
+
delete_config: list[str] = Field(default_factory=list)
|
|
58
|
+
set_secrets: dict[str, str] = Field(default_factory=dict)
|
|
59
|
+
delete_secrets: list[str] = Field(default_factory=list)
|
|
60
|
+
warnings: list[str] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConfigIOService:
|
|
64
|
+
"""Build export payloads and apply validated import plans."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, db: Database, config: ConfigService) -> None:
|
|
67
|
+
self._db = db
|
|
68
|
+
self._config = config
|
|
69
|
+
|
|
70
|
+
def export_payload(self, options: ConfigExportOptions) -> dict[str, Any]:
|
|
71
|
+
config = self._export_config(options)
|
|
72
|
+
secrets, warnings = self._export_secrets(options, config)
|
|
73
|
+
payload: dict[str, Any] = {
|
|
74
|
+
"schema_version": SCHEMA_VERSION,
|
|
75
|
+
"tool": "deeptrade",
|
|
76
|
+
"exported_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
|
|
77
|
+
"mode": "effective" if options.effective else "persisted",
|
|
78
|
+
"config": config,
|
|
79
|
+
"secrets": secrets,
|
|
80
|
+
}
|
|
81
|
+
if warnings:
|
|
82
|
+
payload["warnings"] = warnings
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
def plan_import(
|
|
86
|
+
self,
|
|
87
|
+
payload: dict[str, Any],
|
|
88
|
+
options: ConfigImportOptions,
|
|
89
|
+
) -> ConfigImportPlan:
|
|
90
|
+
self._validate_envelope(payload)
|
|
91
|
+
config = payload.get("config", {})
|
|
92
|
+
secrets = payload.get("secrets", {})
|
|
93
|
+
if not isinstance(config, dict):
|
|
94
|
+
raise ConfigIOError("config must be an object")
|
|
95
|
+
if not isinstance(secrets, dict):
|
|
96
|
+
raise ConfigIOError("secrets must be an object")
|
|
97
|
+
|
|
98
|
+
set_config = self._validate_config_values(config)
|
|
99
|
+
set_secrets, warnings = self._validate_secret_values(secrets, options)
|
|
100
|
+
|
|
101
|
+
delete_config: list[str] = []
|
|
102
|
+
delete_secrets: list[str] = []
|
|
103
|
+
if options.mode == "replace":
|
|
104
|
+
delete_config = self._persisted_config_keys()
|
|
105
|
+
delete_secrets = sorted(
|
|
106
|
+
self._framework_secret_keys(
|
|
107
|
+
payload_config=set_config,
|
|
108
|
+
payload_secrets=secrets,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return ConfigImportPlan(
|
|
113
|
+
set_config=set_config,
|
|
114
|
+
delete_config=delete_config,
|
|
115
|
+
set_secrets=set_secrets,
|
|
116
|
+
delete_secrets=delete_secrets,
|
|
117
|
+
warnings=warnings,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def apply_import_plan(self, plan: ConfigImportPlan) -> None:
|
|
121
|
+
"""Apply a plan.
|
|
122
|
+
|
|
123
|
+
Non-secret config is written in one DuckDB transaction. Secret writes
|
|
124
|
+
can hit keyring backends and are not guaranteed to be transactionally
|
|
125
|
+
tied to DuckDB, matching the design's first-version trade-off.
|
|
126
|
+
"""
|
|
127
|
+
with self._db.transaction():
|
|
128
|
+
for key in plan.delete_config:
|
|
129
|
+
self._config.delete(key)
|
|
130
|
+
for key, value in plan.set_config.items():
|
|
131
|
+
self._config.set(key, value)
|
|
132
|
+
|
|
133
|
+
for key in plan.delete_secrets:
|
|
134
|
+
self._config.delete(key)
|
|
135
|
+
for key, value in plan.set_secrets.items():
|
|
136
|
+
self._config.set(key, value)
|
|
137
|
+
|
|
138
|
+
# --- export helpers -------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _export_config(self, options: ConfigExportOptions) -> dict[str, Any]:
|
|
141
|
+
if options.effective:
|
|
142
|
+
cfg = self._config.get_app_config().model_dump(mode="json")
|
|
143
|
+
return {key: cfg[field] for key, field in _non_secret_key_fields().items()}
|
|
144
|
+
|
|
145
|
+
if options.include_defaults:
|
|
146
|
+
defaults = AppConfig().model_dump(mode="json")
|
|
147
|
+
out: dict[str, Any] = {}
|
|
148
|
+
persisted = self._persisted_config_map()
|
|
149
|
+
for key, field in _non_secret_key_fields().items():
|
|
150
|
+
out[key] = persisted.get(key, defaults[field])
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
return self._persisted_config_map()
|
|
154
|
+
|
|
155
|
+
def _export_secrets(
|
|
156
|
+
self,
|
|
157
|
+
options: ConfigExportOptions,
|
|
158
|
+
exported_config: dict[str, Any],
|
|
159
|
+
) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
|
160
|
+
out: dict[str, dict[str, Any]] = {}
|
|
161
|
+
warnings: list[str] = []
|
|
162
|
+
records = {r.key: r for r in self._config.list_secret_records()}
|
|
163
|
+
keys = self._framework_secret_keys(payload_config=exported_config, payload_secrets={})
|
|
164
|
+
|
|
165
|
+
for key in sorted(keys):
|
|
166
|
+
record = records.get(key)
|
|
167
|
+
env_value = os.environ.get(env_var_for(key)) if options.effective else None
|
|
168
|
+
present = env_value is not None or record is not None
|
|
169
|
+
value: str | None = None
|
|
170
|
+
if options.include_secrets and present:
|
|
171
|
+
if env_value is not None:
|
|
172
|
+
value = env_value
|
|
173
|
+
else:
|
|
174
|
+
value = self._config.get(key)
|
|
175
|
+
if value is None and record is not None:
|
|
176
|
+
warnings.append(f"unreadable_secret:{key}")
|
|
177
|
+
out[key] = {"present": present, "value": value}
|
|
178
|
+
return out, warnings
|
|
179
|
+
|
|
180
|
+
# --- import validation ---------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _validate_envelope(self, payload: dict[str, Any]) -> None:
|
|
183
|
+
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
184
|
+
raise ConfigIOError(f"unsupported schema_version: {payload.get('schema_version')!r}")
|
|
185
|
+
if payload.get("tool") != "deeptrade":
|
|
186
|
+
raise ConfigIOError("tool must be 'deeptrade'")
|
|
187
|
+
|
|
188
|
+
def _validate_config_values(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
189
|
+
known = set(known_keys())
|
|
190
|
+
fields = _non_secret_key_fields()
|
|
191
|
+
out: dict[str, Any] = {}
|
|
192
|
+
for key, value in config.items():
|
|
193
|
+
if key not in known or is_secret_key(key):
|
|
194
|
+
raise ConfigIOError(f"invalid non-secret config key: {key!r}")
|
|
195
|
+
if key == "llm.providers":
|
|
196
|
+
self._validate_provider_names(value)
|
|
197
|
+
field = fields[key]
|
|
198
|
+
validated = AppConfig(**{field: value})
|
|
199
|
+
out[key] = validated.model_dump(mode="json")[field]
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
def _validate_secret_values(
|
|
203
|
+
self,
|
|
204
|
+
secrets: dict[str, Any],
|
|
205
|
+
options: ConfigImportOptions,
|
|
206
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
207
|
+
set_secrets: dict[str, str] = {}
|
|
208
|
+
warnings: list[str] = []
|
|
209
|
+
for key, item in secrets.items():
|
|
210
|
+
if not is_secret_key(key):
|
|
211
|
+
raise ConfigIOError(f"invalid secret key: {key!r}")
|
|
212
|
+
if not isinstance(item, dict):
|
|
213
|
+
raise ConfigIOError(f"secret entry must be an object: {key!r}")
|
|
214
|
+
value = item.get("value")
|
|
215
|
+
if not options.include_secrets:
|
|
216
|
+
if value not in (None, ""):
|
|
217
|
+
warnings.append(f"secret_value_ignored:{key}")
|
|
218
|
+
continue
|
|
219
|
+
if value in (None, ""):
|
|
220
|
+
continue
|
|
221
|
+
if not isinstance(value, str):
|
|
222
|
+
raise ConfigIOError(f"secret value must be a string: {key!r}")
|
|
223
|
+
if value.startswith("********"):
|
|
224
|
+
raise ConfigIOError(f"masked secret values cannot be imported: {key!r}")
|
|
225
|
+
set_secrets[key] = value
|
|
226
|
+
return set_secrets, warnings
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _validate_provider_names(value: Any) -> None:
|
|
230
|
+
if not isinstance(value, dict):
|
|
231
|
+
raise ConfigIOError("llm.providers must be an object")
|
|
232
|
+
normalized: dict[str, str] = {}
|
|
233
|
+
for name in value:
|
|
234
|
+
if not isinstance(name, str) or not name or "." in name:
|
|
235
|
+
raise ConfigIOError(
|
|
236
|
+
f"invalid provider name: {name!r}; must be non-empty and contain no '.'"
|
|
237
|
+
)
|
|
238
|
+
env_name = name.replace("-", "_")
|
|
239
|
+
if env_name in normalized:
|
|
240
|
+
raise ConfigIOError(
|
|
241
|
+
f"provider name {name!r} collides with {normalized[env_name]!r} "
|
|
242
|
+
"after env-var normalization"
|
|
243
|
+
)
|
|
244
|
+
normalized[env_name] = name
|
|
245
|
+
|
|
246
|
+
# --- persistence introspection -------------------------------------
|
|
247
|
+
|
|
248
|
+
def _persisted_config_map(self) -> dict[str, Any]:
|
|
249
|
+
known_non_secret = set(_non_secret_key_fields())
|
|
250
|
+
rows = self._db.fetchall("SELECT key, value_json FROM app_config ORDER BY key")
|
|
251
|
+
out: dict[str, Any] = {}
|
|
252
|
+
for key, value_json in rows:
|
|
253
|
+
if key in known_non_secret:
|
|
254
|
+
out[key] = json.loads(value_json)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
def _persisted_config_keys(self) -> list[str]:
|
|
258
|
+
return sorted(self._persisted_config_map().keys())
|
|
259
|
+
|
|
260
|
+
def _framework_secret_keys(
|
|
261
|
+
self,
|
|
262
|
+
*,
|
|
263
|
+
payload_config: dict[str, Any],
|
|
264
|
+
payload_secrets: dict[str, Any],
|
|
265
|
+
) -> set[str]:
|
|
266
|
+
keys = {key for key in known_keys() if is_secret_key(key)}
|
|
267
|
+
current = self._config.get("llm.providers")
|
|
268
|
+
if isinstance(current, dict):
|
|
269
|
+
keys.update(f"llm.{name}.api_key" for name in current)
|
|
270
|
+
payload_providers = payload_config.get("llm.providers")
|
|
271
|
+
if isinstance(payload_providers, dict):
|
|
272
|
+
keys.update(f"llm.{name}.api_key" for name in payload_providers)
|
|
273
|
+
keys.update(key for key in payload_secrets if is_secret_key(key))
|
|
274
|
+
keys.update(r.key for r in self._config.list_secret_records() if is_secret_key(r.key))
|
|
275
|
+
return keys
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _non_secret_key_fields() -> dict[str, str]:
|
|
279
|
+
return {
|
|
280
|
+
"app.timezone": "app_timezone",
|
|
281
|
+
"app.locale": "app_locale",
|
|
282
|
+
"app.log_level": "app_log_level",
|
|
283
|
+
"app.close_after": "app_close_after",
|
|
284
|
+
"tushare.rps": "tushare_rps",
|
|
285
|
+
"tushare.timeout": "tushare_timeout",
|
|
286
|
+
"tushare.max_retries": "tushare_max_retries",
|
|
287
|
+
"app.profile": "app_profile",
|
|
288
|
+
"llm.providers": "llm_providers",
|
|
289
|
+
"llm.audit_full_payload": "llm_audit_full_payload",
|
|
290
|
+
"llm.replay.enabled": "llm_replay_enabled",
|
|
291
|
+
"llm.replay.write": "llm_replay_write",
|
|
292
|
+
"llm.replay.ttl_days": "llm_replay_ttl_days",
|
|
293
|
+
"report.upload.enabled": "report_upload_enabled",
|
|
294
|
+
"report.upload.url": "report_upload_url",
|
|
295
|
+
"report.upload.timeout": "report_upload_timeout",
|
|
296
|
+
}
|