deeptrade-quant 0.3.0__tar.gz → 0.4.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 (61) hide show
  1. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/PKG-INFO +6 -3
  2. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/__init__.py +1 -1
  3. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/cli.py +5 -3
  4. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/cli_config.py +8 -10
  5. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/cli_plugin.py +30 -20
  6. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/config.py +12 -3
  7. deeptrade_quant-0.4.0/deeptrade/core/dep_installer.py +225 -0
  8. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/github_fetch.py +9 -25
  9. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/llm_manager.py +2 -4
  10. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_manager.py +152 -16
  11. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_source.py +3 -8
  12. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/registry.py +8 -22
  13. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/tushare_client.py +173 -17
  14. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/metadata.py +31 -0
  15. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/pyproject.toml +13 -3
  16. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_config.py +2 -6
  17. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_db.py +1 -0
  18. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_github_fetch.py +3 -3
  19. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_llm_client.py +6 -18
  20. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_llm_manager.py +1 -3
  21. deeptrade_quant-0.4.0/tests/core/test_plugin_dependencies.py +503 -0
  22. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_plugin_install.py +4 -12
  23. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_plugin_source.py +3 -1
  24. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_plugin_upgrade.py +3 -3
  25. deeptrade_quant-0.4.0/tests/core/test_tushare_classifier.py +274 -0
  26. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_tushare_client.py +14 -28
  27. deeptrade_quant-0.4.0/tests/core/test_tushare_retry_r1.py +234 -0
  28. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/.gitignore +0 -0
  29. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/LICENSE +0 -0
  30. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/README.md +0 -0
  31. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/cli_data.py +0 -0
  32. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/__init__.py +0 -0
  33. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/config_migrations.py +0 -0
  34. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/db.py +0 -0
  35. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/llm_client.py +0 -0
  36. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/logging_config.py +0 -0
  37. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/__init__.py +0 -0
  38. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  39. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  40. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/paths.py +0 -0
  41. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/run_status.py +0 -0
  42. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/core/secrets.py +0 -0
  43. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/__init__.py +0 -0
  44. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/base.py +0 -0
  45. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/events.py +0 -0
  46. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/llm.py +0 -0
  47. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/deeptrade/theme.py +0 -0
  48. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/__init__.py +0 -0
  49. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/cli/__init__.py +0 -0
  50. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/cli/test_config_cmd.py +0 -0
  51. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/cli/test_plugin_cmd.py +0 -0
  52. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/cli/test_routing.py +0 -0
  53. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/conftest.py +0 -0
  54. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/__init__.py +0 -0
  55. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_config_migrations.py +0 -0
  56. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_paths.py +0 -0
  57. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_registry.py +0 -0
  58. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/core/test_secrets.py +0 -0
  59. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/plugins_api/__init__.py +0 -0
  60. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/plugins_api/test_protocol.py +0 -0
  61. {deeptrade_quant-0.3.0 → deeptrade_quant-0.4.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -15,21 +15,24 @@ Requires-Dist: duckdb>=1.0
15
15
  Requires-Dist: keyring>=25.0
16
16
  Requires-Dist: openai>=1.0
17
17
  Requires-Dist: packaging>=23.0
18
- Requires-Dist: pandas>=2.2
19
18
  Requires-Dist: pydantic>=2.7
20
19
  Requires-Dist: pyyaml>=6.0
21
20
  Requires-Dist: questionary>=2.0
22
21
  Requires-Dist: rich>=13.7
23
22
  Requires-Dist: tenacity>=8.0
24
- Requires-Dist: tushare>=1.4
25
23
  Requires-Dist: typer>=0.12
26
24
  Provides-Extra: dev
27
25
  Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pandas>=2.2; extra == 'dev'
28
27
  Requires-Dist: pre-commit>=3.7; extra == 'dev'
29
28
  Requires-Dist: pytest-mock>=3.12; extra == 'dev'
30
29
  Requires-Dist: pytest>=8.0; extra == 'dev'
31
30
  Requires-Dist: ruff>=0.5; extra == 'dev'
31
+ Requires-Dist: tushare>=1.4; extra == 'dev'
32
32
  Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
33
+ Provides-Extra: plugin-runtime
34
+ Requires-Dist: pandas>=2.2; extra == 'plugin-runtime'
35
+ Requires-Dist: tushare>=1.4; extra == 'plugin-runtime'
33
36
  Description-Content-Type: text/markdown
34
37
 
35
38
  # DeepTrade
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.4.0"
6
6
  __all__ = ["__version__"]
@@ -103,8 +103,7 @@ def _build_plugin_command(plugin_id: str) -> click.Command | None:
103
103
  )
104
104
  def _disabled() -> None:
105
105
  typer.echo(
106
- f"✘ plugin {plugin_id!r} is disabled; "
107
- f"run `deeptrade plugin enable {plugin_id}`"
106
+ f"✘ plugin {plugin_id!r} is disabled; run `deeptrade plugin enable {plugin_id}`"
108
107
  )
109
108
  raise typer.Exit(2)
110
109
 
@@ -204,15 +203,16 @@ def init(
204
203
  cmd_set_llm()
205
204
 
206
205
 
207
-
208
206
  @app.command(name="db", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
209
207
  def db_cmd(ctx: click.Context) -> None:
210
208
  """Database migration and management commands (legacy stub; use `deeptrade db init` via group if added)."""
211
209
  pass
212
210
 
211
+
213
212
  db_app = typer.Typer(name="db", help="Database migration and management commands.")
214
213
  app.add_typer(db_app, name="db")
215
214
 
215
+
216
216
  @db_app.command("init")
217
217
  def db_init() -> None:
218
218
  """Initialize the core database tables and apply migrations."""
@@ -232,10 +232,12 @@ def db_init() -> None:
232
232
  finally:
233
233
  db.close()
234
234
 
235
+
235
236
  @db_app.command("upgrade")
236
237
  def db_upgrade() -> None:
237
238
  """Apply any pending core migrations."""
238
239
  db_init()
239
240
 
241
+
240
242
  if __name__ == "__main__":
241
243
  app()
@@ -156,10 +156,12 @@ def cmd_set_llm() -> None:
156
156
  existing = sorted(cfg.llm_providers.keys())
157
157
 
158
158
  if existing:
159
- choices = ["[+] Add new provider"] + [f"[~] {n}" for n in existing] + ["[x] Delete a provider"]
160
- picked = questionary.select(
161
- "Pick action:", choices=choices
162
- ).ask()
159
+ choices = (
160
+ ["[+] Add new provider"]
161
+ + [f"[~] {n}" for n in existing]
162
+ + ["[x] Delete a provider"]
163
+ )
164
+ picked = questionary.select("Pick action:", choices=choices).ask()
163
165
  if picked is None:
164
166
  raise typer.Exit(1)
165
167
  if picked.startswith("[+]"):
@@ -177,9 +179,7 @@ def cmd_set_llm() -> None:
177
179
 
178
180
 
179
181
  def _set_llm_new(svc: ConfigService) -> None:
180
- name = questionary.text(
181
- "Provider name (e.g. deepseek, qwen-plus, kimi):"
182
- ).ask()
182
+ name = questionary.text("Provider name (e.g. deepseek, qwen-plus, kimi):").ask()
183
183
  if not name:
184
184
  raise typer.Exit(1)
185
185
  name = name.strip()
@@ -259,9 +259,7 @@ def _prompt_and_save_provider(
259
259
  raise typer.Exit(2) from e
260
260
 
261
261
  api_key_prompt = (
262
- "API key (leave empty to keep existing):"
263
- if defaults is not None
264
- else "API key:"
262
+ "API key (leave empty to keep existing):" if defaults is not None else "API key:"
265
263
  )
266
264
  api_key = questionary.password(api_key_prompt).ask()
267
265
  if api_key is None:
@@ -58,9 +58,7 @@ def _format_origin(resolved: ResolvedSource) -> str:
58
58
  if resolved.origin == "local":
59
59
  return f"本地路径 ({d.get('local_path', resolved.path)})"
60
60
  if resolved.origin == "github_registry":
61
- return (
62
- f"GitHub 注册表 ({d['repo']}@{d['ref']}, subdir={d['subdir']})"
63
- )
61
+ return f"GitHub 注册表 ({d['repo']}@{d['ref']}, subdir={d['subdir']})"
64
62
  if resolved.origin == "github_url":
65
63
  return f"GitHub URL ({d['repo']}@{d['ref']})"
66
64
  return resolved.origin
@@ -68,13 +66,17 @@ def _format_origin(resolved: ResolvedSource) -> str:
68
66
 
69
67
  @app.command("install")
70
68
  def cmd_install(
71
- source: str = typer.Argument(
72
- ..., help="短名(注册表)/ 本地路径 / GitHub URL"
73
- ),
69
+ source: str = typer.Argument(..., help="短名(注册表)/ 本地路径 / GitHub URL"),
74
70
  ref: str | None = typer.Option(
75
71
  None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
76
72
  ),
77
73
  yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt"),
74
+ no_deps: bool = typer.Option(
75
+ False, "--no-deps", help="Skip plugin Python dependency installation"
76
+ ),
77
+ reinstall_deps: bool = typer.Option(
78
+ False, "--reinstall-deps", help="Re-run installer for all deps (uv/pip --upgrade)"
79
+ ),
78
80
  ) -> None:
79
81
  """Install a plugin from the registry, a GitHub URL, or a local directory."""
80
82
  resolver = SourceResolver()
@@ -94,6 +96,8 @@ def cmd_install(
94
96
  typer.echo("─── 即将安装 ─────────────────────────────")
95
97
  typer.echo(f"来源: {_format_origin(resolved)}")
96
98
  typer.echo(summarize_for_install(meta, resolved.path))
99
+ if no_deps and meta.dependencies:
100
+ typer.echo("(deps install will be SKIPPED — --no-deps)")
97
101
  typer.echo("──────────────────────────────────────────")
98
102
  if not yes:
99
103
  ok = questionary.confirm("确认安装?", default=False).ask()
@@ -103,7 +107,11 @@ def cmd_install(
103
107
 
104
108
  db, mgr = _open()
105
109
  try:
106
- rec = mgr.install(resolved.path)
110
+ rec = mgr.install(
111
+ resolved.path,
112
+ install_deps=not no_deps,
113
+ reinstall_deps=reinstall_deps,
114
+ )
107
115
  except PluginInstallError as e:
108
116
  typer.echo(f"✘ Install failed: {e}")
109
117
  raise typer.Exit(2) from e
@@ -150,9 +158,7 @@ def cmd_info(plugin_id: str = typer.Argument(...)) -> None:
150
158
  try:
151
159
  try:
152
160
  rec = mgr.info(plugin_id)
153
- typer.echo(
154
- yaml.safe_dump(rec.metadata.model_dump(mode="json"), allow_unicode=True)
155
- )
161
+ typer.echo(yaml.safe_dump(rec.metadata.model_dump(mode="json"), allow_unicode=True))
156
162
  return
157
163
  except PluginNotFoundError:
158
164
  pass # fall through to registry lookup
@@ -237,12 +243,16 @@ def cmd_uninstall(
237
243
 
238
244
  @app.command("upgrade")
239
245
  def cmd_upgrade(
240
- source: str = typer.Argument(
241
- ..., help="短名(注册表)/ 本地路径 / GitHub URL"
242
- ),
246
+ source: str = typer.Argument(..., help="短名(注册表)/ 本地路径 / GitHub URL"),
243
247
  ref: str | None = typer.Option(
244
248
  None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
245
249
  ),
250
+ no_deps: bool = typer.Option(
251
+ False, "--no-deps", help="Skip plugin Python dependency installation"
252
+ ),
253
+ reinstall_deps: bool = typer.Option(
254
+ False, "--reinstall-deps", help="Re-run installer for all deps (uv/pip --upgrade)"
255
+ ),
246
256
  ) -> None:
247
257
  """Upgrade an installed plugin.
248
258
 
@@ -262,16 +272,18 @@ def cmd_upgrade(
262
272
  db, mgr = _open()
263
273
  try:
264
274
  try:
265
- result = mgr.upgrade(resolved.path)
275
+ result = mgr.upgrade(
276
+ resolved.path,
277
+ install_deps=not no_deps,
278
+ reinstall_deps=reinstall_deps,
279
+ )
266
280
  except PluginNotFoundError as e:
267
281
  try:
268
282
  meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
269
283
  pid = meta.plugin_id
270
284
  except PluginInstallError:
271
285
  pid = source
272
- typer.echo(
273
- f'✘ 插件 "{pid}" 未安装,请先执行 deeptrade plugin install'
274
- )
286
+ typer.echo(f'✘ 插件 "{pid}" 未安装,请先执行 deeptrade plugin install')
275
287
  raise typer.Exit(2) from e
276
288
  except PluginInstallError as e:
277
289
  typer.echo(f"✘ Upgrade failed: {e}")
@@ -293,9 +305,7 @@ def cmd_search(
293
305
  keyword: str | None = typer.Argument(
294
306
  None, help="可选过滤关键词(匹配 plugin_id / name / description)"
295
307
  ),
296
- no_cache: bool = typer.Option(
297
- False, "--no-cache", help="强制刷新注册表(旁路 ETag 缓存)"
298
- ),
308
+ no_cache: bool = typer.Option(False, "--no-cache", help="强制刷新注册表(旁路 ETag 缓存)"),
299
309
  ) -> None:
300
310
  """List plugins available in the official registry."""
301
311
  try:
@@ -87,6 +87,11 @@ class AppConfig(BaseModel):
87
87
  # tushare.* (token lives in secret_store)
88
88
  tushare_rps: float = Field(default=6.0, gt=0)
89
89
  tushare_timeout: int = Field(default=30, ge=1)
90
+ # Tenacity stop_after_attempt for transient errors (rate limit / server /
91
+ # transport). Default 7 keeps worst-case wait around one minute of
92
+ # jittered exponential backoff. Each attempt re-enters the token bucket,
93
+ # so retries never bypass rate limiting.
94
+ tushare_max_retries: int = Field(default=7, ge=1, le=20)
90
95
 
91
96
  # Global preset name. v0.7 — renamed from ``deepseek.profile``; semantics
92
97
  # are vendor-agnostic. Per-stage tuning is resolved by each plugin's
@@ -134,6 +139,7 @@ _DOT_TO_FIELD: dict[str, str] = {
134
139
  "app.close_after": "app_close_after",
135
140
  "tushare.rps": "tushare_rps",
136
141
  "tushare.timeout": "tushare_timeout",
142
+ "tushare.max_retries": "tushare_max_retries",
137
143
  "app.profile": "app_profile",
138
144
  "llm.providers": "llm_providers",
139
145
  "llm.audit_full_payload": "llm_audit_full_payload",
@@ -365,8 +371,7 @@ class ConfigService:
365
371
  # already default; otherwise we'd leave the dict with no
366
372
  # default at all. Preserve prior_default in that case.
367
373
  other_has_default = any(
368
- k != name and bool((v or {}).get("is_default"))
369
- for k, v in current.items()
374
+ k != name and bool((v or {}).get("is_default")) for k, v in current.items()
370
375
  )
371
376
  new_default = prior_default if not other_has_default else False
372
377
 
@@ -374,7 +379,11 @@ class ConfigService:
374
379
  # the invariant "at most one default" holds.
375
380
  if new_default:
376
381
  for other_name, other_cfg in current.items():
377
- if other_name != name and isinstance(other_cfg, dict) and other_cfg.get("is_default"):
382
+ if (
383
+ other_name != name
384
+ and isinstance(other_cfg, dict)
385
+ and other_cfg.get("is_default")
386
+ ):
378
387
  current[other_name] = {**other_cfg, "is_default": False}
379
388
 
380
389
  current[name] = {
@@ -0,0 +1,225 @@
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: ``docs/plugin_dependency_management_design.md``.
8
+
9
+ Key invariants:
10
+ * Plugins share the framework's Python interpreter — deps must be
11
+ importable from ``sys.executable``'s site-packages.
12
+ * Installer preference: ``uv`` (with ``--python <sys.executable>``) →
13
+ ``python -m pip``. Both missing → :class:`DepInstallError`.
14
+ * Conflicts (installed version not satisfying spec) are detected at
15
+ install time and raised, not silently overridden.
16
+ * Failed installs leave any already-installed deps in the environment
17
+ (do not unwind — common deps would be wrongly removed).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ from collections.abc import Callable, Iterable
28
+ from dataclasses import dataclass, field
29
+ from importlib import metadata as importlib_metadata
30
+
31
+ from packaging.requirements import InvalidRequirement, Requirement
32
+ from packaging.utils import canonicalize_name
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ DEFAULT_TIMEOUT_SECONDS = 300
37
+
38
+
39
+ class DepInstallError(Exception):
40
+ """Dependency resolution, conflict, or install-subprocess failure."""
41
+
42
+
43
+ @dataclass
44
+ class DepConflict:
45
+ requirement: Requirement
46
+ installed_version: str
47
+ owner: str # human-readable attribution string
48
+
49
+ def __str__(self) -> str:
50
+ return (
51
+ f"{self.requirement} not satisfied by installed "
52
+ f"{self.requirement.name}=={self.installed_version} (owner: {self.owner})"
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class DepPlan:
58
+ to_install: list[Requirement] = field(default_factory=list)
59
+ skipped: list[tuple[Requirement, str]] = field(default_factory=list)
60
+ conflicts: list[DepConflict] = field(default_factory=list)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Parsing
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def parse_specs(specs: Iterable[str]) -> list[Requirement]:
69
+ """Parse PEP 508 specifier strings into ``Requirement`` objects.
70
+
71
+ Rejects VCS/URL forms and duplicate package names. Same validation
72
+ as :meth:`PluginMetadata._dependencies_valid` — kept here so callers
73
+ that pre-validated metadata can reuse the parsed objects, and so
74
+ runtime code (not just Pydantic) reads idiomatically.
75
+ """
76
+ out: list[Requirement] = []
77
+ seen: dict[str, str] = {}
78
+ for raw in specs:
79
+ try:
80
+ req = Requirement(raw)
81
+ except InvalidRequirement as e:
82
+ raise DepInstallError(f"invalid dependency spec {raw!r}: {e}") from e
83
+ if req.url:
84
+ raise DepInstallError(
85
+ f"dependency {raw!r}: VCS/URL forms not allowed; use PEP 508 specifiers"
86
+ )
87
+ canonical = canonicalize_name(req.name)
88
+ if canonical in seen:
89
+ raise DepInstallError(
90
+ f"duplicate dependency {req.name!r} (also {seen[canonical]!r}); combine into one spec"
91
+ )
92
+ seen[canonical] = raw
93
+ out.append(req)
94
+ return out
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Planning (satisfied / conflict / to-install)
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def plan_install(
103
+ requirements: list[Requirement],
104
+ *,
105
+ attribute_conflict: Callable[[str], str | None] | None = None,
106
+ ) -> DepPlan:
107
+ """Sort each requirement into ``skipped`` / ``to_install`` / ``conflicts``.
108
+
109
+ ``attribute_conflict(canonical_name)`` returns a human-readable owner
110
+ (e.g. ``"framework core dependency"``, ``"plugin foo"``) for the package
111
+ when a conflict is detected. Falls back to ``"external (already in
112
+ environment)"`` if ``None`` or returns ``None``.
113
+
114
+ Marker-gated requirements (``"x>=1; python_version < '3.10'"``) whose
115
+ marker evaluates to False in the current environment are dropped.
116
+ """
117
+ plan = DepPlan()
118
+ for req in requirements:
119
+ if req.marker is not None and not req.marker.evaluate():
120
+ logger.debug("Skipping %s — marker did not match current env", req)
121
+ continue
122
+ try:
123
+ installed = importlib_metadata.version(req.name)
124
+ except importlib_metadata.PackageNotFoundError:
125
+ plan.to_install.append(req)
126
+ continue
127
+ if not req.specifier or req.specifier.contains(installed, prereleases=True):
128
+ plan.skipped.append((req, installed))
129
+ continue
130
+ owner = (
131
+ attribute_conflict(canonicalize_name(req.name)) if attribute_conflict else None
132
+ ) or "external (already in environment)"
133
+ plan.conflicts.append(DepConflict(req, installed, owner))
134
+ return plan
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Installer detection + subprocess invocation
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def detect_installer() -> tuple[str, list[str]]:
143
+ """Return ``(label, argv_prefix)`` for the chosen installer.
144
+
145
+ Prefers ``uv`` (with ``--python <sys.executable>`` so packages land in
146
+ the framework's interpreter). Falls back to ``python -m pip``. Raises
147
+ :class:`DepInstallError` if neither is available.
148
+ """
149
+ uv_path = shutil.which("uv")
150
+ if uv_path:
151
+ return ("uv", [uv_path, "pip", "install", "--python", sys.executable])
152
+ try:
153
+ pip_check = subprocess.run( # noqa: S603 — args fully controlled
154
+ [sys.executable, "-m", "pip", "--version"],
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=15,
158
+ )
159
+ except (subprocess.TimeoutExpired, FileNotFoundError):
160
+ pip_check = None
161
+ if pip_check is not None and pip_check.returncode == 0:
162
+ return ("pip", [sys.executable, "-m", "pip", "install"])
163
+ raise DepInstallError(
164
+ "no installer available: neither 'uv' (on PATH) nor 'pip' (python -m pip) usable"
165
+ )
166
+
167
+
168
+ def run_install(
169
+ requirements: list[Requirement],
170
+ *,
171
+ reinstall: bool = False,
172
+ timeout_seconds: int | None = None,
173
+ ) -> None:
174
+ """Spawn the installer subprocess. Raises :class:`DepInstallError` on
175
+ timeout, missing binary, or non-zero exit. stdout/stderr are inherited
176
+ so users see pip/uv progress in real time."""
177
+ if not requirements:
178
+ return
179
+ label, argv = detect_installer()
180
+ if reinstall:
181
+ argv.append("--upgrade")
182
+ argv.extend(str(r) for r in requirements)
183
+ timeout = timeout_seconds if timeout_seconds is not None else _resolved_timeout()
184
+ logger.info(
185
+ "Installing %d plugin dep(s) via %s: %s",
186
+ len(requirements),
187
+ label,
188
+ [str(r) for r in requirements],
189
+ )
190
+ try:
191
+ result = subprocess.run( # noqa: S603 — args fully controlled
192
+ argv,
193
+ timeout=timeout,
194
+ text=True,
195
+ )
196
+ except subprocess.TimeoutExpired as e:
197
+ raise DepInstallError(
198
+ f"dependency install timed out after {timeout}s ({label}): "
199
+ f"{[str(r) for r in requirements]}"
200
+ ) from e
201
+ except FileNotFoundError as e:
202
+ raise DepInstallError(f"installer disappeared mid-run: {e}") from e
203
+ if result.returncode != 0:
204
+ raise DepInstallError(
205
+ f"{label} install failed (exit {result.returncode}) for: "
206
+ f"{[str(r) for r in requirements]}"
207
+ )
208
+
209
+
210
+ def _resolved_timeout() -> int:
211
+ raw = os.environ.get("DEEPTRADE_DEP_INSTALL_TIMEOUT")
212
+ if raw is None:
213
+ return DEFAULT_TIMEOUT_SECONDS
214
+ try:
215
+ v = int(raw)
216
+ if v <= 0:
217
+ raise ValueError("must be positive")
218
+ return v
219
+ except ValueError:
220
+ logger.warning(
221
+ "Invalid DEEPTRADE_DEP_INSTALL_TIMEOUT=%r; using default %ds",
222
+ raw,
223
+ DEFAULT_TIMEOUT_SECONDS,
224
+ )
225
+ return DEFAULT_TIMEOUT_SECONDS
@@ -95,20 +95,14 @@ def latest_release_tag(repo: str, tag_prefix: str = "", *, timeout: float = 15.0
95
95
  payload = resp.read()
96
96
  link_header = resp.headers.get("Link")
97
97
  except HTTPError as e:
98
- raise GitHubFetchError(
99
- f"HTTP {e.code} listing releases for {repo}: {e}"
100
- ) from e
98
+ raise GitHubFetchError(f"HTTP {e.code} listing releases for {repo}: {e}") from e
101
99
  except URLError as e:
102
- raise GitHubFetchError(
103
- f"network error listing releases for {repo}: {e}"
104
- ) from e
100
+ raise GitHubFetchError(f"network error listing releases for {repo}: {e}") from e
105
101
 
106
102
  try:
107
103
  data = json.loads(payload.decode("utf-8"))
108
104
  except (UnicodeDecodeError, json.JSONDecodeError) as e:
109
- raise GitHubFetchError(
110
- f"invalid JSON in releases response for {repo}: {e}"
111
- ) from e
105
+ raise GitHubFetchError(f"invalid JSON in releases response for {repo}: {e}") from e
112
106
 
113
107
  if not isinstance(data, list):
114
108
  raise GitHubFetchError(
@@ -125,7 +119,7 @@ def latest_release_tag(repo: str, tag_prefix: str = "", *, timeout: float = 15.0
125
119
  continue
126
120
  if tag_prefix and not tag.startswith(tag_prefix):
127
121
  continue
128
- ver_str = tag[len(tag_prefix):] if tag_prefix else tag
122
+ ver_str = tag[len(tag_prefix) :] if tag_prefix else tag
129
123
  ver_str = ver_str.lstrip("v")
130
124
  try:
131
125
  candidates.append((Version(ver_str), tag))
@@ -142,9 +136,7 @@ def latest_release_tag(repo: str, tag_prefix: str = "", *, timeout: float = 15.0
142
136
  return candidates[0][1]
143
137
 
144
138
 
145
- def fetch_tarball(
146
- repo: str, ref: str, dest_dir: Path, *, timeout: float = 60.0
147
- ) -> Path:
139
+ def fetch_tarball(repo: str, ref: str, dest_dir: Path, *, timeout: float = 60.0) -> Path:
148
140
  """Download ``repo`` at ``ref`` from the GitHub tarball API and extract.
149
141
 
150
142
  Returns the unique top-level directory created inside ``dest_dir``
@@ -157,9 +149,7 @@ def fetch_tarball(
157
149
 
158
150
  tmp_path: Path | None = None
159
151
  try:
160
- with tempfile.NamedTemporaryFile(
161
- suffix=".tar.gz", delete=False
162
- ) as tmp:
152
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
163
153
  tmp_path = Path(tmp.name)
164
154
 
165
155
  req = _build_request(url, accept="application/vnd.github+json")
@@ -167,13 +157,9 @@ def fetch_tarball(
167
157
  with urlopen(req, timeout=timeout) as resp, tmp_path.open("wb") as fout:
168
158
  shutil.copyfileobj(resp, fout)
169
159
  except HTTPError as e:
170
- raise TarballFetchError(
171
- f"HTTP {e.code} downloading tarball {repo}@{ref}: {e}"
172
- ) from e
160
+ raise TarballFetchError(f"HTTP {e.code} downloading tarball {repo}@{ref}: {e}") from e
173
161
  except URLError as e:
174
- raise TarballFetchError(
175
- f"network error downloading tarball {repo}@{ref}: {e}"
176
- ) from e
162
+ raise TarballFetchError(f"network error downloading tarball {repo}@{ref}: {e}") from e
177
163
 
178
164
  try:
179
165
  with tarfile.open(tmp_path, mode="r:gz") as tf:
@@ -208,9 +194,7 @@ def _safe_extract(tf: tarfile.TarFile, dest: Path) -> None:
208
194
  try:
209
195
  member_path.relative_to(dest_resolved)
210
196
  except ValueError as e:
211
- raise tarfile.TarError(
212
- f"unsafe path in tarball (would escape dest): {m.name!r}"
213
- ) from e
197
+ raise tarfile.TarError(f"unsafe path in tarball (would escape dest): {m.name!r}") from e
214
198
 
215
199
  try:
216
200
  tf.extractall(dest, filter="data")
@@ -111,8 +111,7 @@ class LLMManager:
111
111
  provider = cfg.llm_providers.get(name)
112
112
  if provider is None:
113
113
  raise LLMNotConfiguredError(
114
- f"LLM provider {name!r} is not configured; "
115
- "run `deeptrade config set-llm` to add it"
114
+ f"LLM provider {name!r} is not configured; run `deeptrade config set-llm` to add it"
116
115
  )
117
116
  return LLMProviderInfo(name=name, model=provider.model, base_url=provider.base_url)
118
117
 
@@ -160,8 +159,7 @@ class LLMManager:
160
159
  provider = cfg.llm_providers.get(name)
161
160
  if provider is None:
162
161
  raise LLMNotConfiguredError(
163
- f"LLM provider {name!r} is not configured; "
164
- "run `deeptrade config set-llm` to add it"
162
+ f"LLM provider {name!r} is not configured; run `deeptrade config set-llm` to add it"
165
163
  )
166
164
  api_key = self._config.get(f"llm.{name}.api_key")
167
165
  if not api_key: