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.
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/PKG-INFO +6 -3
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/__init__.py +1 -1
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_plugin.py +24 -2
- deeptrade_quant-0.4.0/deeptrade/core/dep_installer.py +225 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_manager.py +151 -13
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/metadata.py +31 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/pyproject.toml +13 -3
- deeptrade_quant-0.4.0/tests/core/test_plugin_dependencies.py +503 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/.gitignore +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/LICENSE +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/README.md +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/cli_data.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/config_migrations.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/db.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/github_fetch.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/llm_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/llm_manager.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/logging_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/migrations/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/paths.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/plugin_source.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/registry.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/run_status.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/secrets.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/core/tushare_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/base.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/events.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/plugins_api/llm.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/deeptrade/theme.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_config_cmd.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_plugin_cmd.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/cli/test_routing.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/conftest.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_config_migrations.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_db.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_github_fetch.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_llm_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_llm_manager.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_paths.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_install.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_source.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_plugin_upgrade.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_registry.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_secrets.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_classifier.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/core/test_tushare_retry_r1.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.0}/tests/plugins_api/test_protocol.py +0 -0
- {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
|
+
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
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
194
|
-
|
|
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(
|
|
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
|
|
635
|
-
name
|
|
636
|
-
version
|
|
637
|
-
type
|
|
638
|
-
entrypoint
|
|
639
|
-
source
|
|
640
|
-
required
|
|
641
|
-
optional
|
|
642
|
-
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.
|
|
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]
|