deeptrade-quant 0.3.1__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.1 → deeptrade_quant-0.4.0}/PKG-INFO +6 -3
  2. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/__init__.py +1 -1
  3. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_plugin.py +24 -2
  4. deeptrade_quant-0.4.0/deeptrade/core/dep_installer.py +225 -0
  5. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_manager.py +151 -13
  6. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/metadata.py +31 -0
  7. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/pyproject.toml +13 -3
  8. deeptrade_quant-0.4.0/tests/core/test_plugin_dependencies.py +503 -0
  9. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/.gitignore +0 -0
  10. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/LICENSE +0 -0
  11. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/README.md +0 -0
  12. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli.py +0 -0
  13. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_config.py +0 -0
  14. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_data.py +0 -0
  15. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/__init__.py +0 -0
  16. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/config.py +0 -0
  17. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/config_migrations.py +0 -0
  18. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/db.py +0 -0
  19. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/github_fetch.py +0 -0
  20. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/llm_client.py +0 -0
  21. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/llm_manager.py +0 -0
  22. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/logging_config.py +0 -0
  23. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/__init__.py +0 -0
  24. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  25. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  26. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/paths.py +0 -0
  27. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_source.py +0 -0
  28. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/registry.py +0 -0
  29. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/run_status.py +0 -0
  30. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/secrets.py +0 -0
  31. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/tushare_client.py +0 -0
  32. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/__init__.py +0 -0
  33. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/base.py +0 -0
  34. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/events.py +0 -0
  35. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/llm.py +0 -0
  36. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/theme.py +0 -0
  37. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/__init__.py +0 -0
  38. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/__init__.py +0 -0
  39. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_config_cmd.py +0 -0
  40. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_plugin_cmd.py +0 -0
  41. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_routing.py +0 -0
  42. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/conftest.py +0 -0
  43. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/__init__.py +0 -0
  44. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_config.py +0 -0
  45. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_config_migrations.py +0 -0
  46. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_db.py +0 -0
  47. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_github_fetch.py +0 -0
  48. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_llm_client.py +0 -0
  49. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_llm_manager.py +0 -0
  50. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_paths.py +0 -0
  51. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_install.py +0 -0
  52. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_source.py +0 -0
  53. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_upgrade.py +0 -0
  54. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_registry.py +0 -0
  55. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_secrets.py +0 -0
  56. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_classifier.py +0 -0
  57. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_client.py +0 -0
  58. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_retry_r1.py +0 -0
  59. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/plugins_api/__init__.py +0 -0
  60. {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/plugins_api/test_protocol.py +0 -0
  61. {deeptrade_quant-0.3.1 → 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.1
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.1"
5
+ __version__ = "0.4.0"
6
6
  __all__ = ["__version__"]
@@ -71,6 +71,12 @@ def cmd_install(
71
71
  None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
72
72
  ),
73
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
+ ),
74
80
  ) -> None:
75
81
  """Install a plugin from the registry, a GitHub URL, or a local directory."""
76
82
  resolver = SourceResolver()
@@ -90,6 +96,8 @@ def cmd_install(
90
96
  typer.echo("─── 即将安装 ─────────────────────────────")
91
97
  typer.echo(f"来源: {_format_origin(resolved)}")
92
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)")
93
101
  typer.echo("──────────────────────────────────────────")
94
102
  if not yes:
95
103
  ok = questionary.confirm("确认安装?", default=False).ask()
@@ -99,7 +107,11 @@ def cmd_install(
99
107
 
100
108
  db, mgr = _open()
101
109
  try:
102
- rec = mgr.install(resolved.path)
110
+ rec = mgr.install(
111
+ resolved.path,
112
+ install_deps=not no_deps,
113
+ reinstall_deps=reinstall_deps,
114
+ )
103
115
  except PluginInstallError as e:
104
116
  typer.echo(f"✘ Install failed: {e}")
105
117
  raise typer.Exit(2) from e
@@ -235,6 +247,12 @@ def cmd_upgrade(
235
247
  ref: str | None = typer.Option(
236
248
  None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
237
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
+ ),
238
256
  ) -> None:
239
257
  """Upgrade an installed plugin.
240
258
 
@@ -254,7 +272,11 @@ def cmd_upgrade(
254
272
  db, mgr = _open()
255
273
  try:
256
274
  try:
257
- result = mgr.upgrade(resolved.path)
275
+ result = mgr.upgrade(
276
+ resolved.path,
277
+ install_deps=not no_deps,
278
+ reinstall_deps=reinstall_deps,
279
+ )
258
280
  except PluginNotFoundError as e:
259
281
  try:
260
282
  meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
@@ -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
@@ -14,14 +14,23 @@ import sys
14
14
  import textwrap
15
15
  from collections.abc import Sequence
16
16
  from dataclasses import dataclass
17
+ from importlib import metadata as importlib_metadata
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
20
21
  import yaml
22
+ from packaging.requirements import InvalidRequirement, Requirement
23
+ from packaging.utils import canonicalize_name
21
24
  from packaging.version import InvalidVersion, Version
22
25
 
23
26
  from deeptrade.core import paths
24
27
  from deeptrade.core.db import Database
28
+ from deeptrade.core.dep_installer import (
29
+ DepInstallError,
30
+ parse_specs,
31
+ plan_install,
32
+ run_install,
33
+ )
25
34
  from deeptrade.plugins_api.base import Plugin
26
35
  from deeptrade.plugins_api.metadata import MigrationSpec, PluginMetadata
27
36
 
@@ -190,8 +199,15 @@ class PluginManager:
190
199
 
191
200
  # --- install -----------------------------------------------------
192
201
 
193
- def install(self, source_path: Path) -> InstalledPlugin:
194
- """Install a plugin from a local directory. Network never touched."""
202
+ def install(
203
+ self,
204
+ source_path: Path,
205
+ *,
206
+ install_deps: bool = True,
207
+ reinstall_deps: bool = False,
208
+ ) -> InstalledPlugin:
209
+ """Install a plugin from a local directory. Network never touched
210
+ by plugin code; framework may run pip/uv if ``install_deps=True``."""
195
211
  source_path = source_path.resolve()
196
212
  if not source_path.is_dir():
197
213
  raise PluginInstallError(f"source path is not a directory: {source_path}")
@@ -233,6 +249,18 @@ class PluginManager:
233
249
  shutil.rmtree(target)
234
250
  shutil.copytree(source_path, target)
235
251
 
252
+ # Resolve & install Python deps BEFORE migrations + entrypoint load.
253
+ # validate_static imports the plugin module — deps must be importable
254
+ # by then. On failure, just remove the copied dir; already-installed
255
+ # deps stay in the env (see design §4.5).
256
+ if install_deps:
257
+ try:
258
+ self._handle_dependencies(meta, reinstall=reinstall_deps)
259
+ except DepInstallError as e:
260
+ if target.exists():
261
+ shutil.rmtree(target, ignore_errors=True)
262
+ raise PluginInstallError(str(e)) from e
263
+
236
264
  # Apply migrations + write registries inside ONE transaction.
237
265
  try:
238
266
  with self._db.transaction():
@@ -354,7 +382,13 @@ class PluginManager:
354
382
 
355
383
  return {"purged_tables": dropped, "purge": purge}
356
384
 
357
- def upgrade(self, source_path: Path) -> InstalledPlugin | UpgradeNoop:
385
+ def upgrade(
386
+ self,
387
+ source_path: Path,
388
+ *,
389
+ install_deps: bool = True,
390
+ reinstall_deps: bool = False,
391
+ ) -> InstalledPlugin | UpgradeNoop:
358
392
  """Upgrade an existing plugin: apply only NEW migrations (S5).
359
393
 
360
394
  Version semantics (see distribution-and-plugin-install-design.md §7):
@@ -411,6 +445,17 @@ class PluginManager:
411
445
  shutil.rmtree(target)
412
446
  shutil.copytree(source_path, target)
413
447
 
448
+ # Resolve & install plugin deps BEFORE migrations / load. Failure
449
+ # path mirrors install(): remove the copied dir, leave installed
450
+ # deps in place (design §4.5).
451
+ if install_deps:
452
+ try:
453
+ self._handle_dependencies(meta, reinstall=reinstall_deps)
454
+ except DepInstallError as e:
455
+ if target.exists():
456
+ shutil.rmtree(target, ignore_errors=True)
457
+ raise PluginInstallError(str(e)) from e
458
+
414
459
  # F-M5 — keep a backup of the previous install_path so we can roll back on failure
415
460
  prev_install_path = Path(existing.install_path)
416
461
  prev_metadata_yaml = self._db.fetchone(
@@ -489,6 +534,98 @@ class PluginManager:
489
534
 
490
535
  return self._compose_record(meta, target, enabled=existing.enabled)
491
536
 
537
+ # --- dep handling ------------------------------------------------
538
+
539
+ def _handle_dependencies(self, meta: PluginMetadata, *, reinstall: bool) -> None:
540
+ """Resolve declared deps, fail loudly on conflict, run installer.
541
+
542
+ Pre-conditions: ``meta.dependencies`` already passed Pydantic
543
+ validation (PEP 508, no URL/VCS, unique names).
544
+ """
545
+ if not meta.dependencies:
546
+ return
547
+
548
+ specs = parse_specs(meta.dependencies)
549
+ ownership = self._build_dep_ownership(exclude_plugin_id=meta.plugin_id)
550
+
551
+ def attribute(canonical: str) -> str | None:
552
+ return ownership.get(canonical)
553
+
554
+ plan = plan_install(specs, attribute_conflict=attribute)
555
+
556
+ if plan.conflicts:
557
+ lines = [f" - {c}" for c in plan.conflicts]
558
+ raise DepInstallError(
559
+ "plugin dependency conflicts:\n"
560
+ + "\n".join(lines)
561
+ + "\nResolve by uninstalling the conflicting plugin or "
562
+ + "adjusting this plugin's specifier."
563
+ )
564
+
565
+ targets = specs if reinstall else plan.to_install
566
+ if plan.skipped and not reinstall:
567
+ logger.info(
568
+ "plugin %s: %d dep(s) already satisfied: %s",
569
+ meta.plugin_id,
570
+ len(plan.skipped),
571
+ [f"{r.name}=={v}" for r, v in plan.skipped],
572
+ )
573
+ run_install(targets, reinstall=reinstall)
574
+
575
+ def _build_dep_ownership(self, *, exclude_plugin_id: str) -> dict[str, str]:
576
+ """Map canonical package name → human-readable owner attribution.
577
+
578
+ Sources, in priority order (first wins):
579
+ 1. Framework's own declared deps (``deeptrade-quant`` distribution).
580
+ 2. Already-installed plugins' declared ``dependencies``
581
+ (looked up from ``plugins.metadata_yaml``).
582
+
583
+ Used only for conflict messages; never gates install decisions.
584
+ """
585
+ out: dict[str, str] = {}
586
+
587
+ # Framework deps
588
+ try:
589
+ dist = importlib_metadata.distribution("deeptrade-quant")
590
+ except importlib_metadata.PackageNotFoundError:
591
+ dist = None
592
+ if dist is not None:
593
+ for raw in dist.requires or []:
594
+ try:
595
+ req = Requirement(raw)
596
+ except InvalidRequirement:
597
+ continue
598
+ # Skip extras-only requirements (e.g. dev extras)
599
+ if req.marker is not None:
600
+ try:
601
+ if not req.marker.evaluate({"extra": ""}):
602
+ continue
603
+ except Exception: # noqa: BLE001 — marker eval edge cases
604
+ continue
605
+ out.setdefault(canonicalize_name(req.name), "framework core dependency")
606
+
607
+ # Other plugins
608
+ try:
609
+ rows = self._db.fetchall(
610
+ "SELECT plugin_id, metadata_yaml FROM plugins WHERE plugin_id != ?",
611
+ (exclude_plugin_id,),
612
+ )
613
+ except Exception: # noqa: BLE001 — DB may be in transitional state
614
+ rows = []
615
+ for pid, meta_yaml in rows:
616
+ try:
617
+ meta_dict = yaml.safe_load(meta_yaml) or {}
618
+ except yaml.YAMLError:
619
+ continue
620
+ for raw in meta_dict.get("dependencies", []) or []:
621
+ try:
622
+ req = Requirement(raw)
623
+ except InvalidRequirement:
624
+ continue
625
+ out.setdefault(canonicalize_name(req.name), f"plugin {pid}")
626
+
627
+ return out
628
+
492
629
  # --- internal helpers --------------------------------------------
493
630
 
494
631
  def _apply_migrations(
@@ -631,16 +768,17 @@ def summarize_for_install(meta: PluginMetadata, source_path: Path) -> str:
631
768
  """Render the install confirmation pre-flight summary (CLI only)."""
632
769
  lines = textwrap.dedent(
633
770
  f"""
634
- plugin_id : {meta.plugin_id}
635
- name : {meta.name}
636
- version : {meta.version}
637
- type : {meta.type}
638
- entrypoint : {meta.entrypoint}
639
- source : {source_path}
640
- required : {", ".join(meta.permissions.tushare_apis.required) or "(none)"}
641
- optional : {", ".join(meta.permissions.tushare_apis.optional) or "(none)"}
642
- migrations : {", ".join(m.version for m in meta.migrations)}
643
- tables ({len(meta.tables)}): {", ".join(t.name for t in meta.tables)}
771
+ plugin_id : {meta.plugin_id}
772
+ name : {meta.name}
773
+ version : {meta.version}
774
+ type : {meta.type}
775
+ entrypoint : {meta.entrypoint}
776
+ source : {source_path}
777
+ required : {", ".join(meta.permissions.tushare_apis.required) or "(none)"}
778
+ optional : {", ".join(meta.permissions.tushare_apis.optional) or "(none)"}
779
+ migrations : {", ".join(m.version for m in meta.migrations)}
780
+ tables ({len(meta.tables)}) : {", ".join(t.name for t in meta.tables)}
781
+ dependencies : {", ".join(meta.dependencies) or "(none)"}
644
782
  """
645
783
  ).strip()
646
784
  return lines
@@ -4,12 +4,18 @@ DESIGN §8.2 (v0.3.1):
4
4
  * `tables` declares table names + purge policy ONLY (no inline DDL).
5
5
  * `migrations` is the SOLE DDL execution path (S1 fix).
6
6
  * `permissions.llm_tools` is fixed False (M3 hard constraint).
7
+ DESIGN v0.4 (plugin dependency management):
8
+ * `dependencies` declares third-party Python packages the plugin needs.
9
+ PEP 508 specifier strings; framework installs them at plugin install /
10
+ upgrade time. See ``docs/plugin_dependency_management_design.md``.
7
11
  """
8
12
 
9
13
  from __future__ import annotations
10
14
 
11
15
  from typing import Literal
12
16
 
17
+ from packaging.requirements import InvalidRequirement, Requirement
18
+ from packaging.utils import canonicalize_name
13
19
  from pydantic import BaseModel, ConfigDict, Field, model_validator
14
20
 
15
21
 
@@ -61,6 +67,31 @@ class PluginMetadata(BaseModel):
61
67
  permissions: PluginPermissions = Field(default_factory=PluginPermissions)
62
68
  tables: list[TableSpec]
63
69
  migrations: list[MigrationSpec]
70
+ dependencies: list[str] = Field(default_factory=list)
71
+
72
+ @model_validator(mode="after")
73
+ def _dependencies_valid(self) -> PluginMetadata:
74
+ """Each entry must parse as PEP 508 ``Requirement``; no VCS/URL forms;
75
+ no duplicate package names (canonicalized)."""
76
+ seen: dict[str, str] = {}
77
+ for raw in self.dependencies:
78
+ try:
79
+ req = Requirement(raw)
80
+ except InvalidRequirement as e:
81
+ raise ValueError(f"invalid dependency spec {raw!r}: {e}") from e
82
+ if req.url:
83
+ raise ValueError(
84
+ f"dependency {raw!r}: VCS/URL forms (git+https://, package @ url) "
85
+ f"are not allowed; use PEP 508 version specifiers only"
86
+ )
87
+ canonical = canonicalize_name(req.name)
88
+ if canonical in seen:
89
+ raise ValueError(
90
+ f"duplicate dependency package {req.name!r} "
91
+ f"(also declared as {seen[canonical]!r}); combine into a single spec"
92
+ )
93
+ seen[canonical] = raw
94
+ return self
64
95
 
65
96
  @model_validator(mode="after")
66
97
  def _migrations_not_empty(self) -> PluginMetadata:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "deeptrade-quant"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -17,18 +17,25 @@ dependencies = [
17
17
  "questionary>=2.0",
18
18
  "rich>=13.7",
19
19
  "duckdb>=1.0",
20
- "tushare>=1.4",
21
20
  "openai>=1.0",
22
21
  "pydantic>=2.7",
23
22
  "tenacity>=8.0",
24
23
  "pyyaml>=6.0",
25
24
  "keyring>=25.0",
26
- "pandas>=2.2",
27
25
  "click>=8.1",
28
26
  "packaging>=23.0",
29
27
  ]
30
28
 
31
29
  [project.optional-dependencies]
30
+ # Deps used ONLY by `deeptrade.core.tushare_client` (a plugin-facing service
31
+ # helper not imported by any framework runtime code path). Plugin authors that
32
+ # import TushareClient must declare these in their plugin's
33
+ # `deeptrade_plugin.yaml::dependencies`. Kept here so `uv sync --all-extras`
34
+ # still installs them for local tests of tushare_client.
35
+ plugin-runtime = [
36
+ "tushare>=1.4",
37
+ "pandas>=2.2",
38
+ ]
32
39
  dev = [
33
40
  "pytest>=8.0",
34
41
  "pytest-mock>=3.12",
@@ -36,6 +43,9 @@ dev = [
36
43
  "mypy>=1.10",
37
44
  "pre-commit>=3.7",
38
45
  "types-PyYAML>=6.0",
46
+ # Pulled in transitively so the tushare_client test files load.
47
+ "tushare>=1.4",
48
+ "pandas>=2.2",
39
49
  ]
40
50
 
41
51
  [project.scripts]