fin3 0.4.0__py3-none-any.whl
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.
- fin3/__init__.py +55 -0
- fin3/calendar/__init__.py +1 -0
- fin3/calendar/exchange.py +142 -0
- fin3/cli.py +366 -0
- fin3/config/__init__.py +1 -0
- fin3/config/settings.py +300 -0
- fin3/core.py +611 -0
- fin3/exceptions.py +126 -0
- fin3/inspect.py +239 -0
- fin3/manifest.py +631 -0
- fin3/metadata/__init__.py +1 -0
- fin3/metadata/asset_profile.py +126 -0
- fin3/monitoring/__init__.py +15 -0
- fin3/monitoring/collector.py +172 -0
- fin3/monitoring/display.py +114 -0
- fin3/monitoring/render.py +128 -0
- fin3/monitoring/tmux.py +96 -0
- fin3/monitoring/tracker.py +453 -0
- fin3/options.py +653 -0
- fin3/options_schemas.py +481 -0
- fin3/providers/__init__.py +144 -0
- fin3/providers/base.py +79 -0
- fin3/providers/binance.py +358 -0
- fin3/providers/cboe.py +306 -0
- fin3/providers/databento.py +253 -0
- fin3/providers/eodhd_options.py +1257 -0
- fin3/providers/massive.py +404 -0
- fin3/providers/yfinance.py +303 -0
- fin3/schemas.py +164 -0
- fin3/storage/__init__.py +1 -0
- fin3/storage/arctic.py +601 -0
- fin3/storage/defrag.py +225 -0
- fin3/storage/locking.py +314 -0
- fin3/sync.py +1078 -0
- fin3/utils/__init__.py +1 -0
- fin3/utils/date_utils.py +119 -0
- fin3/utils/integrity.py +127 -0
- fin3/utils/logging.py +48 -0
- fin3/utils/options_validation.py +245 -0
- fin3/utils/rules.py +573 -0
- fin3/utils/symbols.py +34 -0
- fin3/utils/validation.py +104 -0
- fin3-0.4.0.dist-info/METADATA +188 -0
- fin3-0.4.0.dist-info/RECORD +48 -0
- fin3-0.4.0.dist-info/WHEEL +5 -0
- fin3-0.4.0.dist-info/entry_points.txt +2 -0
- fin3-0.4.0.dist-info/licenses/LICENSE +202 -0
- fin3-0.4.0.dist-info/top_level.txt +1 -0
fin3/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""fin3 — Declarative financial time-series data library."""
|
|
2
|
+
|
|
3
|
+
from fin3.config.settings import ClientConfig
|
|
4
|
+
from fin3.core import MarketDataFetcher
|
|
5
|
+
from fin3.exceptions import (
|
|
6
|
+
BoundaryMismatchError,
|
|
7
|
+
ConfigurationError,
|
|
8
|
+
DataValidationError,
|
|
9
|
+
Fin3Error,
|
|
10
|
+
ProviderError,
|
|
11
|
+
ProviderRateLimitError,
|
|
12
|
+
ProviderTimeoutError,
|
|
13
|
+
RequestLimitExceededError,
|
|
14
|
+
SchemaValidationError,
|
|
15
|
+
StorageError,
|
|
16
|
+
)
|
|
17
|
+
from fin3.inspect import LibraryOverview, SymbolProfile, inspect_library
|
|
18
|
+
from fin3.monitoring import ResourceReport, ResourceTracker
|
|
19
|
+
from fin3.options import ChainResult, OptionsDataFetcher
|
|
20
|
+
from fin3.options_schemas import ChainCoverageManifest
|
|
21
|
+
from fin3.schemas import AssetType, Resolution
|
|
22
|
+
from fin3.storage.defrag import DefragReport, SymbolDefragResult, defragment_library, get_fragmentation_info
|
|
23
|
+
from fin3.utils.integrity import IntegrityIssue, IntegrityReport, check_integrity
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AssetType",
|
|
27
|
+
"BoundaryMismatchError",
|
|
28
|
+
"ChainCoverageManifest",
|
|
29
|
+
"ChainResult",
|
|
30
|
+
"ClientConfig",
|
|
31
|
+
"ConfigurationError",
|
|
32
|
+
"DataValidationError",
|
|
33
|
+
"DefragReport",
|
|
34
|
+
"Fin3Error",
|
|
35
|
+
"IntegrityIssue",
|
|
36
|
+
"IntegrityReport",
|
|
37
|
+
"LibraryOverview",
|
|
38
|
+
"MarketDataFetcher",
|
|
39
|
+
"OptionsDataFetcher",
|
|
40
|
+
"ProviderError",
|
|
41
|
+
"RequestLimitExceededError",
|
|
42
|
+
"ResourceReport",
|
|
43
|
+
"ResourceTracker",
|
|
44
|
+
"ProviderRateLimitError",
|
|
45
|
+
"ProviderTimeoutError",
|
|
46
|
+
"Resolution",
|
|
47
|
+
"SchemaValidationError",
|
|
48
|
+
"StorageError",
|
|
49
|
+
"SymbolDefragResult",
|
|
50
|
+
"SymbolProfile",
|
|
51
|
+
"check_integrity",
|
|
52
|
+
"defragment_library",
|
|
53
|
+
"get_fragmentation_info",
|
|
54
|
+
"inspect_library",
|
|
55
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Calendar strategy protocol and implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import exchange_calendars
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from fin3.schemas import Resolution
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CalendarStrategy(Protocol):
|
|
14
|
+
"""Protocol for trading calendar grid generation.
|
|
15
|
+
|
|
16
|
+
Implementations generate a DatetimeIndex of valid bar timestamps within
|
|
17
|
+
the trading session for a given date range and resolution. Used to
|
|
18
|
+
reindex OHLCV data so every expected bar has a row.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def generate_grid(
|
|
22
|
+
self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
|
|
23
|
+
) -> pd.DatetimeIndex:
|
|
24
|
+
"""Generate a grid of valid bar timestamps.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
start : pd.Timestamp
|
|
29
|
+
Start of the range (UTC).
|
|
30
|
+
end : pd.Timestamp
|
|
31
|
+
End of the range (UTC).
|
|
32
|
+
resolution : Resolution
|
|
33
|
+
Bar resolution (e.g. ``1m``, ``1h``).
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
pd.DatetimeIndex
|
|
38
|
+
Sorted UTC timestamps for every valid bar in the range.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExchangeCalendarStrategy:
|
|
44
|
+
"""Generates master grid using exchange_calendars for a given MIC.
|
|
45
|
+
|
|
46
|
+
Produces aligned intraday timestamps for exchanges with defined trading
|
|
47
|
+
sessions, holidays, and early closes. Used for equities (NYSE) and
|
|
48
|
+
futures (CME).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, mic: str) -> None:
|
|
52
|
+
"""Initialise with a Market Identifier Code.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
mic : str
|
|
57
|
+
ISO 10383 Market Identifier Code (e.g. ``XNYS``, ``XNAS``, ``CME``).
|
|
58
|
+
"""
|
|
59
|
+
self._calendar = exchange_calendars.get_calendar(mic)
|
|
60
|
+
|
|
61
|
+
def generate_grid(
|
|
62
|
+
self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
|
|
63
|
+
) -> pd.DatetimeIndex:
|
|
64
|
+
"""Generate an exchange-aligned bar grid.
|
|
65
|
+
|
|
66
|
+
Builds per-session bar sequences using the exchange's open/close
|
|
67
|
+
times, excluding holidays and early closes. Intraday sub-day grids
|
|
68
|
+
are trimmed to the requested range when start/end carry intraday time.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
start : pd.Timestamp
|
|
73
|
+
Start of the range (UTC).
|
|
74
|
+
end : pd.Timestamp
|
|
75
|
+
End of the range (UTC).
|
|
76
|
+
resolution : Resolution
|
|
77
|
+
Bar resolution (e.g. ``1m`` for minute bars).
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
pd.DatetimeIndex
|
|
82
|
+
Sorted UTC timestamps for every valid bar in the range.
|
|
83
|
+
"""
|
|
84
|
+
naive_start = start.tz_localize(None) if start.tz is not None else start
|
|
85
|
+
naive_end = end.tz_localize(None) if end.tz is not None else end
|
|
86
|
+
# sessions_in_range requires date-only inputs (midnight)
|
|
87
|
+
date_start = naive_start.normalize()
|
|
88
|
+
date_end = naive_end.normalize()
|
|
89
|
+
sessions = self._calendar.sessions_in_range(date_start, date_end)
|
|
90
|
+
freq = resolution.timedelta_alias
|
|
91
|
+
|
|
92
|
+
all_bars: list[pd.DatetimeIndex] = []
|
|
93
|
+
for session in sessions:
|
|
94
|
+
session_ts = pd.Timestamp(session)
|
|
95
|
+
open_time, close_time = self._calendar.session_open_close(session_ts)
|
|
96
|
+
bars = pd.date_range(open_time, close_time, freq=freq)
|
|
97
|
+
if len(bars) > 0 and bars[-1] > close_time:
|
|
98
|
+
bars = bars[:-1]
|
|
99
|
+
all_bars.append(bars)
|
|
100
|
+
|
|
101
|
+
if not all_bars:
|
|
102
|
+
return pd.DatetimeIndex([], tz="UTC")
|
|
103
|
+
|
|
104
|
+
combined = pd.DatetimeIndex(pd.concat([pd.Series(idx) for idx in all_bars]))
|
|
105
|
+
if combined.tz is None:
|
|
106
|
+
combined = combined.tz_localize("UTC")
|
|
107
|
+
# Trim to the requested range when start/end carry intraday time
|
|
108
|
+
utc_start = start if start.tz is not None else start.tz_localize("UTC")
|
|
109
|
+
utc_end = end if end.tz is not None else end.tz_localize("UTC")
|
|
110
|
+
if utc_start != utc_start.normalize() or utc_end != utc_end.normalize():
|
|
111
|
+
combined = combined[(combined >= utc_start) & (combined <= utc_end)]
|
|
112
|
+
return combined
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ContinuousCalendarStrategy:
|
|
116
|
+
"""Generates master grid as a continuous date_range (no holidays). Used for crypto.
|
|
117
|
+
|
|
118
|
+
Produces uninterrupted bar timestamps across the full date range at the
|
|
119
|
+
specified resolution. No gaps for weekends, holidays, or exchange closures.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def generate_grid(
|
|
123
|
+
self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
|
|
124
|
+
) -> pd.DatetimeIndex:
|
|
125
|
+
"""Generate a continuous bar grid with no calendar gaps.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
start : pd.Timestamp
|
|
130
|
+
Start of the range (UTC).
|
|
131
|
+
end : pd.Timestamp
|
|
132
|
+
End of the range (UTC).
|
|
133
|
+
resolution : Resolution
|
|
134
|
+
Bar resolution (e.g. ``1h`` for hourly bars).
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
pd.DatetimeIndex
|
|
139
|
+
Sorted UTC timestamps at the requested resolution across the range.
|
|
140
|
+
"""
|
|
141
|
+
freq = resolution.timedelta_alias
|
|
142
|
+
return pd.date_range(start, end, freq=freq, tz="UTC")
|
fin3/cli.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""``fin3`` command line: ensure / sync / status for declarative manifests.
|
|
2
|
+
|
|
3
|
+
Construction order is fixed: parse arguments → load/validate manifest →
|
|
4
|
+
``_load_config()`` → ``configure_logging`` → ``_build_runner()`` → perform
|
|
5
|
+
operation. Manifest validation deliberately precedes environment
|
|
6
|
+
configuration so an invalid manifest is diagnosed even outside a configured
|
|
7
|
+
checkout. ``--json`` emits exactly one JSON value on stdout; all diagnostics
|
|
8
|
+
routed through ``logging`` go to stderr.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import SecretStr, ValidationError
|
|
21
|
+
|
|
22
|
+
from fin3.config.settings import ClientConfig
|
|
23
|
+
from fin3.exceptions import ConfigurationError, CostLimitExceededError, Fin3Error
|
|
24
|
+
from fin3.manifest import load_manifest
|
|
25
|
+
from fin3.storage.locking import LockAcquisitionError
|
|
26
|
+
from fin3.sync import ManifestReport, ManifestRunner, SymbolResult
|
|
27
|
+
from fin3.utils.logging import configure_logging
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_config() -> ClientConfig:
|
|
31
|
+
"""Production configuration seam (tests monkeypatch this).
|
|
32
|
+
|
|
33
|
+
``ClientConfig`` validates ``FIN3_*`` environment variables at
|
|
34
|
+
construction; a missing ``FIN3_MINIO__*`` set raises
|
|
35
|
+
``pydantic.ValidationError``, which ``main`` renders as a
|
|
36
|
+
configuration error.
|
|
37
|
+
"""
|
|
38
|
+
return ClientConfig() # type: ignore[call-arg] # required fields come from env
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_runner(config: ClientConfig) -> ManifestRunner:
|
|
42
|
+
"""Production runner seam (tests monkeypatch this)."""
|
|
43
|
+
return ManifestRunner(config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Secret redaction
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _collect_secrets(config: ClientConfig | None) -> list[str]:
|
|
52
|
+
"""Return configured secret values (API keys, MinIO credentials).
|
|
53
|
+
|
|
54
|
+
Recognizes plain ``str`` fields and ``SecretStr`` fields (via
|
|
55
|
+
``get_secret_value()``) — including the hidden top-level
|
|
56
|
+
``ClientConfig.eodhd_api_key``. This is a redaction boundary: the
|
|
57
|
+
collected values must never appear in dumps, errors, reports, or URLs.
|
|
58
|
+
"""
|
|
59
|
+
if config is None:
|
|
60
|
+
return []
|
|
61
|
+
secrets: list[str] = []
|
|
62
|
+
models: list[object] = [config.minio, *config.providers.values()]
|
|
63
|
+
for model in models:
|
|
64
|
+
for name in getattr(type(model), "model_fields", {}):
|
|
65
|
+
if "key" not in name and "secret" not in name:
|
|
66
|
+
continue
|
|
67
|
+
value = getattr(model, name, None)
|
|
68
|
+
if isinstance(value, SecretStr):
|
|
69
|
+
raw = value.get_secret_value()
|
|
70
|
+
if raw:
|
|
71
|
+
secrets.append(raw)
|
|
72
|
+
elif isinstance(value, str) and value:
|
|
73
|
+
secrets.append(value)
|
|
74
|
+
# Top-level hidden raw key (a bare SecretStr field, not a model).
|
|
75
|
+
raw_key = config.eodhd_api_key
|
|
76
|
+
if isinstance(raw_key, SecretStr) and raw_key.get_secret_value():
|
|
77
|
+
secrets.append(raw_key.get_secret_value())
|
|
78
|
+
return sorted(set(secrets), key=len, reverse=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _redact(message: str, config: ClientConfig | None) -> str:
|
|
82
|
+
for secret in _collect_secrets(config):
|
|
83
|
+
if secret in message:
|
|
84
|
+
message = message.replace(secret, "***")
|
|
85
|
+
return message
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _redact_report(
|
|
89
|
+
report: ManifestReport, config: ClientConfig | None
|
|
90
|
+
) -> ManifestReport:
|
|
91
|
+
"""Return a copy of *report* with secret-redacted error strings."""
|
|
92
|
+
results: list[SymbolResult] = []
|
|
93
|
+
for r in report.results:
|
|
94
|
+
if r.error is None:
|
|
95
|
+
results.append(r)
|
|
96
|
+
continue
|
|
97
|
+
results.append(
|
|
98
|
+
SymbolResult(
|
|
99
|
+
dataset_id=r.dataset_id,
|
|
100
|
+
library=r.library,
|
|
101
|
+
symbol=r.symbol,
|
|
102
|
+
policy=r.policy,
|
|
103
|
+
action=r.action,
|
|
104
|
+
status=r.status,
|
|
105
|
+
reason=r.reason,
|
|
106
|
+
fetch_ranges=list(r.fetch_ranges),
|
|
107
|
+
write_ranges=list(r.write_ranges),
|
|
108
|
+
rows_written=r.rows_written,
|
|
109
|
+
write_state_unknown=r.write_state_unknown,
|
|
110
|
+
error=_redact(r.error, config),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return ManifestReport(
|
|
114
|
+
manifest_id=report.manifest_id,
|
|
115
|
+
declaration_hash=report.declaration_hash,
|
|
116
|
+
last_successful_hash=report.last_successful_hash,
|
|
117
|
+
definition_state=report.definition_state,
|
|
118
|
+
operation=report.operation,
|
|
119
|
+
dry_run=report.dry_run,
|
|
120
|
+
resolved_at=report.resolved_at,
|
|
121
|
+
planned_libraries=report.planned_libraries,
|
|
122
|
+
created_libraries=report.created_libraries,
|
|
123
|
+
estimated_cost=report.estimated_cost,
|
|
124
|
+
results=results,
|
|
125
|
+
errors=[_redact(e, config) for e in report.errors],
|
|
126
|
+
state_write_unknown=report.state_write_unknown,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Rendering
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _iso(dt: datetime) -> str:
|
|
136
|
+
return dt.astimezone(timezone.utc).isoformat()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _result_to_dict(result: SymbolResult) -> dict[str, Any]:
|
|
140
|
+
return {
|
|
141
|
+
"dataset_id": result.dataset_id,
|
|
142
|
+
"library": result.library,
|
|
143
|
+
"symbol": result.symbol,
|
|
144
|
+
"policy": result.policy,
|
|
145
|
+
"action": result.action,
|
|
146
|
+
"status": result.status,
|
|
147
|
+
"reason": result.reason,
|
|
148
|
+
"fetch_ranges": [[_iso(a), _iso(b)] for a, b in result.fetch_ranges],
|
|
149
|
+
"write_ranges": [[_iso(a), _iso(b)] for a, b in result.write_ranges],
|
|
150
|
+
"rows_written": result.rows_written,
|
|
151
|
+
"write_state_unknown": result.write_state_unknown,
|
|
152
|
+
"error": result.error,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _report_to_dict(report: ManifestReport) -> dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"manifest_id": report.manifest_id,
|
|
159
|
+
"declaration_hash": report.declaration_hash,
|
|
160
|
+
"last_successful_hash": report.last_successful_hash,
|
|
161
|
+
"definition_state": report.definition_state,
|
|
162
|
+
"operation": report.operation,
|
|
163
|
+
"dry_run": report.dry_run,
|
|
164
|
+
"resolved_at": _iso(report.resolved_at),
|
|
165
|
+
"planned_libraries": report.planned_libraries,
|
|
166
|
+
"created_libraries": report.created_libraries,
|
|
167
|
+
"estimated_cost": report.estimated_cost,
|
|
168
|
+
"totals": {
|
|
169
|
+
"created": report.created_count,
|
|
170
|
+
"updated": report.updated_count,
|
|
171
|
+
"skipped": report.skipped_count,
|
|
172
|
+
"failed": report.failed_count,
|
|
173
|
+
},
|
|
174
|
+
"results": [_result_to_dict(r) for r in report.results],
|
|
175
|
+
"errors": list(report.errors),
|
|
176
|
+
"state_write_unknown": report.state_write_unknown,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_report(report: ManifestReport) -> str:
|
|
181
|
+
lines = [
|
|
182
|
+
f"Manifest {report.manifest_id}: {report.operation}"
|
|
183
|
+
+ (" (dry-run)" if report.dry_run else ""),
|
|
184
|
+
f" definition: {report.definition_state}"
|
|
185
|
+
+ (
|
|
186
|
+
f" (last successful hash: {report.last_successful_hash})"
|
|
187
|
+
if report.last_successful_hash
|
|
188
|
+
else ""
|
|
189
|
+
),
|
|
190
|
+
f" resolved_at: {_iso(report.resolved_at)}",
|
|
191
|
+
]
|
|
192
|
+
if report.planned_libraries:
|
|
193
|
+
lines.append(
|
|
194
|
+
f" planned library creations: {', '.join(report.planned_libraries)}"
|
|
195
|
+
)
|
|
196
|
+
if report.created_libraries:
|
|
197
|
+
lines.append(f" created libraries: {', '.join(report.created_libraries)}")
|
|
198
|
+
if report.estimated_cost is not None:
|
|
199
|
+
lines.append(f" estimated cost: ${report.estimated_cost:.2f}")
|
|
200
|
+
lines.append(
|
|
201
|
+
f" totals: {report.created_count} created, {report.updated_count} updated, "
|
|
202
|
+
f"{report.skipped_count} skipped, {report.failed_count} failed"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if report.results:
|
|
206
|
+
lines.append(" targets:")
|
|
207
|
+
for r in report.results:
|
|
208
|
+
line = (
|
|
209
|
+
f" {r.dataset_id}/{r.symbol} [{r.library}] "
|
|
210
|
+
f"action={r.action} status={r.status}"
|
|
211
|
+
)
|
|
212
|
+
if r.reason:
|
|
213
|
+
line += f" reason={r.reason}"
|
|
214
|
+
if r.rows_written:
|
|
215
|
+
line += f" rows={r.rows_written}"
|
|
216
|
+
if r.fetch_ranges:
|
|
217
|
+
ranges = ", ".join(
|
|
218
|
+
f"[{a.isoformat()}, {b.isoformat()}]" for a, b in r.fetch_ranges
|
|
219
|
+
)
|
|
220
|
+
line += f" fetch={ranges}"
|
|
221
|
+
if r.write_ranges:
|
|
222
|
+
ranges = ", ".join(
|
|
223
|
+
f"[{a.isoformat()}, {b.isoformat()}]" for a, b in r.write_ranges
|
|
224
|
+
)
|
|
225
|
+
line += f" write={ranges}"
|
|
226
|
+
lines.append(line)
|
|
227
|
+
if r.error is not None:
|
|
228
|
+
lines.append(f" error: {r.error}")
|
|
229
|
+
for err in report.errors:
|
|
230
|
+
lines.append(f" error: {err}")
|
|
231
|
+
if report.state_write_unknown:
|
|
232
|
+
lines.append(" warning: manifest state write outcome unknown")
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Argument parsing
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
242
|
+
parser = argparse.ArgumentParser(
|
|
243
|
+
prog="fin3",
|
|
244
|
+
description="Declarative dataset manifests: ensure, sync, and status",
|
|
245
|
+
)
|
|
246
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
247
|
+
|
|
248
|
+
for name, help_text in (
|
|
249
|
+
("ensure", "create missing libraries and symbols declared by the manifest"),
|
|
250
|
+
("sync", "reconcile stored data with the manifest per update policy"),
|
|
251
|
+
):
|
|
252
|
+
p = sub.add_parser(name, help=help_text)
|
|
253
|
+
p.add_argument("manifest", help="Path to a manifest .yaml/.yml/.json file")
|
|
254
|
+
p.add_argument(
|
|
255
|
+
"--dry-run",
|
|
256
|
+
action="store_true",
|
|
257
|
+
help="Report planned actions without writing anything",
|
|
258
|
+
)
|
|
259
|
+
p.add_argument(
|
|
260
|
+
"--json",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help="Emit the report as a single JSON value on stdout",
|
|
263
|
+
)
|
|
264
|
+
p.add_argument(
|
|
265
|
+
"--max-cost",
|
|
266
|
+
type=float,
|
|
267
|
+
default=None,
|
|
268
|
+
metavar="USD",
|
|
269
|
+
help="Abort before any write when the estimated download cost exceeds this",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
status = sub.add_parser(
|
|
273
|
+
"status", help="read-only preview of the actions sync would take"
|
|
274
|
+
)
|
|
275
|
+
status.add_argument("manifest", help="Path to a manifest .yaml/.yml/.json file")
|
|
276
|
+
status.add_argument(
|
|
277
|
+
"--json",
|
|
278
|
+
action="store_true",
|
|
279
|
+
help="Emit the report as a single JSON value on stdout",
|
|
280
|
+
)
|
|
281
|
+
return parser
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Entry point
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
290
|
+
"""CLI entry point. Returns a process exit code."""
|
|
291
|
+
parser = _build_parser()
|
|
292
|
+
args = parser.parse_args(argv)
|
|
293
|
+
|
|
294
|
+
config: ClientConfig | None = None
|
|
295
|
+
try:
|
|
296
|
+
manifest = load_manifest(args.manifest)
|
|
297
|
+
config = _load_config()
|
|
298
|
+
configure_logging(level=config.log_level, format_=config.log_format)
|
|
299
|
+
runner = _build_runner(config)
|
|
300
|
+
if args.command == "status":
|
|
301
|
+
report = runner.status(manifest)
|
|
302
|
+
elif args.command == "ensure":
|
|
303
|
+
report = runner.ensure(
|
|
304
|
+
manifest, dry_run=args.dry_run, max_cost=args.max_cost
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
report = runner.sync(manifest, dry_run=args.dry_run, max_cost=args.max_cost)
|
|
308
|
+
except ConfigurationError as exc:
|
|
309
|
+
payload = {
|
|
310
|
+
"error": {
|
|
311
|
+
"kind": "configuration",
|
|
312
|
+
"message": _redact(str(exc), config),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if getattr(args, "json", False):
|
|
316
|
+
print(json.dumps(payload))
|
|
317
|
+
else:
|
|
318
|
+
print(
|
|
319
|
+
f"configuration error: {payload['error']['message']}",
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
return 2
|
|
323
|
+
except ValidationError as exc:
|
|
324
|
+
payload = {
|
|
325
|
+
"error": {
|
|
326
|
+
"kind": "configuration",
|
|
327
|
+
"message": _redact(f"invalid configuration: {exc}", None),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if getattr(args, "json", False):
|
|
331
|
+
print(json.dumps(payload))
|
|
332
|
+
else:
|
|
333
|
+
print(
|
|
334
|
+
f"configuration error: {payload['error']['message']}",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
)
|
|
337
|
+
return 2
|
|
338
|
+
except (CostLimitExceededError, Fin3Error, LockAcquisitionError) as exc:
|
|
339
|
+
payload = {
|
|
340
|
+
"error": {
|
|
341
|
+
"kind": "operational",
|
|
342
|
+
"message": _redact(str(exc), config),
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if getattr(args, "json", False):
|
|
346
|
+
print(json.dumps(payload))
|
|
347
|
+
else:
|
|
348
|
+
print(
|
|
349
|
+
f"operational error: {payload['error']['message']}",
|
|
350
|
+
file=sys.stderr,
|
|
351
|
+
)
|
|
352
|
+
return 1
|
|
353
|
+
|
|
354
|
+
report = _redact_report(report, config)
|
|
355
|
+
if getattr(args, "json", False):
|
|
356
|
+
print(json.dumps(_report_to_dict(report)))
|
|
357
|
+
else:
|
|
358
|
+
print(_format_report(report))
|
|
359
|
+
|
|
360
|
+
if report.failed_count > 0 or report.state_write_unknown or report.errors:
|
|
361
|
+
return 1
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__": # pragma: no cover
|
|
366
|
+
raise SystemExit(main())
|
fin3/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|