@drico2008/fincli 0.2.2 → 0.3.0
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.
- package/README.md +217 -909
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +7 -2
- package/fincli/app/analysis/analyzer.py +8 -4
- package/fincli/app/analysis/assistant_context.py +1 -1
- package/fincli/app/cli/commands.py +7 -2
- package/fincli/app/cli/router.py +363 -102
- package/fincli/app/modules/economic_calendar.py +1 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/plugins/loader.py +1 -1
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +2 -1
- package/fincli/app/research/engine.py +66 -4
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +10 -5
- package/fincli/app/research/models.py +6 -0
- package/fincli/app/research/prompt_builder.py +8 -1
- package/fincli/app/services/macro_data.py +1 -1
- package/fincli/app/services/market_data.py +141 -36
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +7 -2
- package/fincli/app/storage/database.py +12 -1
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +18 -0
- package/package.json +7 -5
- package/pyproject.toml +1 -1
|
@@ -2,32 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
-
from dataclasses import asdict
|
|
8
|
-
from datetime import datetime
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
from fincli.app.
|
|
13
|
-
from fincli.app.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
5
|
+
import asyncio
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
from typing import Any, Awaitable
|
|
11
|
+
|
|
12
|
+
from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
|
|
13
|
+
from fincli.app.providers.reliability import ProviderResult, classify_payload, classify_provider_error
|
|
14
|
+
from fincli.app.storage.market_cache import MarketCache
|
|
15
|
+
from fincli.app.utils.errors import ProviderError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MarketDataService:
|
|
17
19
|
"""Fetch market data through a prioritized provider chain."""
|
|
18
20
|
|
|
19
21
|
def __init__(
|
|
20
22
|
self,
|
|
21
|
-
providers: list[BaseMarketProvider],
|
|
22
|
-
cache: MarketCache | None = None,
|
|
23
|
-
cache_ttl_seconds: int = 300,
|
|
24
|
-
|
|
23
|
+
providers: list[BaseMarketProvider],
|
|
24
|
+
cache: MarketCache | None = None,
|
|
25
|
+
cache_ttl_seconds: int = 300,
|
|
26
|
+
metrics_store: Any | None = None,
|
|
27
|
+
) -> None:
|
|
25
28
|
if not providers:
|
|
26
29
|
raise ProviderError("MarketDataService membutuhkan minimal satu provider.")
|
|
27
30
|
self.providers = providers
|
|
28
|
-
self.cache = cache
|
|
29
|
-
self.cache_ttl_seconds = cache_ttl_seconds
|
|
30
|
-
self.
|
|
31
|
+
self.cache = cache
|
|
32
|
+
self.cache_ttl_seconds = cache_ttl_seconds
|
|
33
|
+
self.metrics_store = metrics_store
|
|
34
|
+
self.last_errors: list[str] = []
|
|
35
|
+
self.provider_results: list[ProviderResult] = []
|
|
36
|
+
self.provider_metrics: dict[str, ProviderRuntimeMetrics] = {
|
|
37
|
+
getattr(provider, "name", "unknown"): ProviderRuntimeMetrics(getattr(provider, "name", "unknown"))
|
|
38
|
+
for provider in providers
|
|
39
|
+
}
|
|
31
40
|
|
|
32
41
|
@property
|
|
33
42
|
def primary_provider(self) -> BaseMarketProvider:
|
|
@@ -82,19 +91,72 @@ class MarketDataService:
|
|
|
82
91
|
message=str(exc),
|
|
83
92
|
)
|
|
84
93
|
|
|
85
|
-
async def _with_fallback(self, method_name: str, *args: object) -> Any:
|
|
86
|
-
errors: list[str] = []
|
|
87
|
-
for provider in self.providers:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
async def _with_fallback(self, method_name: str, *args: object) -> Any:
|
|
95
|
+
errors: list[str] = []
|
|
96
|
+
for provider in self.providers:
|
|
97
|
+
provider_name = getattr(provider, "name", "unknown")
|
|
98
|
+
started = perf_counter()
|
|
99
|
+
try:
|
|
100
|
+
method = getattr(provider, method_name)
|
|
101
|
+
payload = await method(*args)
|
|
102
|
+
latency_ms = (perf_counter() - started) * 1000
|
|
103
|
+
status, missing = classify_payload(method_name, payload)
|
|
104
|
+
self._record_provider_metric(provider_name, success=status != "partial_data", latency_ms=latency_ms)
|
|
105
|
+
self._record_provider_result(
|
|
106
|
+
provider=provider_name,
|
|
107
|
+
operation=method_name,
|
|
108
|
+
status=status,
|
|
109
|
+
missing_fields=missing,
|
|
110
|
+
message="ok" if not missing else f"partial payload: {', '.join(missing)}",
|
|
111
|
+
)
|
|
112
|
+
return payload
|
|
113
|
+
except Exception as exc: # noqa: BLE001
|
|
114
|
+
latency_ms = (perf_counter() - started) * 1000
|
|
115
|
+
errors.append(f"{provider_name}: {exc}")
|
|
116
|
+
self._record_provider_metric(provider_name, success=False, latency_ms=latency_ms, fallback=True)
|
|
117
|
+
self._record_provider_result(
|
|
118
|
+
provider=provider_name,
|
|
119
|
+
operation=method_name,
|
|
120
|
+
status=classify_provider_error(exc),
|
|
121
|
+
message=str(exc),
|
|
122
|
+
)
|
|
123
|
+
self.last_errors = errors
|
|
124
|
+
raise ProviderError(
|
|
125
|
+
f"Semua provider gagal untuk {method_name}.",
|
|
126
|
+
"\n".join(errors),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _record_provider_result(
|
|
130
|
+
self,
|
|
131
|
+
provider: str,
|
|
132
|
+
operation: str,
|
|
133
|
+
status: str,
|
|
134
|
+
missing_fields: tuple[str, ...] = (),
|
|
135
|
+
message: str = "",
|
|
136
|
+
) -> None:
|
|
137
|
+
self.provider_results.append(
|
|
138
|
+
ProviderResult(
|
|
139
|
+
provider=provider,
|
|
140
|
+
operation=operation,
|
|
141
|
+
status=status,
|
|
142
|
+
realtime_label="unknown",
|
|
143
|
+
source=provider,
|
|
144
|
+
data_quality=status,
|
|
145
|
+
missing_fields=missing_fields,
|
|
146
|
+
message=message,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
if len(self.provider_results) > 50:
|
|
150
|
+
self.provider_results = self.provider_results[-50:]
|
|
151
|
+
|
|
152
|
+
def _record_provider_metric(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
|
|
153
|
+
metric = self.provider_metrics.setdefault(provider, ProviderRuntimeMetrics(provider))
|
|
154
|
+
metric.record(success=success, latency_ms=latency_ms, fallback=fallback)
|
|
155
|
+
if self.metrics_store is not None:
|
|
156
|
+
self.metrics_store.record(provider, success=success, latency_ms=latency_ms, fallback=fallback)
|
|
157
|
+
|
|
158
|
+
def provider_metrics_snapshot(self) -> dict[str, "ProviderRuntimeMetrics"]:
|
|
159
|
+
return {provider: metric.copy() for provider, metric in self.provider_metrics.items()}
|
|
98
160
|
|
|
99
161
|
def _cache_key(self, symbol: str, *parts: object) -> str:
|
|
100
162
|
provider_chain = ",".join(provider.name for provider in self.providers)
|
|
@@ -197,7 +259,50 @@ def _parse_datetime(value: object) -> datetime:
|
|
|
197
259
|
return datetime.fromisoformat(str(value))
|
|
198
260
|
|
|
199
261
|
|
|
200
|
-
def _optional_float(value: object) -> float | None:
|
|
201
|
-
if value is None:
|
|
202
|
-
return None
|
|
203
|
-
return float(value)
|
|
262
|
+
def _optional_float(value: object) -> float | None:
|
|
263
|
+
if value is None:
|
|
264
|
+
return None
|
|
265
|
+
return float(value)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ProviderRuntimeMetrics:
|
|
269
|
+
"""Runtime metrics for one market provider."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, provider: str) -> None:
|
|
272
|
+
self.provider = provider
|
|
273
|
+
self.calls = 0
|
|
274
|
+
self.successes = 0
|
|
275
|
+
self.errors = 0
|
|
276
|
+
self.fallbacks = 0
|
|
277
|
+
self.total_latency_ms = 0.0
|
|
278
|
+
self.last_status = "not_called"
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def success_rate(self) -> float:
|
|
282
|
+
return (self.successes / self.calls * 100) if self.calls else 0.0
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def avg_latency_ms(self) -> float:
|
|
286
|
+
return (self.total_latency_ms / self.calls) if self.calls else 0.0
|
|
287
|
+
|
|
288
|
+
def record(self, success: bool, latency_ms: float, fallback: bool = False) -> None:
|
|
289
|
+
self.calls += 1
|
|
290
|
+
self.total_latency_ms += max(latency_ms, 0.0)
|
|
291
|
+
if success:
|
|
292
|
+
self.successes += 1
|
|
293
|
+
self.last_status = "success"
|
|
294
|
+
else:
|
|
295
|
+
self.errors += 1
|
|
296
|
+
self.last_status = "error"
|
|
297
|
+
if fallback:
|
|
298
|
+
self.fallbacks += 1
|
|
299
|
+
|
|
300
|
+
def copy(self) -> "ProviderRuntimeMetrics":
|
|
301
|
+
duplicate = ProviderRuntimeMetrics(self.provider)
|
|
302
|
+
duplicate.calls = self.calls
|
|
303
|
+
duplicate.successes = self.successes
|
|
304
|
+
duplicate.errors = self.errors
|
|
305
|
+
duplicate.fallbacks = self.fallbacks
|
|
306
|
+
duplicate.total_latency_ms = self.total_latency_ms
|
|
307
|
+
duplicate.last_status = self.last_status
|
|
308
|
+
return duplicate
|
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
|
|
8
8
|
from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
|
|
9
9
|
from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, Quote
|
|
10
|
+
from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE
|
|
10
11
|
from fincli.app.services.market_data import MarketDataService
|
|
11
12
|
from fincli.app.utils.errors import FinCLIError
|
|
12
13
|
|
|
@@ -19,6 +20,11 @@ class DataQuality:
|
|
|
19
20
|
news: str
|
|
20
21
|
fundamentals: str
|
|
21
22
|
provider: str
|
|
23
|
+
tier: str
|
|
24
|
+
freshness: str
|
|
25
|
+
reliability_status: str
|
|
26
|
+
missing_fields: tuple[str, ...]
|
|
27
|
+
label: str
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
@dataclass(frozen=True, slots=True)
|
|
@@ -73,9 +79,12 @@ def _score_data_quality(
|
|
|
73
79
|
fundamentals: FundamentalSnapshot | None,
|
|
74
80
|
) -> DataQuality:
|
|
75
81
|
score = 0
|
|
82
|
+
missing: list[str] = []
|
|
76
83
|
quote_status = "ok" if quote.price is not None else "missing"
|
|
77
84
|
if quote.price is not None:
|
|
78
85
|
score += 25
|
|
86
|
+
else:
|
|
87
|
+
missing.append("quote")
|
|
79
88
|
|
|
80
89
|
candle_count = len(candles)
|
|
81
90
|
if candle_count >= 120:
|
|
@@ -89,23 +98,55 @@ def _score_data_quality(
|
|
|
89
98
|
score += 10
|
|
90
99
|
else:
|
|
91
100
|
ohlcv_status = "missing"
|
|
101
|
+
missing.append("ohlcv")
|
|
92
102
|
|
|
93
103
|
news_status = f"{len(news)} item(s)" if news else "missing"
|
|
94
104
|
if news:
|
|
95
105
|
score += 15
|
|
106
|
+
else:
|
|
107
|
+
missing.append("news")
|
|
96
108
|
|
|
97
109
|
fundamentals_status = "ok" if fundamentals is not None else "missing"
|
|
98
110
|
if fundamentals is not None:
|
|
99
111
|
score += 20
|
|
112
|
+
else:
|
|
113
|
+
missing.append("fundamentals")
|
|
100
114
|
|
|
101
115
|
if quote.status == "realtime":
|
|
102
116
|
score += 5
|
|
103
117
|
|
|
118
|
+
normalized_score = min(score, 100)
|
|
119
|
+
tier = _quality_tier(normalized_score)
|
|
120
|
+
freshness = quote.status or "unknown"
|
|
121
|
+
reliability_status = _reliability_status(missing, normalized_score)
|
|
104
122
|
return DataQuality(
|
|
105
|
-
score=
|
|
123
|
+
score=normalized_score,
|
|
106
124
|
quote=quote_status,
|
|
107
125
|
ohlcv=ohlcv_status,
|
|
108
126
|
news=news_status,
|
|
109
127
|
fundamentals=fundamentals_status,
|
|
110
128
|
provider=f"{quote.provider} ({quote.status})",
|
|
129
|
+
tier=tier,
|
|
130
|
+
freshness=freshness,
|
|
131
|
+
reliability_status=reliability_status,
|
|
132
|
+
missing_fields=tuple(missing),
|
|
133
|
+
label=f"{tier} | {reliability_status} | freshness={freshness}",
|
|
111
134
|
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _quality_tier(score: int) -> str:
|
|
138
|
+
if score >= 85:
|
|
139
|
+
return "strong"
|
|
140
|
+
if score >= 65:
|
|
141
|
+
return "usable"
|
|
142
|
+
if score >= 40:
|
|
143
|
+
return "partial"
|
|
144
|
+
return "weak"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _reliability_status(missing: list[str], score: int) -> str:
|
|
148
|
+
if "quote" in missing or "ohlcv" in missing or score < 25:
|
|
149
|
+
return STATUS_UNAVAILABLE
|
|
150
|
+
if missing:
|
|
151
|
+
return STATUS_PARTIAL_DATA
|
|
152
|
+
return STATUS_OK
|
|
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
7
7
|
|
|
8
8
|
from fincli.app.connectors.news_connectors import NewsConnectorManager
|
|
9
9
|
from fincli.app.providers.market.base import NewsItem
|
|
10
|
+
from fincli.app.providers.reliability import STATUS_OK, STATUS_PARTIAL_DATA, STATUS_UNAVAILABLE, classify_provider_error
|
|
10
11
|
from fincli.app.services.market_data import MarketDataService
|
|
11
12
|
|
|
12
13
|
|
|
@@ -18,6 +19,7 @@ class NewsDesk:
|
|
|
18
19
|
note: str
|
|
19
20
|
errors: tuple[str, ...] = ()
|
|
20
21
|
lookback_days: int | None = None
|
|
22
|
+
reliability_status: str = STATUS_UNAVAILABLE
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class NewsAggregator:
|
|
@@ -42,7 +44,7 @@ class NewsAggregator:
|
|
|
42
44
|
try:
|
|
43
45
|
fetched = await self._fetch_provider(provider, normalized, max(limit - len(items), 1))
|
|
44
46
|
except Exception as exc: # noqa: BLE001 - fallback chain should continue
|
|
45
|
-
errors.append(f"{provider}: {exc}")
|
|
47
|
+
errors.append(f"{provider}: {classify_provider_error(exc)} ({exc})")
|
|
46
48
|
continue
|
|
47
49
|
for item in fetched:
|
|
48
50
|
if lookback_days is not None and not _within_lookback(item, lookback_days):
|
|
@@ -57,11 +59,14 @@ class NewsAggregator:
|
|
|
57
59
|
break
|
|
58
60
|
|
|
59
61
|
note = "Provider-backed news. Realtime/delayed status depends on provider entitlement."
|
|
62
|
+
reliability_status = STATUS_OK
|
|
60
63
|
if not items:
|
|
61
64
|
note = "No news returned by active providers. Try /research <symbol> --deep or configure /news_model priority."
|
|
65
|
+
reliability_status = STATUS_UNAVAILABLE if errors else STATUS_PARTIAL_DATA
|
|
62
66
|
elif errors:
|
|
63
67
|
note = f"{note} Fallback used after {len(errors)} provider error(s)."
|
|
64
|
-
|
|
68
|
+
reliability_status = STATUS_PARTIAL_DATA
|
|
69
|
+
return NewsDesk(normalized, provider_chain, items, note, tuple(errors), lookback_days, reliability_status)
|
|
65
70
|
|
|
66
71
|
async def _fetch_provider(self, provider: str, symbol: str, limit: int) -> list[NewsItem]:
|
|
67
72
|
if provider == "yfinance" or any(item.name == provider for item in self.market_service.providers):
|
|
@@ -119,6 +119,17 @@ class FinCLIDatabase:
|
|
|
119
119
|
gameplay TEXT NOT NULL,
|
|
120
120
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
121
121
|
);
|
|
122
|
+
|
|
123
|
+
CREATE TABLE IF NOT EXISTS provider_metrics (
|
|
124
|
+
provider TEXT PRIMARY KEY,
|
|
125
|
+
calls INTEGER DEFAULT 0,
|
|
126
|
+
successes INTEGER DEFAULT 0,
|
|
127
|
+
errors INTEGER DEFAULT 0,
|
|
128
|
+
fallbacks INTEGER DEFAULT 0,
|
|
129
|
+
total_latency_ms REAL DEFAULT 0,
|
|
130
|
+
last_status TEXT DEFAULT 'not_called',
|
|
131
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
132
|
+
);
|
|
122
133
|
"""
|
|
123
134
|
)
|
|
124
135
|
_migrate_user_profile_schema(db)
|
|
@@ -142,7 +153,7 @@ class FinCLIDatabase:
|
|
|
142
153
|
|
|
143
154
|
|
|
144
155
|
def _migrate_user_profile_schema(db: sqlite3.Connection) -> None:
|
|
145
|
-
"""Normalize older user_profile schemas to the v0.
|
|
156
|
+
"""Normalize older user_profile schemas to the v0.3.0 canonical shape."""
|
|
146
157
|
|
|
147
158
|
columns = {str(row["name"]) for row in db.execute("PRAGMA table_info(user_profile)").fetchall()}
|
|
148
159
|
canonical = {"id", "name", "equity", "currency", "leverage", "years_in_investment", "gameplay", "updated_at"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Persistent provider metrics storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fincli.app.services.market_data import ProviderRuntimeMetrics
|
|
6
|
+
from fincli.app.storage.database import FinCLIDatabase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderMetricsStore:
|
|
10
|
+
"""Persist aggregate provider metrics across FinCLI sessions."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, db: FinCLIDatabase) -> None:
|
|
13
|
+
self.db = db
|
|
14
|
+
|
|
15
|
+
def record(self, provider: str, success: bool, latency_ms: float, fallback: bool = False) -> None:
|
|
16
|
+
current = self.snapshot().get(provider, ProviderRuntimeMetrics(provider))
|
|
17
|
+
current.record(success=success, latency_ms=latency_ms, fallback=fallback)
|
|
18
|
+
self.db.execute(
|
|
19
|
+
"""
|
|
20
|
+
INSERT INTO provider_metrics(provider, calls, successes, errors, fallbacks, total_latency_ms, last_status, updated_at)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
22
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
23
|
+
calls=excluded.calls,
|
|
24
|
+
successes=excluded.successes,
|
|
25
|
+
errors=excluded.errors,
|
|
26
|
+
fallbacks=excluded.fallbacks,
|
|
27
|
+
total_latency_ms=excluded.total_latency_ms,
|
|
28
|
+
last_status=excluded.last_status,
|
|
29
|
+
updated_at=CURRENT_TIMESTAMP
|
|
30
|
+
""",
|
|
31
|
+
(
|
|
32
|
+
current.provider,
|
|
33
|
+
current.calls,
|
|
34
|
+
current.successes,
|
|
35
|
+
current.errors,
|
|
36
|
+
current.fallbacks,
|
|
37
|
+
current.total_latency_ms,
|
|
38
|
+
current.last_status,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def snapshot(self) -> dict[str, ProviderRuntimeMetrics]:
|
|
43
|
+
rows = self.db.query(
|
|
44
|
+
"""
|
|
45
|
+
SELECT provider, calls, successes, errors, fallbacks, total_latency_ms, last_status
|
|
46
|
+
FROM provider_metrics
|
|
47
|
+
ORDER BY provider
|
|
48
|
+
"""
|
|
49
|
+
)
|
|
50
|
+
metrics: dict[str, ProviderRuntimeMetrics] = {}
|
|
51
|
+
for row in rows:
|
|
52
|
+
metric = ProviderRuntimeMetrics(str(row["provider"]))
|
|
53
|
+
metric.calls = int(row["calls"])
|
|
54
|
+
metric.successes = int(row["successes"])
|
|
55
|
+
metric.errors = int(row["errors"])
|
|
56
|
+
metric.fallbacks = int(row["fallbacks"])
|
|
57
|
+
metric.total_latency_ms = float(row["total_latency_ms"])
|
|
58
|
+
metric.last_status = str(row["last_status"])
|
|
59
|
+
metrics[metric.provider] = metric
|
|
60
|
+
return metrics
|
|
61
|
+
|
|
@@ -60,6 +60,24 @@ def save_secret(env_key: str, value: str, path: Path | None = None) -> None:
|
|
|
60
60
|
os.environ[key] = secret
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def clear_secrets(path: Path | None = None) -> int:
|
|
64
|
+
"""Clear persisted local secrets and remove them from the current process."""
|
|
65
|
+
path = path or SECRETS_FILE
|
|
66
|
+
secrets = read_secrets(path)
|
|
67
|
+
for key in secrets:
|
|
68
|
+
os.environ.pop(key, None)
|
|
69
|
+
try:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text("# FinCLI local secrets. Do not commit or share this file.\n", encoding="utf-8")
|
|
72
|
+
try:
|
|
73
|
+
os.chmod(path, 0o600)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise ConfigError("Secret lokal gagal dibersihkan.", f"Path: {path}") from exc
|
|
78
|
+
return len(secrets)
|
|
79
|
+
|
|
80
|
+
|
|
63
81
|
def read_secrets(path: Path | None = None) -> dict[str, str]:
|
|
64
82
|
"""Read local secrets without printing or masking them."""
|
|
65
83
|
path = path or SECRETS_FILE
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drico2008/fincli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Modern financial CLI/TUI terminal for market monitoring and analysis.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fincli": "npm/bin/fincli.js"
|
|
8
8
|
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"postinstall": "node npm/postinstall.js",
|
|
11
|
-
"check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js"
|
|
12
|
-
|
|
9
|
+
"scripts": {
|
|
10
|
+
"postinstall": "node npm/postinstall.js",
|
|
11
|
+
"check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js",
|
|
12
|
+
"prepublish:safety": "python scripts/prepublish_check.py",
|
|
13
|
+
"prepublishOnly": "python scripts/prepublish_check.py"
|
|
14
|
+
},
|
|
13
15
|
"files": [
|
|
14
16
|
"fincli/**/*.py",
|
|
15
17
|
"npm",
|
package/pyproject.toml
CHANGED