deeptrade-quant 0.3.1__tar.gz → 0.4.1__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.1}/PKG-INFO +6 -3
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/__init__.py +1 -1
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/cli_plugin.py +24 -2
- deeptrade_quant-0.4.1/deeptrade/core/dep_installer.py +225 -0
- deeptrade_quant-0.4.1/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +10 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/plugin_manager.py +151 -13
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/tushare_client.py +40 -3
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/plugins_api/metadata.py +31 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/pyproject.toml +13 -3
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_db.py +2 -2
- deeptrade_quant-0.4.1/tests/core/test_plugin_dependencies.py +503 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_tushare_client.py +34 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/.gitignore +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/LICENSE +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/README.md +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/cli.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/cli_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/cli_data.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/config_migrations.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/db.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/github_fetch.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/llm_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/llm_manager.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/logging_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/migrations/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/migrations/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/paths.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/plugin_source.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/registry.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/run_status.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/core/secrets.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/plugins_api/base.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/plugins_api/events.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/plugins_api/llm.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/deeptrade/theme.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/cli/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/cli/test_config_cmd.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/cli/test_plugin_cmd.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/cli/test_routing.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/conftest.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_config.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_config_migrations.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_github_fetch.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_llm_client.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_llm_manager.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_paths.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_plugin_install.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_plugin_source.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_plugin_upgrade.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_registry.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_secrets.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_tushare_classifier.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/core/test_tushare_retry_r1.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/tests/plugins_api/test_protocol.py +0 -0
- {deeptrade_quant-0.3.1 → deeptrade_quant-0.4.1}/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.1
|
|
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
|
deeptrade_quant-0.4.1/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- v0.4.1 — drop legacy tushare_cache_blob entries.
|
|
2
|
+
--
|
|
3
|
+
-- Pre-v0.4.1 payloads were stored as a bare JSON array, then read back via
|
|
4
|
+
-- pd.read_json which triggered a pandas FutureWarning on string columns whose
|
|
5
|
+
-- names matched its date heuristic (trade_date, cal_date, ...). The cache
|
|
6
|
+
-- read/write path now wraps payloads as {version:1, schema:{...}, data:[...]}
|
|
7
|
+
-- and restores dtypes explicitly. Old rows are incompatible with that reader,
|
|
8
|
+
-- so wipe the table; TushareClient._ensure_cache_table re-creates it lazily on
|
|
9
|
+
-- the next write.
|
|
10
|
+
DROP TABLE IF EXISTS tushare_cache_blob;
|
|
@@ -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
|
|
@@ -19,7 +19,6 @@ Cache class buckets:
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import hashlib
|
|
22
|
-
import io
|
|
23
22
|
import json
|
|
24
23
|
import logging
|
|
25
24
|
import threading
|
|
@@ -896,6 +895,15 @@ class TushareClient:
|
|
|
896
895
|
")"
|
|
897
896
|
)
|
|
898
897
|
|
|
898
|
+
# Payload wrapper format (v0.4.1+):
|
|
899
|
+
# {"version": 1, "schema": {col: dtype_str}, "data": [...records...]}
|
|
900
|
+
# Bypasses pd.read_json's date-column heuristic (which warns on string
|
|
901
|
+
# columns named like dates — trade_date / cal_date / ann_date) and
|
|
902
|
+
# restores dtypes explicitly from the recorded schema. Pre-v0.4.1 rows
|
|
903
|
+
# were a bare records array; those are wiped by core migration
|
|
904
|
+
# 20260512_001_drop_legacy_tushare_cache.sql, so no legacy branch here.
|
|
905
|
+
_CACHE_PAYLOAD_VERSION = 1
|
|
906
|
+
|
|
899
907
|
def _write_cached(
|
|
900
908
|
self,
|
|
901
909
|
api_name: str,
|
|
@@ -906,7 +914,14 @@ class TushareClient:
|
|
|
906
914
|
self._ensure_cache_table()
|
|
907
915
|
body = json.dumps(params, sort_keys=True, default=str)
|
|
908
916
|
h = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
909
|
-
|
|
917
|
+
records_json = df.to_json(orient="records", date_format="iso")
|
|
918
|
+
payload = json.dumps(
|
|
919
|
+
{
|
|
920
|
+
"version": self._CACHE_PAYLOAD_VERSION,
|
|
921
|
+
"schema": {col: str(dt) for col, dt in df.dtypes.items()},
|
|
922
|
+
"data": json.loads(records_json) if records_json else [],
|
|
923
|
+
}
|
|
924
|
+
)
|
|
910
925
|
with self._db.transaction():
|
|
911
926
|
self._db.execute(
|
|
912
927
|
"DELETE FROM tushare_cache_blob "
|
|
@@ -937,12 +952,34 @@ class TushareClient:
|
|
|
937
952
|
)
|
|
938
953
|
if row is None:
|
|
939
954
|
return pd.DataFrame()
|
|
940
|
-
|
|
955
|
+
wrapper = json.loads(row[0])
|
|
956
|
+
df = self._restore_cached_frame(wrapper)
|
|
941
957
|
if fields:
|
|
942
958
|
cols = [c.strip() for c in fields.split(",") if c.strip() in df.columns]
|
|
943
959
|
df = df[cols]
|
|
944
960
|
return df
|
|
945
961
|
|
|
962
|
+
@classmethod
|
|
963
|
+
def _restore_cached_frame(cls, wrapper: dict[str, Any]) -> pd.DataFrame:
|
|
964
|
+
schema: dict[str, str] = wrapper["schema"]
|
|
965
|
+
data: list[dict[str, Any]] = wrapper["data"]
|
|
966
|
+
df = pd.DataFrame.from_records(data, columns=list(schema.keys()))
|
|
967
|
+
for col, dtype_str in schema.items():
|
|
968
|
+
if col not in df.columns:
|
|
969
|
+
continue
|
|
970
|
+
if dtype_str.startswith("datetime"):
|
|
971
|
+
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
972
|
+
elif dtype_str == "object":
|
|
973
|
+
continue
|
|
974
|
+
else:
|
|
975
|
+
try:
|
|
976
|
+
df[col] = df[col].astype(dtype_str)
|
|
977
|
+
except (TypeError, ValueError):
|
|
978
|
+
# Best-effort: if a numeric/bool column can't be coerced
|
|
979
|
+
# back (e.g. all-null), leave the inferred dtype.
|
|
980
|
+
pass
|
|
981
|
+
return df
|
|
982
|
+
|
|
946
983
|
|
|
947
984
|
# ---------------------------------------------------------------------------
|
|
948
985
|
# Fallback predicate (DESIGN §13.2 + S4)
|
|
@@ -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:
|