deeptrade-quant 0.6.0__tar.gz → 0.8.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.
Files changed (72) hide show
  1. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/CHANGELOG.md +73 -0
  2. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/PKG-INFO +1 -1
  3. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/__init__.py +1 -1
  4. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/cli_plugin.py +12 -2
  5. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/config.py +22 -0
  6. deeptrade_quant-0.8.0/deeptrade/core/dep_installer.py +434 -0
  7. deeptrade_quant-0.8.0/deeptrade/core/github_fetch.py +122 -0
  8. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/paths.py +17 -1
  9. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/plugin_manager.py +74 -5
  10. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/plugin_source.py +23 -12
  11. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/registry.py +15 -4
  12. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/pyproject.toml +1 -1
  13. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_config.py +38 -0
  14. deeptrade_quant-0.8.0/tests/core/test_github_fetch.py +126 -0
  15. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_plugin_dependencies.py +203 -0
  16. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_plugin_source.py +78 -39
  17. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_registry.py +25 -0
  18. deeptrade_quant-0.6.0/deeptrade/core/dep_installer.py +0 -226
  19. deeptrade_quant-0.6.0/deeptrade/core/github_fetch.py +0 -202
  20. deeptrade_quant-0.6.0/tests/core/test_github_fetch.py +0 -206
  21. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/.gitignore +0 -0
  22. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/LICENSE +0 -0
  23. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/README.md +0 -0
  24. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/cli.py +0 -0
  25. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/cli_config.py +0 -0
  26. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/cli_data.py +0 -0
  27. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/__init__.py +0 -0
  28. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/config_migrations.py +0 -0
  29. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/db.py +0 -0
  30. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/llm_client.py +0 -0
  31. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/llm_manager.py +0 -0
  32. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/logging_config.py +0 -0
  33. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/migrations/__init__.py +0 -0
  34. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  35. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
  36. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
  37. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  38. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/run_status.py +0 -0
  39. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/secrets.py +0 -0
  40. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/core/tushare_client.py +0 -0
  41. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/__init__.py +0 -0
  42. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/base.py +0 -0
  43. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/errors.py +0 -0
  44. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/events.py +0 -0
  45. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/llm.py +0 -0
  46. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/plugins_api/metadata.py +0 -0
  47. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/deeptrade/theme.py +0 -0
  48. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/__init__.py +0 -0
  49. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/cli/__init__.py +0 -0
  50. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/cli/test_config_cmd.py +0 -0
  51. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/cli/test_plugin_cmd.py +0 -0
  52. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/cli/test_routing.py +0 -0
  53. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
  54. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/conftest.py +0 -0
  55. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/__init__.py +0 -0
  56. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_config_migrations.py +0 -0
  57. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_db.py +0 -0
  58. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_llm_client.py +0 -0
  59. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_llm_manager.py +0 -0
  60. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_paths.py +0 -0
  61. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_plugin_install.py +0 -0
  62. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_plugin_security.py +0 -0
  63. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_plugin_upgrade.py +0 -0
  64. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_secrets.py +0 -0
  65. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_tushare_classifier.py +0 -0
  66. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_tushare_client.py +0 -0
  67. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/core/test_tushare_retry_r1.py +0 -0
  68. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/plugins_api/__init__.py +0 -0
  69. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/plugins_api/test_api_version_2.py +0 -0
  70. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/plugins_api/test_errors.py +0 -0
  71. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/plugins_api/test_protocol.py +0 -0
  72. {deeptrade_quant-0.6.0 → deeptrade_quant-0.8.0}/tests/test_smoke.py +0 -0
@@ -2,6 +2,79 @@
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.8.0] — 2026-05-16 — 插件 install / upgrade 走 CDN,零 GitHub API 调用
6
+
7
+ `deeptrade plugin install` 与 `deeptrade plugin upgrade` 此前在解析"最新版本"与下载 tarball 时各打一次 ``api.github.com``,未认证用户共享 60/h 的 IP 级配额。一旦插件用户数上来,或者用户与浏览器 / `gh` CLI / `git clone` 公共仓库共用同一公网 IP,``HTTP 403: rate limit exceeded`` 就会把 install / upgrade 直接打死。共享 token 会违反 GitHub ToS,且配额仍会在那个 token 上聚合——不是解。
8
+
9
+ 本版改造将插件分发热路径全部搬到 CDN 端点:
10
+
11
+ - **Tarball 下载**改走 ``codeload.github.com/<owner>/<repo>/tar.gz/<ref>``。codeload 是 CDN-backed 静态端点,不计入 REST API 限流。
12
+ - **"最新版本"**改从注册表 ``index.json`` 的新字段 ``latest_version`` 读取(``raw.githubusercontent.com`` 同样不限流,且已有 ETag 缓存)。
13
+ - **URL 形式安装**(``deeptrade plugin install https://github.com/...``)无 ``--ref`` 时默认 ``main`` 分支;其他默认分支需显式 ``--ref``。
14
+
15
+ 净效果:未认证用户走 ``install`` / ``upgrade`` 全流程**零 API 调用**,60/h 限制对默认路径不再存在。``GITHUB_TOKEN`` 不再被框架读取,文档不再推荐设置。
16
+
17
+ ### Changed
18
+
19
+ - ``deeptrade/core/github_fetch.py``:删除 ``latest_release_tag`` / ``NoMatchingReleaseError``;``fetch_tarball`` 改写为 codeload 单端点(移除 ``GITHUB_TOKEN`` / ``X-GitHub-Api-Version`` header)。
20
+ - ``deeptrade/core/registry.py``:``RegistryEntry`` 新增可选字段 ``latest_version: str | None``;解析器引入 ``_OPTIONAL_FIELDS`` 集合,含字段时填入,缺省 ``None``。schema_version 不变(仍为 1,向后兼容旧注册表文件)。
21
+ - ``deeptrade/core/plugin_source.py``:``_resolve_short_name`` 用 ``entry.latest_version`` 替代 ``latest_release_tag``;缺字段且无 ``--ref`` 时抛 ``SourceResolveError`` 提示需补 ``--ref``。``_resolve_url`` 无 ``--ref`` 默认 ``main``;codeload 失败时错误信息提示用户切换 ``--ref``。
22
+ - ``deeptrade/cli_plugin.py``:移除 ``NoMatchingReleaseError`` 引用。
23
+
24
+ ### Removed
25
+
26
+ - ``latest_release_tag`` 公共函数与 ``NoMatchingReleaseError`` 异常类。
27
+ - 对 ``GITHUB_TOKEN`` 环境变量的读取(github_fetch.py 中)。
28
+
29
+ ### Migration notes
30
+
31
+ - **注册表维护者**:在 ``DeepTradePluginOfficial/registry/index.json`` 每个 plugin entry 中加 ``latest_version`` 字段,每次插件发版后同步该字段。可由 plugin 仓库 release workflow 自动 PR 到注册表仓库。
32
+ - **存量旧版框架用户**:旧版(v0.7 及以下)仍会调 ``api.github.com``。``latest_version`` 字段是可选的、旧框架读取时会被忽略,不破坏现有用户;旧用户升级到本版后限流问题自动消失。
33
+ - **URL 形式高级用户**:若你 host 的仓库默认分支不是 ``main``,``deeptrade plugin install https://github.com/<your>/<repo>`` 需显式 ``--ref <branch>``。
34
+
35
+ ### Why not "use a shared token"
36
+
37
+ GitHub ToS 明确禁止 token 跨用户共享(被发现 token 会被吊销);即便允许,5000/h 也会被规模化使用快速吃掉,且 token 泄露需要维护者承担权限滥用风险。CDN 改造是唯一既不需要每用户认证、又能稳定服务的方案。未来若需要支持私有仓库或企业内部分发,再走 OAuth Device Flow(``gh auth login`` 同款流程)让每个用户认证自己的 token。
38
+
39
+ ## [v0.7.0] — 2026-05-15 — 依赖隔离稳健化 + timezone IANA 校验
40
+
41
+ 本版按 2026-05-15 评审研判文档 §5 v0.7 路线落地 2 项主线,至此 v0.5 § 5 全部"采纳项"清零:
42
+
43
+ 1. **H6 依赖隔离稳健化**:
44
+ - **dry-run 预检**:``run_install`` 之前先跑 ``uv pip install --dry-run`` 抓 uv 的 change plan;解析 ``~`` (update) / ``-`` (remove) 行,与 ``framework_core_canonicals()`` 求交集。若插件依赖会顺带升级 / 降级 / 移除任何框架核心 dep,默认拒绝,要求显式 ``--allow-core-bump`` 才放行。``uv`` 不在 PATH 时降级为不做检查(``pip`` 没有对等结构化 dry-run),保持 v0.4 装机不动手的承诺。
45
+ - **dep snapshot**:``run_install`` 之前把 ``pip list --format=freeze`` 输出落到 ``~/.deeptrade/dep_snapshots/<plugin_id>/pre-install-<UTC>.txt``。失败时错误信息把这个目录甩到用户面前,便于 ``diff`` 回滚。
46
+ - 仍**不做**:venv-per-plugin / subprocess 隔离(重型方案,违反"框架轻");框架 deps 的 pin lockfile(会污染用户项目的解析)。
47
+ 2. **L1 timezone IANA 校验**:``AppConfig.app_timezone`` 改用 ``zoneinfo.available_timezones()`` 做硬校验,把 ``Asia/Shangai`` 这种近似拼写在写时拦住,而不是延迟到第一次 ``ZoneInfo(...)`` 调用爆 ``ZoneInfoNotFoundError``。``base_url`` HTTPS 强制按方案 §4 评估**仍不做**(本地 LLM 网关 / Ollama 常用 http)。
48
+
49
+ ### Added
50
+
51
+ - ``deeptrade/core/dep_installer.py``:
52
+ - ``framework_core_canonicals()``——读取 ``deeptrade-quant`` distribution 的 ``Requires``,过滤掉 ``extra`` marker,输出框架核心 dep 的 canonical 名字集合。从源码运行(未 ``pip install -e``)时返回空集合,让预检自动降级为 no-op。
53
+ - ``preflight_dry_run(reqs, *, watched)``——best-effort 跑 ``uv pip install --dry-run``;解析 stderr / stdout 中的 ``~`` 与 ``-`` 行;返回与 ``watched`` 相交的 canonical 名字集合。``uv`` 缺席、resolution 失败、超时——一律返回空集合,让真正的 ``run_install`` 路径报底层错误,预检不抢话语权。
54
+ - ``_parse_dry_run_changes(text, watched)``——dry-run 输出解析的纯函数版本,便于单测覆盖。
55
+ - ``write_dep_snapshot(plugin_id, dir_root)``——优先 ``uv pip list``,回退 ``python -m pip list``(uv 管理的 venv 通常不带 pip);写 ``<dir_root>/<plugin_id>/pre-install-<UTC>.txt``,返回 ``Path`` 或失败时 ``None``。
56
+ - ``DRY_RUN_TIMEOUT_SECONDS`` 短超时常量,避免被卡住的 uv 阻塞真正的 install。
57
+ - ``deeptrade/core/paths.py::dep_snapshots_dir()``——``~/.deeptrade/dep_snapshots/``;纳入 ``ensure_layout()`` 自动创建。
58
+ - ``deeptrade/core/plugin_manager.py::PluginManager.install`` / ``upgrade`` 接 ``allow_core_bump: bool = False``;``_handle_dependencies`` 在 ``run_install`` 之前先跑 preflight 与 snapshot,并在 install error 信息里附上 snapshot 目录 hint。
59
+ - ``deeptrade/cli_plugin.py``:``plugin install`` 与 ``plugin upgrade`` 加 ``--allow-core-bump`` flag(中文 help 文案)。
60
+ - ``deeptrade/core/config.py::AppConfig._validate_app_timezone``——基于 ``zoneinfo.available_timezones()`` 的 ``@field_validator``;非法值抛 Pydantic ``ValidationError`` 并指出如何打印当前主机支持的全部 zones。
61
+ - 测试:``tests/core/test_config.py`` 5 个新用例(默认值通过、典型 IANA 名通过、typo / 空串 / 任意字符串拒绝)。``tests/core/test_plugin_dependencies.py`` 10 个新用例(``framework_core_canonicals`` 列出 must-have、``_parse_dry_run_changes`` 识别 update / remove、忽略 install 行、uv 缺席返回空集、空 requirements 返回空集、watched 为空跳过 subprocess、``_handle_dependencies`` 默认拒绝 core bump 与 ``--allow-core-bump`` 放行、``write_dep_snapshot`` 写文件、pip 缺失返回 ``None``、CLI 双向)。
62
+
63
+ ### Changed
64
+
65
+ - ``deeptrade/core/plugin_manager.py::_handle_dependencies``:新增 ``allow_core_bump`` kwarg;``run_install`` 调用包了 ``try/except DepInstallError``,把异常包裹一层附上 snapshot 目录提示,便于排障。
66
+ - ``deeptrade/core/dep_installer.py::write_dep_snapshot``:拆出 ``_snapshot_argv()`` 帮助选择 ``uv pip list`` vs ``python -m pip list``,与 ``detect_installer()`` 的优先级一致。
67
+
68
+ ### Migration notes
69
+
70
+ - **用户视角零强制动作**:默认行为是更严的(核心 dep 改动需要显式确认),但常见插件不会动框架核心 dep——绝大多数 ``deeptrade plugin install <短名>`` 命令仍然零摩擦。
71
+ - **遇到核心依赖冲突如何处理**:v0.7 之后若看到 ``plugin install would change framework core dep(s) [...]`` 的拒绝信息,先看 dry-run 报告的具体包:
72
+ - 如果是 ``duckdb`` / ``pydantic`` / ``click`` / ``typer`` 等加载链路上的运行时关键件——大概率是插件 spec 写得太死,反馈给插件作者放宽;强行 ``--allow-core-bump`` 可能让框架进程立刻 import 失败。
73
+ - 如果只是 minor 版本提升且已读过 release note——加 ``--allow-core-bump`` 显式确认放行。
74
+ - **uv 缺席的主机**:``preflight_dry_run`` 自动跳过,行为与 v0.4 一致。``write_dep_snapshot`` 也能用 ``python -m pip list`` 兜底(虽然 uv-only venv 上 pip 通常缺席,此时 snapshot 静默 no-op)。
75
+ - **dep_snapshots 目录的清理**:框架本身不做轮转。如果该目录变大,可以放心 ``rm`` 老的 ``pre-install-*.txt`` 文件——它们只用于回溯,不影响运行时。
76
+ - **timezone**:升级到 v0.7 之后第一次 ``Database()`` 打开会触发自动迁移,迁移本身不动 ``app.timezone``;但如果你之前不小心存了 typo 进 ``app_config``,下一次 ``ConfigService.get_app_config()`` 会抛 ``ValidationError``。修法:``deeptrade config set app.timezone Asia/Shanghai``(或你本地的正确 IANA 名)。
77
+
5
78
  ## [v0.6.0] — 2026-05-15 — 插件运行时 API + LLM 方言 + 杂项收尾
6
79
 
7
80
  本版按 2026-05-15 评审研判文档 `DeepTrade-review-assessment-and-fix-plan-2026-05-15.md` §5 中 v0.6 路线落地 6 项主线:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.8.0"
6
6
  __all__ = ["__version__"]
@@ -12,7 +12,6 @@ from deeptrade.core import paths
12
12
  from deeptrade.core.db import Database
13
13
  from deeptrade.core.github_fetch import (
14
14
  GitHubFetchError,
15
- NoMatchingReleaseError,
16
15
  TarballFetchError,
17
16
  )
18
17
  from deeptrade.core.plugin_manager import (
@@ -41,7 +40,6 @@ _RESOLVE_ERRORS: tuple[type[Exception], ...] = (
41
40
  RegistryNotFoundError,
42
41
  RegistryFetchError,
43
42
  RegistrySchemaError,
44
- NoMatchingReleaseError,
45
43
  TarballFetchError,
46
44
  GitHubFetchError,
47
45
  SourceResolveError,
@@ -75,6 +73,11 @@ def cmd_install(
75
73
  reinstall_deps: bool = typer.Option(
76
74
  False, "--reinstall-deps", help="对全部依赖重新运行安装器(uv/pip --upgrade)"
77
75
  ),
76
+ allow_core_bump: bool = typer.Option(
77
+ False,
78
+ "--allow-core-bump",
79
+ help="允许该插件的依赖安装顺带升级 / 降级 / 移除框架核心 dep(默认拒绝)",
80
+ ),
78
81
  ) -> None:
79
82
  """从注册表 / GitHub URL / 本地目录安装一个插件。"""
80
83
  resolver = SourceResolver()
@@ -109,6 +112,7 @@ def cmd_install(
109
112
  resolved.path,
110
113
  install_deps=not no_deps,
111
114
  reinstall_deps=reinstall_deps,
115
+ allow_core_bump=allow_core_bump,
112
116
  )
113
117
  except PluginInstallError as e:
114
118
  typer.echo(f"✘ 安装失败:{e}")
@@ -269,6 +273,11 @@ def cmd_upgrade(
269
273
  reinstall_deps: bool = typer.Option(
270
274
  False, "--reinstall-deps", help="对全部依赖重新运行安装器(uv/pip --upgrade)"
271
275
  ),
276
+ allow_core_bump: bool = typer.Option(
277
+ False,
278
+ "--allow-core-bump",
279
+ help="允许该插件的依赖升级顺带升级 / 降级 / 移除框架核心 dep(默认拒绝)",
280
+ ),
272
281
  ) -> None:
273
282
  """升级一个已安装的插件。
274
283
 
@@ -292,6 +301,7 @@ def cmd_upgrade(
292
301
  resolved.path,
293
302
  install_deps=not no_deps,
294
303
  reinstall_deps=reinstall_deps,
304
+ allow_core_bump=allow_core_bump,
295
305
  )
296
306
  except PluginNotFoundError as e:
297
307
  try:
@@ -118,6 +118,28 @@ class AppConfig(BaseModel):
118
118
  return time(int(parts[0]), int(parts[1]), int(parts[2]))
119
119
  return v
120
120
 
121
+ @field_validator("app_timezone")
122
+ @classmethod
123
+ def _validate_app_timezone(cls, v: str) -> str:
124
+ """v0.7 L1 — reject ``app.timezone`` values that aren't IANA zone names.
125
+
126
+ ``zoneinfo.available_timezones()`` is the authoritative source on the
127
+ runtime host (Python 3.11+; reads ``/usr/share/zoneinfo`` on Linux,
128
+ falls back to the bundled ``tzdata`` wheel on Windows). Without this,
129
+ a typo like ``Asia/Shangai`` reaches the eventual ``ZoneInfo(...)``
130
+ call and crashes much later with a confusing ``ZoneInfoNotFoundError``.
131
+ """
132
+ from zoneinfo import available_timezones # noqa: PLC0415
133
+
134
+ zones = available_timezones()
135
+ if v not in zones:
136
+ raise ValueError(
137
+ f"app.timezone {v!r} is not a valid IANA zone; "
138
+ f"see `python -c 'import zoneinfo; print(sorted(zoneinfo.available_timezones()))'` "
139
+ f"for the list this host supports"
140
+ )
141
+ return v
142
+
121
143
  @field_validator("llm_providers", mode="before")
122
144
  @classmethod
123
145
  def _parse_llm_providers(cls, v: Any) -> Any:
@@ -0,0 +1,434 @@
1
+ """Plugin dependency resolution & installation.
2
+
3
+ Called by :class:`PluginManager` during install / upgrade to satisfy a
4
+ plugin's declared third-party Python dependencies (PEP 508 specifiers
5
+ in ``deeptrade_plugin.yaml::dependencies``).
6
+
7
+ Design context: ``CHANGELOG.md`` v0.4.0 (per-plugin Python dependency
8
+ management; framework-interpreter install model rather than per-plugin venv).
9
+
10
+ Key invariants:
11
+ * Plugins share the framework's Python interpreter — deps must be
12
+ importable from ``sys.executable``'s site-packages.
13
+ * Installer preference: ``uv`` (with ``--python <sys.executable>``) →
14
+ ``python -m pip``. Both missing → :class:`DepInstallError`.
15
+ * Conflicts (installed version not satisfying spec) are detected at
16
+ install time and raised, not silently overridden.
17
+ * Failed installs leave any already-installed deps in the environment
18
+ (do not unwind — common deps would be wrongly removed).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from collections.abc import Callable, Iterable
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from importlib import metadata as importlib_metadata
32
+ from pathlib import Path
33
+
34
+ from packaging.requirements import InvalidRequirement, Requirement
35
+ from packaging.utils import canonicalize_name
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ DEFAULT_TIMEOUT_SECONDS = 300
40
+ # Plan §2.6: dry-run preflight gets its own short timeout so a stuck `uv`
41
+ # can't block the real install path.
42
+ DRY_RUN_TIMEOUT_SECONDS = 60
43
+
44
+
45
+ class DepInstallError(Exception):
46
+ """Dependency resolution, conflict, or install-subprocess failure."""
47
+
48
+
49
+ @dataclass
50
+ class DepConflict:
51
+ requirement: Requirement
52
+ installed_version: str
53
+ owner: str # human-readable attribution string
54
+
55
+ def __str__(self) -> str:
56
+ return (
57
+ f"{self.requirement} not satisfied by installed "
58
+ f"{self.requirement.name}=={self.installed_version} (owner: {self.owner})"
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class DepPlan:
64
+ to_install: list[Requirement] = field(default_factory=list)
65
+ skipped: list[tuple[Requirement, str]] = field(default_factory=list)
66
+ conflicts: list[DepConflict] = field(default_factory=list)
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Parsing
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def parse_specs(specs: Iterable[str]) -> list[Requirement]:
75
+ """Parse PEP 508 specifier strings into ``Requirement`` objects.
76
+
77
+ Rejects VCS/URL forms and duplicate package names. Same validation
78
+ as :meth:`PluginMetadata._dependencies_valid` — kept here so callers
79
+ that pre-validated metadata can reuse the parsed objects, and so
80
+ runtime code (not just Pydantic) reads idiomatically.
81
+ """
82
+ out: list[Requirement] = []
83
+ seen: dict[str, str] = {}
84
+ for raw in specs:
85
+ try:
86
+ req = Requirement(raw)
87
+ except InvalidRequirement as e:
88
+ raise DepInstallError(f"invalid dependency spec {raw!r}: {e}") from e
89
+ if req.url:
90
+ raise DepInstallError(
91
+ f"dependency {raw!r}: VCS/URL forms not allowed; use PEP 508 specifiers"
92
+ )
93
+ canonical = canonicalize_name(req.name)
94
+ if canonical in seen:
95
+ raise DepInstallError(
96
+ f"duplicate dependency {req.name!r} (also {seen[canonical]!r}); combine into one spec"
97
+ )
98
+ seen[canonical] = raw
99
+ out.append(req)
100
+ return out
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Planning (satisfied / conflict / to-install)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def plan_install(
109
+ requirements: list[Requirement],
110
+ *,
111
+ attribute_conflict: Callable[[str], str | None] | None = None,
112
+ ) -> DepPlan:
113
+ """Sort each requirement into ``skipped`` / ``to_install`` / ``conflicts``.
114
+
115
+ ``attribute_conflict(canonical_name)`` returns a human-readable owner
116
+ (e.g. ``"framework core dependency"``, ``"plugin foo"``) for the package
117
+ when a conflict is detected. Falls back to ``"external (already in
118
+ environment)"`` if ``None`` or returns ``None``.
119
+
120
+ Marker-gated requirements (``"x>=1; python_version < '3.10'"``) whose
121
+ marker evaluates to False in the current environment are dropped.
122
+ """
123
+ plan = DepPlan()
124
+ for req in requirements:
125
+ if req.marker is not None and not req.marker.evaluate():
126
+ logger.debug("Skipping %s — marker did not match current env", req)
127
+ continue
128
+ try:
129
+ installed = importlib_metadata.version(req.name)
130
+ except importlib_metadata.PackageNotFoundError:
131
+ plan.to_install.append(req)
132
+ continue
133
+ if not req.specifier or req.specifier.contains(installed, prereleases=True):
134
+ plan.skipped.append((req, installed))
135
+ continue
136
+ owner = (
137
+ attribute_conflict(canonicalize_name(req.name)) if attribute_conflict else None
138
+ ) or "external (already in environment)"
139
+ plan.conflicts.append(DepConflict(req, installed, owner))
140
+ return plan
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Installer detection + subprocess invocation
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def detect_installer() -> tuple[str, list[str]]:
149
+ """Return ``(label, argv_prefix)`` for the chosen installer.
150
+
151
+ Prefers ``uv`` (with ``--python <sys.executable>`` so packages land in
152
+ the framework's interpreter). Falls back to ``python -m pip``. Raises
153
+ :class:`DepInstallError` if neither is available.
154
+ """
155
+ uv_path = shutil.which("uv")
156
+ if uv_path:
157
+ return ("uv", [uv_path, "pip", "install", "--python", sys.executable])
158
+ try:
159
+ pip_check = subprocess.run( # noqa: S603 — args fully controlled
160
+ [sys.executable, "-m", "pip", "--version"],
161
+ capture_output=True,
162
+ text=True,
163
+ timeout=15,
164
+ )
165
+ except (subprocess.TimeoutExpired, FileNotFoundError):
166
+ pip_check = None
167
+ if pip_check is not None and pip_check.returncode == 0:
168
+ return ("pip", [sys.executable, "-m", "pip", "install"])
169
+ raise DepInstallError(
170
+ "no installer available: neither 'uv' (on PATH) nor 'pip' (python -m pip) usable"
171
+ )
172
+
173
+
174
+ def run_install(
175
+ requirements: list[Requirement],
176
+ *,
177
+ reinstall: bool = False,
178
+ timeout_seconds: int | None = None,
179
+ ) -> None:
180
+ """Spawn the installer subprocess. Raises :class:`DepInstallError` on
181
+ timeout, missing binary, or non-zero exit. stdout/stderr are inherited
182
+ so users see pip/uv progress in real time."""
183
+ if not requirements:
184
+ return
185
+ label, argv = detect_installer()
186
+ if reinstall:
187
+ argv.append("--upgrade")
188
+ argv.extend(str(r) for r in requirements)
189
+ timeout = timeout_seconds if timeout_seconds is not None else _resolved_timeout()
190
+ logger.info(
191
+ "Installing %d plugin dep(s) via %s: %s",
192
+ len(requirements),
193
+ label,
194
+ [str(r) for r in requirements],
195
+ )
196
+ try:
197
+ result = subprocess.run( # noqa: S603 — args fully controlled
198
+ argv,
199
+ timeout=timeout,
200
+ text=True,
201
+ )
202
+ except subprocess.TimeoutExpired as e:
203
+ raise DepInstallError(
204
+ f"dependency install timed out after {timeout}s ({label}): "
205
+ f"{[str(r) for r in requirements]}"
206
+ ) from e
207
+ except FileNotFoundError as e:
208
+ raise DepInstallError(f"installer disappeared mid-run: {e}") from e
209
+ if result.returncode != 0:
210
+ raise DepInstallError(
211
+ f"{label} install failed (exit {result.returncode}) for: "
212
+ f"{[str(r) for r in requirements]}"
213
+ )
214
+
215
+
216
+ def _resolved_timeout() -> int:
217
+ raw = os.environ.get("DEEPTRADE_DEP_INSTALL_TIMEOUT")
218
+ if raw is None:
219
+ return DEFAULT_TIMEOUT_SECONDS
220
+ try:
221
+ v = int(raw)
222
+ if v <= 0:
223
+ raise ValueError("must be positive")
224
+ return v
225
+ except ValueError:
226
+ logger.warning(
227
+ "Invalid DEEPTRADE_DEP_INSTALL_TIMEOUT=%r; using default %ds",
228
+ raw,
229
+ DEFAULT_TIMEOUT_SECONDS,
230
+ )
231
+ return DEFAULT_TIMEOUT_SECONDS
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # v0.7 H6 — dry-run preflight + dep snapshot
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def framework_core_canonicals() -> set[str]:
240
+ """Return canonical names of every direct dependency declared by the
241
+ installed ``deeptrade-quant`` distribution. Used by the dry-run
242
+ preflight (H6-a) to flag plugin installs that would silently mutate
243
+ framework deps.
244
+
245
+ Returns an empty set when the framework isn't installed via a wheel
246
+ (running from source without ``pip install -e``); the protection is
247
+ best-effort and shouldn't block development setups."""
248
+ try:
249
+ dist = importlib_metadata.distribution("deeptrade-quant")
250
+ except importlib_metadata.PackageNotFoundError:
251
+ return set()
252
+ out: set[str] = set()
253
+ for raw in dist.requires or []:
254
+ try:
255
+ req = Requirement(raw)
256
+ except InvalidRequirement:
257
+ continue
258
+ # Drop extras-only requirements (e.g. dev / plugin-runtime entries
259
+ # whose marker is "extra == 'dev'"). They are not part of the
260
+ # base install footprint we want to protect.
261
+ if req.marker is not None:
262
+ try:
263
+ if not req.marker.evaluate({"extra": ""}):
264
+ continue
265
+ except Exception: # noqa: BLE001 — marker eval edge cases
266
+ continue
267
+ out.add(canonicalize_name(req.name))
268
+ return out
269
+
270
+
271
+ def _parse_dry_run_changes(text: str, watched: set[str]) -> set[str]:
272
+ """Pull canonical package names out of a uv dry-run output that appear
273
+ with a change-indicating marker. Returns names that intersect
274
+ ``watched``.
275
+
276
+ uv's dry-run lines we treat as "would touch the existing install":
277
+
278
+ * ``- pkg==ver`` — would remove (or downgrade away from current)
279
+ * ``~ pkg ...`` — would update/downgrade in place
280
+
281
+ ``+ pkg==ver`` lines are skipped because they represent a fresh
282
+ install — by definition that package wasn't already in the env, so it
283
+ cannot be a framework core dep currently in use."""
284
+ if not watched:
285
+ return set()
286
+ affected: set[str] = set()
287
+ for line in text.splitlines():
288
+ stripped = line.strip()
289
+ if len(stripped) < 3:
290
+ continue
291
+ if not (stripped.startswith("- ") or stripped.startswith("~ ")):
292
+ continue
293
+ # Grab the first whitespace-separated token after the marker,
294
+ # then trim any version suffix (``pkg==1.2.3`` → ``pkg``;
295
+ # ``pkg`` alone is also valid for the ``~`` "from a to b" form).
296
+ rest = stripped[2:].lstrip().split()[0].split("==", 1)[0]
297
+ if not rest:
298
+ continue
299
+ try:
300
+ canonical = canonicalize_name(rest)
301
+ except Exception: # noqa: BLE001 — defensive against odd uv output
302
+ continue
303
+ if canonical in watched:
304
+ affected.add(canonical)
305
+ return affected
306
+
307
+
308
+ def preflight_dry_run(
309
+ requirements: list[Requirement],
310
+ *,
311
+ watched: set[str] | None = None,
312
+ timeout_seconds: int = DRY_RUN_TIMEOUT_SECONDS,
313
+ ) -> set[str]:
314
+ """Best-effort preflight: run ``uv pip install --dry-run`` against
315
+ ``requirements`` and return the subset of ``watched`` (canonical
316
+ names) the install would upgrade / downgrade / remove.
317
+
318
+ Returns an empty set silently when:
319
+
320
+ * ``requirements`` is empty;
321
+ * ``uv`` is not on PATH (``pip`` has no equivalent dry-run shape);
322
+ * uv exits non-zero (let the real install path surface the real error);
323
+ * uv times out — we don't want to block on a stuck resolver.
324
+
325
+ The caller decides what to do with the affected names: in the v0.7
326
+ H6 plan, ``PluginManager._handle_dependencies`` raises ``DepInstallError``
327
+ when the set is non-empty and ``--allow-core-bump`` wasn't passed."""
328
+ if not requirements:
329
+ return set()
330
+ if watched is None:
331
+ watched = framework_core_canonicals()
332
+ if not watched:
333
+ return set()
334
+
335
+ uv_path = shutil.which("uv")
336
+ if not uv_path:
337
+ # ``pip`` has no analogous structured dry-run output; fall back to
338
+ # no preflight rather than block the install path.
339
+ return set()
340
+
341
+ argv = [uv_path, "pip", "install", "--dry-run", "--python", sys.executable]
342
+ argv.extend(str(r) for r in requirements)
343
+ try:
344
+ result = subprocess.run( # noqa: S603 — args fully controlled
345
+ argv,
346
+ capture_output=True,
347
+ text=True,
348
+ timeout=timeout_seconds,
349
+ )
350
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
351
+ logger.warning("uv dry-run preflight skipped: %s", e)
352
+ return set()
353
+
354
+ if result.returncode != 0:
355
+ logger.info(
356
+ "uv dry-run exited %d; skipping core-bump preflight (real install "
357
+ "will surface the underlying resolver error)",
358
+ result.returncode,
359
+ )
360
+ return set()
361
+
362
+ # uv prints the change plan to stderr; stdout used as fallback for
363
+ # uv versions that route it differently.
364
+ output = result.stderr or result.stdout or ""
365
+ return _parse_dry_run_changes(output, watched)
366
+
367
+
368
+ def _snapshot_argv() -> list[str] | None:
369
+ """Pick the freeze-list command to use for snapshots.
370
+
371
+ Mirrors :func:`detect_installer` priority: prefer ``uv pip list``
372
+ (works on uv-managed venvs which often ship without pip) and fall
373
+ back to ``python -m pip list``. Returns ``None`` when neither path
374
+ is usable — snapshot becomes a no-op rather than blocking install."""
375
+ uv_path = shutil.which("uv")
376
+ if uv_path:
377
+ return [uv_path, "pip", "list", "--python", sys.executable, "--format=freeze"]
378
+ try:
379
+ check = subprocess.run( # noqa: S603 — args fully controlled
380
+ [sys.executable, "-m", "pip", "--version"],
381
+ capture_output=True,
382
+ text=True,
383
+ timeout=15,
384
+ )
385
+ except (subprocess.TimeoutExpired, FileNotFoundError):
386
+ return None
387
+ if check.returncode == 0:
388
+ return [sys.executable, "-m", "pip", "list", "--format=freeze"]
389
+ return None
390
+
391
+
392
+ def write_dep_snapshot(plugin_id: str, dir_root: Path) -> Path | None:
393
+ """Capture a ``pip list --format=freeze`` baseline to
394
+ ``<dir_root>/<plugin_id>/pre-install-<UTC-ISO>.txt`` so users have a
395
+ point-in-time record to diff against when an install goes sideways.
396
+
397
+ The snapshotting subprocess prefers ``uv pip list`` (uv venvs often
398
+ ship without pip) and falls back to ``python -m pip list``. Returns
399
+ the file path on success; ``None`` if neither is available or the
400
+ write failed (best-effort — never aborts install)."""
401
+ argv = _snapshot_argv()
402
+ if argv is None:
403
+ logger.warning("dep snapshot skipped — neither uv nor pip is usable for `pip list`")
404
+ return None
405
+ try:
406
+ proc = subprocess.run( # noqa: S603 — args fully controlled
407
+ argv,
408
+ capture_output=True,
409
+ text=True,
410
+ timeout=DRY_RUN_TIMEOUT_SECONDS,
411
+ )
412
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
413
+ logger.warning("dep snapshot skipped — list subprocess failed: %s", e)
414
+ return None
415
+ if proc.returncode != 0:
416
+ logger.warning(
417
+ "dep snapshot skipped — list exited %d:\n%s",
418
+ proc.returncode,
419
+ (proc.stderr or "").strip(),
420
+ )
421
+ return None
422
+
423
+ target_dir = dir_root / plugin_id
424
+ target_dir.mkdir(parents=True, exist_ok=True)
425
+ # Use UTC for the filename so multi-host installs collated from one
426
+ # NAS sort sensibly; safe-ish for filenames across OSes (no ``:``).
427
+ ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
428
+ path = target_dir / f"pre-install-{ts}.txt"
429
+ try:
430
+ path.write_text(proc.stdout, encoding="utf-8")
431
+ except OSError as e:
432
+ logger.warning("dep snapshot write failed: %s", e)
433
+ return None
434
+ return path