deeptrade-quant 0.6.0__tar.gz → 0.7.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 (70) hide show
  1. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/CHANGELOG.md +39 -0
  2. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/PKG-INFO +1 -1
  3. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/__init__.py +1 -1
  4. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/cli_plugin.py +12 -0
  5. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/config.py +22 -0
  6. deeptrade_quant-0.7.0/deeptrade/core/dep_installer.py +434 -0
  7. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/paths.py +17 -1
  8. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/plugin_manager.py +74 -5
  9. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/pyproject.toml +1 -1
  10. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_config.py +38 -0
  11. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_plugin_dependencies.py +203 -0
  12. deeptrade_quant-0.6.0/deeptrade/core/dep_installer.py +0 -226
  13. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/.gitignore +0 -0
  14. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/LICENSE +0 -0
  15. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/README.md +0 -0
  16. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/cli.py +0 -0
  17. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/cli_config.py +0 -0
  18. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/cli_data.py +0 -0
  19. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/__init__.py +0 -0
  20. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/config_migrations.py +0 -0
  21. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/db.py +0 -0
  22. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/github_fetch.py +0 -0
  23. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/llm_client.py +0 -0
  24. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/llm_manager.py +0 -0
  25. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/logging_config.py +0 -0
  26. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/migrations/__init__.py +0 -0
  27. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  28. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
  29. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
  30. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  31. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/plugin_source.py +0 -0
  32. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/registry.py +0 -0
  33. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/run_status.py +0 -0
  34. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/secrets.py +0 -0
  35. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/core/tushare_client.py +0 -0
  36. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/__init__.py +0 -0
  37. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/base.py +0 -0
  38. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/errors.py +0 -0
  39. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/events.py +0 -0
  40. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/llm.py +0 -0
  41. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/plugins_api/metadata.py +0 -0
  42. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/deeptrade/theme.py +0 -0
  43. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/__init__.py +0 -0
  44. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/cli/__init__.py +0 -0
  45. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/cli/test_config_cmd.py +0 -0
  46. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/cli/test_plugin_cmd.py +0 -0
  47. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/cli/test_routing.py +0 -0
  48. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
  49. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/conftest.py +0 -0
  50. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/__init__.py +0 -0
  51. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_config_migrations.py +0 -0
  52. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_db.py +0 -0
  53. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_github_fetch.py +0 -0
  54. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_llm_client.py +0 -0
  55. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_llm_manager.py +0 -0
  56. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_paths.py +0 -0
  57. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_plugin_install.py +0 -0
  58. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_plugin_security.py +0 -0
  59. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_plugin_source.py +0 -0
  60. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_plugin_upgrade.py +0 -0
  61. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_registry.py +0 -0
  62. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_secrets.py +0 -0
  63. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_tushare_classifier.py +0 -0
  64. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_tushare_client.py +0 -0
  65. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/core/test_tushare_retry_r1.py +0 -0
  66. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/plugins_api/__init__.py +0 -0
  67. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/plugins_api/test_api_version_2.py +0 -0
  68. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/plugins_api/test_errors.py +0 -0
  69. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/plugins_api/test_protocol.py +0 -0
  70. {deeptrade_quant-0.6.0 → deeptrade_quant-0.7.0}/tests/test_smoke.py +0 -0
@@ -2,6 +2,45 @@
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.7.0] — 2026-05-15 — 依赖隔离稳健化 + timezone IANA 校验
6
+
7
+ 本版按 2026-05-15 评审研判文档 §5 v0.7 路线落地 2 项主线,至此 v0.5 § 5 全部"采纳项"清零:
8
+
9
+ 1. **H6 依赖隔离稳健化**:
10
+ - **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 装机不动手的承诺。
11
+ - **dep snapshot**:``run_install`` 之前把 ``pip list --format=freeze`` 输出落到 ``~/.deeptrade/dep_snapshots/<plugin_id>/pre-install-<UTC>.txt``。失败时错误信息把这个目录甩到用户面前,便于 ``diff`` 回滚。
12
+ - 仍**不做**:venv-per-plugin / subprocess 隔离(重型方案,违反"框架轻");框架 deps 的 pin lockfile(会污染用户项目的解析)。
13
+ 2. **L1 timezone IANA 校验**:``AppConfig.app_timezone`` 改用 ``zoneinfo.available_timezones()`` 做硬校验,把 ``Asia/Shangai`` 这种近似拼写在写时拦住,而不是延迟到第一次 ``ZoneInfo(...)`` 调用爆 ``ZoneInfoNotFoundError``。``base_url`` HTTPS 强制按方案 §4 评估**仍不做**(本地 LLM 网关 / Ollama 常用 http)。
14
+
15
+ ### Added
16
+
17
+ - ``deeptrade/core/dep_installer.py``:
18
+ - ``framework_core_canonicals()``——读取 ``deeptrade-quant`` distribution 的 ``Requires``,过滤掉 ``extra`` marker,输出框架核心 dep 的 canonical 名字集合。从源码运行(未 ``pip install -e``)时返回空集合,让预检自动降级为 no-op。
19
+ - ``preflight_dry_run(reqs, *, watched)``——best-effort 跑 ``uv pip install --dry-run``;解析 stderr / stdout 中的 ``~`` 与 ``-`` 行;返回与 ``watched`` 相交的 canonical 名字集合。``uv`` 缺席、resolution 失败、超时——一律返回空集合,让真正的 ``run_install`` 路径报底层错误,预检不抢话语权。
20
+ - ``_parse_dry_run_changes(text, watched)``——dry-run 输出解析的纯函数版本,便于单测覆盖。
21
+ - ``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``。
22
+ - ``DRY_RUN_TIMEOUT_SECONDS`` 短超时常量,避免被卡住的 uv 阻塞真正的 install。
23
+ - ``deeptrade/core/paths.py::dep_snapshots_dir()``——``~/.deeptrade/dep_snapshots/``;纳入 ``ensure_layout()`` 自动创建。
24
+ - ``deeptrade/core/plugin_manager.py::PluginManager.install`` / ``upgrade`` 接 ``allow_core_bump: bool = False``;``_handle_dependencies`` 在 ``run_install`` 之前先跑 preflight 与 snapshot,并在 install error 信息里附上 snapshot 目录 hint。
25
+ - ``deeptrade/cli_plugin.py``:``plugin install`` 与 ``plugin upgrade`` 加 ``--allow-core-bump`` flag(中文 help 文案)。
26
+ - ``deeptrade/core/config.py::AppConfig._validate_app_timezone``——基于 ``zoneinfo.available_timezones()`` 的 ``@field_validator``;非法值抛 Pydantic ``ValidationError`` 并指出如何打印当前主机支持的全部 zones。
27
+ - 测试:``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 双向)。
28
+
29
+ ### Changed
30
+
31
+ - ``deeptrade/core/plugin_manager.py::_handle_dependencies``:新增 ``allow_core_bump`` kwarg;``run_install`` 调用包了 ``try/except DepInstallError``,把异常包裹一层附上 snapshot 目录提示,便于排障。
32
+ - ``deeptrade/core/dep_installer.py::write_dep_snapshot``:拆出 ``_snapshot_argv()`` 帮助选择 ``uv pip list`` vs ``python -m pip list``,与 ``detect_installer()`` 的优先级一致。
33
+
34
+ ### Migration notes
35
+
36
+ - **用户视角零强制动作**:默认行为是更严的(核心 dep 改动需要显式确认),但常见插件不会动框架核心 dep——绝大多数 ``deeptrade plugin install <短名>`` 命令仍然零摩擦。
37
+ - **遇到核心依赖冲突如何处理**:v0.7 之后若看到 ``plugin install would change framework core dep(s) [...]`` 的拒绝信息,先看 dry-run 报告的具体包:
38
+ - 如果是 ``duckdb`` / ``pydantic`` / ``click`` / ``typer`` 等加载链路上的运行时关键件——大概率是插件 spec 写得太死,反馈给插件作者放宽;强行 ``--allow-core-bump`` 可能让框架进程立刻 import 失败。
39
+ - 如果只是 minor 版本提升且已读过 release note——加 ``--allow-core-bump`` 显式确认放行。
40
+ - **uv 缺席的主机**:``preflight_dry_run`` 自动跳过,行为与 v0.4 一致。``write_dep_snapshot`` 也能用 ``python -m pip list`` 兜底(虽然 uv-only venv 上 pip 通常缺席,此时 snapshot 静默 no-op)。
41
+ - **dep_snapshots 目录的清理**:框架本身不做轮转。如果该目录变大,可以放心 ``rm`` 老的 ``pre-install-*.txt`` 文件——它们只用于回溯,不影响运行时。
42
+ - **timezone**:升级到 v0.7 之后第一次 ``Database()`` 打开会触发自动迁移,迁移本身不动 ``app.timezone``;但如果你之前不小心存了 typo 进 ``app_config``,下一次 ``ConfigService.get_app_config()`` 会抛 ``ValidationError``。修法:``deeptrade config set app.timezone Asia/Shanghai``(或你本地的正确 IANA 名)。
43
+
5
44
  ## [v0.6.0] — 2026-05-15 — 插件运行时 API + LLM 方言 + 杂项收尾
6
45
 
7
46
  本版按 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.7.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.7.0"
6
6
  __all__ = ["__version__"]
@@ -75,6 +75,11 @@ def cmd_install(
75
75
  reinstall_deps: bool = typer.Option(
76
76
  False, "--reinstall-deps", help="对全部依赖重新运行安装器(uv/pip --upgrade)"
77
77
  ),
78
+ allow_core_bump: bool = typer.Option(
79
+ False,
80
+ "--allow-core-bump",
81
+ help="允许该插件的依赖安装顺带升级 / 降级 / 移除框架核心 dep(默认拒绝)",
82
+ ),
78
83
  ) -> None:
79
84
  """从注册表 / GitHub URL / 本地目录安装一个插件。"""
80
85
  resolver = SourceResolver()
@@ -109,6 +114,7 @@ def cmd_install(
109
114
  resolved.path,
110
115
  install_deps=not no_deps,
111
116
  reinstall_deps=reinstall_deps,
117
+ allow_core_bump=allow_core_bump,
112
118
  )
113
119
  except PluginInstallError as e:
114
120
  typer.echo(f"✘ 安装失败:{e}")
@@ -269,6 +275,11 @@ def cmd_upgrade(
269
275
  reinstall_deps: bool = typer.Option(
270
276
  False, "--reinstall-deps", help="对全部依赖重新运行安装器(uv/pip --upgrade)"
271
277
  ),
278
+ allow_core_bump: bool = typer.Option(
279
+ False,
280
+ "--allow-core-bump",
281
+ help="允许该插件的依赖升级顺带升级 / 降级 / 移除框架核心 dep(默认拒绝)",
282
+ ),
272
283
  ) -> None:
273
284
  """升级一个已安装的插件。
274
285
 
@@ -292,6 +303,7 @@ def cmd_upgrade(
292
303
  resolved.path,
293
304
  install_deps=not no_deps,
294
305
  reinstall_deps=reinstall_deps,
306
+ allow_core_bump=allow_core_bump,
295
307
  )
296
308
  except PluginNotFoundError as e:
297
309
  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
@@ -43,7 +43,23 @@ def plugins_cache_dir() -> Path:
43
43
  return home_dir() / "plugins" / "cache"
44
44
 
45
45
 
46
+ def dep_snapshots_dir() -> Path:
47
+ """v0.7 H6-b — pre-install ``pip list --format=freeze`` baselines
48
+ keyed by plugin_id. Each plugin install / upgrade adds one
49
+ ``pre-install-<UTC>.txt`` snapshot here so users can diff back to
50
+ the working state when a botched install leaves the env in a weird
51
+ place."""
52
+ return home_dir() / "dep_snapshots"
53
+
54
+
46
55
  def ensure_layout() -> None:
47
56
  """Create the standard ~/.deeptrade subtree if missing. Idempotent."""
48
- for d in (home_dir(), logs_dir(), reports_dir(), plugins_dir(), plugins_cache_dir()):
57
+ for d in (
58
+ home_dir(),
59
+ logs_dir(),
60
+ reports_dir(),
61
+ plugins_dir(),
62
+ plugins_cache_dir(),
63
+ dep_snapshots_dir(),
64
+ ):
49
65
  d.mkdir(parents=True, exist_ok=True)