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