ohmydata 0.0.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.
- ohmydata-0.0.1/.gitignore +50 -0
- ohmydata-0.0.1/CHANGELOG.md +20 -0
- ohmydata-0.0.1/PKG-INFO +160 -0
- ohmydata-0.0.1/README.md +149 -0
- ohmydata-0.0.1/pyproject.toml +49 -0
- ohmydata-0.0.1/src/ohmydata/__init__.py +5 -0
- ohmydata-0.0.1/src/ohmydata/core/__init__.py +52 -0
- ohmydata-0.0.1/src/ohmydata/core/errors.py +66 -0
- ohmydata-0.0.1/src/ohmydata/core/policy.py +101 -0
- ohmydata-0.0.1/src/ohmydata/core/provenance.py +93 -0
- ohmydata-0.0.1/src/ohmydata/core/rate_limit.py +53 -0
- ohmydata-0.0.1/src/ohmydata/core/snapshot.py +292 -0
- ohmydata-0.0.1/src/ohmydata/core/specs.py +100 -0
- ohmydata-0.0.1/src/ohmydata/providers/__init__.py +5 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/__init__.py +44 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/client.py +265 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/endpoints.py +544 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/errors.py +36 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/recipes/__init__.py +15 -0
- ohmydata-0.0.1/src/ohmydata/providers/tushare/recipes/etf_adjusted_bars.py +177 -0
- ohmydata-0.0.1/uv.lock +532 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Credentials and local configuration
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
*.p12
|
|
8
|
+
*.pfx
|
|
9
|
+
credentials.json
|
|
10
|
+
secrets.json
|
|
11
|
+
token.json
|
|
12
|
+
|
|
13
|
+
# Provider responses and local data
|
|
14
|
+
data/
|
|
15
|
+
downloads/
|
|
16
|
+
snapshots/
|
|
17
|
+
raw/
|
|
18
|
+
artifacts/
|
|
19
|
+
*.parquet
|
|
20
|
+
*.feather
|
|
21
|
+
*.arrow
|
|
22
|
+
*.duckdb
|
|
23
|
+
*.db
|
|
24
|
+
|
|
25
|
+
# Python environments and caches
|
|
26
|
+
.venv/
|
|
27
|
+
venv/
|
|
28
|
+
__pycache__/
|
|
29
|
+
*.py[cod]
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.ruff_cache/
|
|
32
|
+
.mypy_cache/
|
|
33
|
+
.pyright/
|
|
34
|
+
.coverage
|
|
35
|
+
htmlcov/
|
|
36
|
+
|
|
37
|
+
# Build and packaging output
|
|
38
|
+
build/
|
|
39
|
+
dist/
|
|
40
|
+
*.egg-info/
|
|
41
|
+
|
|
42
|
+
# Local tooling and OS files
|
|
43
|
+
.agents/
|
|
44
|
+
.codex/
|
|
45
|
+
.DS_Store
|
|
46
|
+
.idea/
|
|
47
|
+
.vscode/
|
|
48
|
+
*.log
|
|
49
|
+
tmp/
|
|
50
|
+
temp/
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased — 0.0.1
|
|
4
|
+
|
|
5
|
+
- Added the offline-composable adjusted ETF bars recipe with explicit factor
|
|
6
|
+
coverage policy, raw-value preservation, provenance, and validation.
|
|
7
|
+
|
|
8
|
+
- Added an injected-client Tushare adapter for trade calendar and fund endpoint
|
|
9
|
+
requests with explicit validation, retry, provenance, pagination, and empty
|
|
10
|
+
result policies. Tushare/Pandas remain optional.
|
|
11
|
+
|
|
12
|
+
- Added the offline-only `ohmydata` package scaffold and Python 3.11/3.12 CI.
|
|
13
|
+
- Added synthetic adjusted-ETF bar characterization tests and fixtures.
|
|
14
|
+
- Added the initial consumers' Tushare behavioral inventory and conflict log.
|
|
15
|
+
|
|
16
|
+
- Added provider-independent request identity, stable errors, classified retry,
|
|
17
|
+
rate limiting, provenance, and atomic snapshot/replay primitives.
|
|
18
|
+
- Added typed Tushare Phase 2b endpoints for fund dividends, fund portfolios,
|
|
19
|
+
daily basics, and index weights with bounded selectors, explicit fields,
|
|
20
|
+
provenance, duplicate/cap validation, and provider-native units.
|
ohmydata-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ohmydata
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Offline-first market-data SDK scaffold
|
|
5
|
+
Author: OMD contributors
|
|
6
|
+
Requires-Python: <3.13,>=3.11
|
|
7
|
+
Provides-Extra: tushare
|
|
8
|
+
Requires-Dist: pandas<3.0,>=2.0; extra == 'tushare'
|
|
9
|
+
Requires-Dist: tushare<2.0,>=1.4; extra == 'tushare'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Oh My Data (OMD)
|
|
13
|
+
|
|
14
|
+
`ohmydata` is a provisional, offline-first market-data SDK. Tushare endpoint
|
|
15
|
+
adapters accept an already initialized official-compatible client; credentials
|
|
16
|
+
are never loaded by this library.
|
|
17
|
+
|
|
18
|
+
## Supported Python and installation
|
|
19
|
+
|
|
20
|
+
Python 3.11 and 3.12 are supported (`>=3.11,<3.13`). From a source checkout:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync
|
|
24
|
+
uv run python -c "import ohmydata; print(ohmydata.__version__)"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The core has no runtime dependencies. Install `ohmydata[tushare]` for the
|
|
28
|
+
Pandas-backed adapter. Provider tests use fake clients and never call a network.
|
|
29
|
+
|
|
30
|
+
## Phase 2 Tushare adapter (offline and injected)
|
|
31
|
+
|
|
32
|
+
Pass an already initialized official-client-compatible object. The adapter
|
|
33
|
+
does not create clients or read credentials; this fake-client example is safe
|
|
34
|
+
to run offline:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import pandas as pd
|
|
38
|
+
from ohmydata.providers.tushare import EmptyPolicy, FundDailyRequest, TushareClient
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeClient:
|
|
42
|
+
def fund_daily(self, **kwargs):
|
|
43
|
+
return pd.DataFrame(
|
|
44
|
+
{
|
|
45
|
+
"ts_code": ["FAKE.ETF"],
|
|
46
|
+
"trade_date": ["20240102"],
|
|
47
|
+
"open": [1.0],
|
|
48
|
+
"high": [1.1],
|
|
49
|
+
"low": [0.9],
|
|
50
|
+
"close": [1.05],
|
|
51
|
+
"pre_close": [1.0],
|
|
52
|
+
"change": [0.05],
|
|
53
|
+
"pct_chg": [5.0],
|
|
54
|
+
"vol": [100],
|
|
55
|
+
"amount": [250.0],
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
request = FundDailyRequest(
|
|
61
|
+
empty_policy=EmptyPolicy.ERROR, ts_code="FAKE.ETF", start_date="20240101", end_date="20240102"
|
|
62
|
+
)
|
|
63
|
+
result = TushareClient(FakeClient()).fetch_fund_daily(request)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Values and nulls retain Tushare's native semantics: fund daily OHLC and
|
|
67
|
+
`change`/`pct_chg` are provider values, `vol` is in hands, and `amount` is in
|
|
68
|
+
thousand yuan. Empty responses must be selected explicitly with
|
|
69
|
+
`EmptyPolicy.ALLOW` or `EmptyPolicy.ERROR`.
|
|
70
|
+
`fund_share.fd_share` remains provider-native in ten-thousand shares (万份);
|
|
71
|
+
`fund_adj` and `fund_nav` values are likewise preserved without adjustment or
|
|
72
|
+
imputation.
|
|
73
|
+
|
|
74
|
+
### Adjusted ETF bars recipe
|
|
75
|
+
|
|
76
|
+
`fetch_adjusted_etf_bars` composes `fund_daily` with provider-native
|
|
77
|
+
`fund_adj` factors. Choose `AdjustmentCoveragePolicy.STRICT` (the default) or
|
|
78
|
+
`PRESERVE_MISSING_FACTOR`; raw OHLC and `adj_factor` remain available beside
|
|
79
|
+
the explicitly derived adjusted OHLC columns. The recipe is offline-testable
|
|
80
|
+
when supplied an injected `TushareClient` and does not claim point-in-time
|
|
81
|
+
availability.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from ohmydata.providers.tushare import (
|
|
85
|
+
AdjustmentCoveragePolicy,
|
|
86
|
+
AdjustedEtfBarsRequest,
|
|
87
|
+
EmptyPolicy,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
request = AdjustedEtfBarsRequest(
|
|
91
|
+
"FAKE.ETF",
|
|
92
|
+
EmptyPolicy.ERROR,
|
|
93
|
+
AdjustmentCoveragePolicy.STRICT,
|
|
94
|
+
start_date="20240101",
|
|
95
|
+
end_date="20240131",
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Local checks
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv lock
|
|
103
|
+
uv run pytest
|
|
104
|
+
uv run ruff check .
|
|
105
|
+
uv run ruff format --check .
|
|
106
|
+
uv run pyright
|
|
107
|
+
uv build
|
|
108
|
+
git diff --check
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Behavioral evidence for the initial consumers is in
|
|
112
|
+
[`docs/behavioral-inventory.md`](docs/behavioral-inventory.md). The adjusted
|
|
113
|
+
ETF characterization is test-only and uses synthetic JSON fixtures.
|
|
114
|
+
|
|
115
|
+
Phase 1 core is offline and explicit:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from datetime import UTC, datetime
|
|
119
|
+
from pathlib import Path
|
|
120
|
+
from ohmydata.core import RequestSpec, RetryPolicy, RateLimitPolicy, RateLimiter, execute_with_retry
|
|
121
|
+
from ohmydata.core import SnapshotMode, SnapshotStore
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
RequestSpec("demo", "bars", {"api_token": "never-serialize"})
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass
|
|
127
|
+
limiter = RateLimiter(RateLimitPolicy(0.1))
|
|
128
|
+
limiter.acquire()
|
|
129
|
+
result = execute_with_retry(lambda: "ok", RetryPolicy(max_attempts=1))
|
|
130
|
+
store = SnapshotStore(Path("snapshots"))
|
|
131
|
+
store.write(
|
|
132
|
+
RequestSpec("demo", "bars", {}), b"[]", datetime.now(UTC), "json-v1", SnapshotMode.APPEND
|
|
133
|
+
)
|
|
134
|
+
store.write(
|
|
135
|
+
RequestSpec("demo", "bars", {}), b"[]", datetime.now(UTC), "json-v1", SnapshotMode.FROZEN
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`RetryPolicy(max_attempts=3)` counts the first call. APPEND preserves distinct
|
|
140
|
+
observations; FROZEN permits one response identity. Limiter state is per instance.
|
|
141
|
+
|
|
142
|
+
## Phase 1 core (offline)
|
|
143
|
+
|
|
144
|
+
`ohmydata.core` provides canonical request identities, classified retry with
|
|
145
|
+
total-attempt semantics, explicit instance-scoped rate limiters, dataframe-free
|
|
146
|
+
provenance, and immutable APPEND/FROZEN snapshots. Request parameters reject
|
|
147
|
+
secret-bearing keys before serialization. Snapshot callers provide exact bytes;
|
|
148
|
+
the core never contacts providers or loads credentials.
|
|
149
|
+
|
|
150
|
+
## Phase 2b Tushare endpoints
|
|
151
|
+
|
|
152
|
+
The Tushare adapter exposes typed requests for fund dividends, fund portfolios,
|
|
153
|
+
daily basics, and index weights through injected clients. Requests always send
|
|
154
|
+
an explicit ordered field list and preserve provider-native values and missing
|
|
155
|
+
data. `fund_portfolio` requires a bounded report selector (`ann_date`, exact
|
|
156
|
+
`period`, or a same-year `start_date`/`end_date` range); unbounded holdings are
|
|
157
|
+
rejected. Native units remain unchanged: dividend cash is yuan per share,
|
|
158
|
+
portfolio market value is yuan and amount is shares, daily-basic share and
|
|
159
|
+
market-value fields use Tushare's ten-thousand units, and index weights remain
|
|
160
|
+
provider percentages.
|
ohmydata-0.0.1/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Oh My Data (OMD)
|
|
2
|
+
|
|
3
|
+
`ohmydata` is a provisional, offline-first market-data SDK. Tushare endpoint
|
|
4
|
+
adapters accept an already initialized official-compatible client; credentials
|
|
5
|
+
are never loaded by this library.
|
|
6
|
+
|
|
7
|
+
## Supported Python and installation
|
|
8
|
+
|
|
9
|
+
Python 3.11 and 3.12 are supported (`>=3.11,<3.13`). From a source checkout:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv sync
|
|
13
|
+
uv run python -c "import ohmydata; print(ohmydata.__version__)"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The core has no runtime dependencies. Install `ohmydata[tushare]` for the
|
|
17
|
+
Pandas-backed adapter. Provider tests use fake clients and never call a network.
|
|
18
|
+
|
|
19
|
+
## Phase 2 Tushare adapter (offline and injected)
|
|
20
|
+
|
|
21
|
+
Pass an already initialized official-client-compatible object. The adapter
|
|
22
|
+
does not create clients or read credentials; this fake-client example is safe
|
|
23
|
+
to run offline:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from ohmydata.providers.tushare import EmptyPolicy, FundDailyRequest, TushareClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeClient:
|
|
31
|
+
def fund_daily(self, **kwargs):
|
|
32
|
+
return pd.DataFrame(
|
|
33
|
+
{
|
|
34
|
+
"ts_code": ["FAKE.ETF"],
|
|
35
|
+
"trade_date": ["20240102"],
|
|
36
|
+
"open": [1.0],
|
|
37
|
+
"high": [1.1],
|
|
38
|
+
"low": [0.9],
|
|
39
|
+
"close": [1.05],
|
|
40
|
+
"pre_close": [1.0],
|
|
41
|
+
"change": [0.05],
|
|
42
|
+
"pct_chg": [5.0],
|
|
43
|
+
"vol": [100],
|
|
44
|
+
"amount": [250.0],
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
request = FundDailyRequest(
|
|
50
|
+
empty_policy=EmptyPolicy.ERROR, ts_code="FAKE.ETF", start_date="20240101", end_date="20240102"
|
|
51
|
+
)
|
|
52
|
+
result = TushareClient(FakeClient()).fetch_fund_daily(request)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Values and nulls retain Tushare's native semantics: fund daily OHLC and
|
|
56
|
+
`change`/`pct_chg` are provider values, `vol` is in hands, and `amount` is in
|
|
57
|
+
thousand yuan. Empty responses must be selected explicitly with
|
|
58
|
+
`EmptyPolicy.ALLOW` or `EmptyPolicy.ERROR`.
|
|
59
|
+
`fund_share.fd_share` remains provider-native in ten-thousand shares (万份);
|
|
60
|
+
`fund_adj` and `fund_nav` values are likewise preserved without adjustment or
|
|
61
|
+
imputation.
|
|
62
|
+
|
|
63
|
+
### Adjusted ETF bars recipe
|
|
64
|
+
|
|
65
|
+
`fetch_adjusted_etf_bars` composes `fund_daily` with provider-native
|
|
66
|
+
`fund_adj` factors. Choose `AdjustmentCoveragePolicy.STRICT` (the default) or
|
|
67
|
+
`PRESERVE_MISSING_FACTOR`; raw OHLC and `adj_factor` remain available beside
|
|
68
|
+
the explicitly derived adjusted OHLC columns. The recipe is offline-testable
|
|
69
|
+
when supplied an injected `TushareClient` and does not claim point-in-time
|
|
70
|
+
availability.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from ohmydata.providers.tushare import (
|
|
74
|
+
AdjustmentCoveragePolicy,
|
|
75
|
+
AdjustedEtfBarsRequest,
|
|
76
|
+
EmptyPolicy,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
request = AdjustedEtfBarsRequest(
|
|
80
|
+
"FAKE.ETF",
|
|
81
|
+
EmptyPolicy.ERROR,
|
|
82
|
+
AdjustmentCoveragePolicy.STRICT,
|
|
83
|
+
start_date="20240101",
|
|
84
|
+
end_date="20240131",
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Local checks
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv lock
|
|
92
|
+
uv run pytest
|
|
93
|
+
uv run ruff check .
|
|
94
|
+
uv run ruff format --check .
|
|
95
|
+
uv run pyright
|
|
96
|
+
uv build
|
|
97
|
+
git diff --check
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Behavioral evidence for the initial consumers is in
|
|
101
|
+
[`docs/behavioral-inventory.md`](docs/behavioral-inventory.md). The adjusted
|
|
102
|
+
ETF characterization is test-only and uses synthetic JSON fixtures.
|
|
103
|
+
|
|
104
|
+
Phase 1 core is offline and explicit:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from datetime import UTC, datetime
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
from ohmydata.core import RequestSpec, RetryPolicy, RateLimitPolicy, RateLimiter, execute_with_retry
|
|
110
|
+
from ohmydata.core import SnapshotMode, SnapshotStore
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
RequestSpec("demo", "bars", {"api_token": "never-serialize"})
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
limiter = RateLimiter(RateLimitPolicy(0.1))
|
|
117
|
+
limiter.acquire()
|
|
118
|
+
result = execute_with_retry(lambda: "ok", RetryPolicy(max_attempts=1))
|
|
119
|
+
store = SnapshotStore(Path("snapshots"))
|
|
120
|
+
store.write(
|
|
121
|
+
RequestSpec("demo", "bars", {}), b"[]", datetime.now(UTC), "json-v1", SnapshotMode.APPEND
|
|
122
|
+
)
|
|
123
|
+
store.write(
|
|
124
|
+
RequestSpec("demo", "bars", {}), b"[]", datetime.now(UTC), "json-v1", SnapshotMode.FROZEN
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`RetryPolicy(max_attempts=3)` counts the first call. APPEND preserves distinct
|
|
129
|
+
observations; FROZEN permits one response identity. Limiter state is per instance.
|
|
130
|
+
|
|
131
|
+
## Phase 1 core (offline)
|
|
132
|
+
|
|
133
|
+
`ohmydata.core` provides canonical request identities, classified retry with
|
|
134
|
+
total-attempt semantics, explicit instance-scoped rate limiters, dataframe-free
|
|
135
|
+
provenance, and immutable APPEND/FROZEN snapshots. Request parameters reject
|
|
136
|
+
secret-bearing keys before serialization. Snapshot callers provide exact bytes;
|
|
137
|
+
the core never contacts providers or loads credentials.
|
|
138
|
+
|
|
139
|
+
## Phase 2b Tushare endpoints
|
|
140
|
+
|
|
141
|
+
The Tushare adapter exposes typed requests for fund dividends, fund portfolios,
|
|
142
|
+
daily basics, and index weights through injected clients. Requests always send
|
|
143
|
+
an explicit ordered field list and preserve provider-native values and missing
|
|
144
|
+
data. `fund_portfolio` requires a bounded report selector (`ann_date`, exact
|
|
145
|
+
`period`, or a same-year `start_date`/`end_date` range); unbounded holdings are
|
|
146
|
+
rejected. Native units remain unchanged: dividend cash is yuan per share,
|
|
147
|
+
portfolio market value is yuan and amount is shares, daily-basic share and
|
|
148
|
+
market-value fields use Tushare's ten-thousand units, and index weights remain
|
|
149
|
+
provider percentages.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ohmydata"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Offline-first market-data SDK scaffold"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11,<3.13"
|
|
11
|
+
authors = [{ name = "OMD contributors" }]
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
tushare = ["pandas>=2.0,<3.0", "tushare>=1.4,<2.0"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pandas>=2.0",
|
|
20
|
+
"pandas-stubs>=2.2",
|
|
21
|
+
"pyright>=1.1.390",
|
|
22
|
+
"pytest>=8.3",
|
|
23
|
+
"ruff>=0.8",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/ohmydata"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = [
|
|
31
|
+
"src/ohmydata",
|
|
32
|
+
"README.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"pyproject.toml",
|
|
35
|
+
"uv.lock",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = ["--import-mode=importlib"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py311"
|
|
45
|
+
|
|
46
|
+
[tool.pyright]
|
|
47
|
+
include = ["src", "tests"]
|
|
48
|
+
pythonVersion = "3.11"
|
|
49
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .errors import (
|
|
2
|
+
AuthenticationError,
|
|
3
|
+
CoverageError,
|
|
4
|
+
EmptyResponseError,
|
|
5
|
+
OhMyDataError,
|
|
6
|
+
PaginationError,
|
|
7
|
+
PermanentProviderError,
|
|
8
|
+
PermissionDeniedError,
|
|
9
|
+
ProviderError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
RetryExhaustedError,
|
|
12
|
+
SchemaMismatchError,
|
|
13
|
+
SnapshotConflictError,
|
|
14
|
+
SnapshotIntegrityError,
|
|
15
|
+
TransientProviderError,
|
|
16
|
+
)
|
|
17
|
+
from .policy import AttemptRecord, RetryPolicy, RetryResult, execute_with_retry
|
|
18
|
+
from .provenance import EmptyDisposition, FetchProvenance
|
|
19
|
+
from .rate_limit import RateLimitDecision, RateLimiter, RateLimitPolicy
|
|
20
|
+
from .snapshot import SnapshotMode, SnapshotRef, SnapshotReplay, SnapshotStore
|
|
21
|
+
from .specs import RequestSpec
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AttemptRecord",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"CoverageError",
|
|
27
|
+
"EmptyDisposition",
|
|
28
|
+
"EmptyResponseError",
|
|
29
|
+
"FetchProvenance",
|
|
30
|
+
"OhMyDataError",
|
|
31
|
+
"PaginationError",
|
|
32
|
+
"PermanentProviderError",
|
|
33
|
+
"PermissionDeniedError",
|
|
34
|
+
"ProviderError",
|
|
35
|
+
"RateLimitDecision",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"RateLimitPolicy",
|
|
38
|
+
"RateLimiter",
|
|
39
|
+
"RequestSpec",
|
|
40
|
+
"RetryExhaustedError",
|
|
41
|
+
"RetryPolicy",
|
|
42
|
+
"RetryResult",
|
|
43
|
+
"SchemaMismatchError",
|
|
44
|
+
"SnapshotConflictError",
|
|
45
|
+
"SnapshotIntegrityError",
|
|
46
|
+
"SnapshotMode",
|
|
47
|
+
"SnapshotRef",
|
|
48
|
+
"SnapshotReplay",
|
|
49
|
+
"SnapshotStore",
|
|
50
|
+
"TransientProviderError",
|
|
51
|
+
"execute_with_retry",
|
|
52
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OhMyDataError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderError(OhMyDataError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PermanentProviderError(ProviderError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(PermanentProviderError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PermissionDeniedError(PermanentProviderError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmptyResponseError(PermanentProviderError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SchemaMismatchError(PermanentProviderError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PaginationError(PermanentProviderError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TransientProviderError(ProviderError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RateLimitError(TransientProviderError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SnapshotIntegrityError(OhMyDataError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SnapshotConflictError(SnapshotIntegrityError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CoverageError(OhMyDataError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AttemptRecord:
|
|
58
|
+
attempt: int
|
|
59
|
+
exception_type: str | None
|
|
60
|
+
retry_delay_seconds: float | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RetryExhaustedError(TransientProviderError):
|
|
64
|
+
def __init__(self, attempts: tuple[AttemptRecord, ...]):
|
|
65
|
+
self.attempts = attempts
|
|
66
|
+
super().__init__(f"retry exhausted after {len(attempts)} attempts")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from .errors import AttemptRecord, RetryExhaustedError, TransientProviderError
|
|
9
|
+
|
|
10
|
+
__all__ = ["AttemptRecord", "RetryPolicy", "RetryResult", "execute_with_retry"]
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _finite_number(value: object) -> bool:
|
|
16
|
+
return (
|
|
17
|
+
not isinstance(value, bool)
|
|
18
|
+
and isinstance(value, (int, float))
|
|
19
|
+
and math.isfinite(float(value))
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _valid_attempts(value: object) -> bool:
|
|
24
|
+
return type(value) is int and value >= 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RetryPolicy:
|
|
29
|
+
max_attempts: int = 3
|
|
30
|
+
base_delay_seconds: float = 1.0
|
|
31
|
+
backoff_multiplier: float = 2.0
|
|
32
|
+
max_delay_seconds: float = 60.0
|
|
33
|
+
jitter_ratio: float = 0.0
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
if (
|
|
37
|
+
not _valid_attempts(self.max_attempts)
|
|
38
|
+
or any(
|
|
39
|
+
not _finite_number(x)
|
|
40
|
+
for x in (
|
|
41
|
+
self.base_delay_seconds,
|
|
42
|
+
self.backoff_multiplier,
|
|
43
|
+
self.max_delay_seconds,
|
|
44
|
+
self.jitter_ratio,
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
or self.max_attempts < 1
|
|
48
|
+
or self.base_delay_seconds < 0
|
|
49
|
+
or self.backoff_multiplier < 1
|
|
50
|
+
or self.max_delay_seconds < 0
|
|
51
|
+
or not 0 <= self.jitter_ratio <= 1
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("invalid retry policy")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RetryResult(Generic[T]):
|
|
58
|
+
value: T
|
|
59
|
+
attempts: tuple[AttemptRecord, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def execute_with_retry(
|
|
63
|
+
fn: Callable[[], T],
|
|
64
|
+
policy: RetryPolicy | None = None,
|
|
65
|
+
*,
|
|
66
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
67
|
+
random_value: Callable[[], float] = random.random,
|
|
68
|
+
classifier: Callable[[Exception], bool] = lambda e: isinstance(e, TransientProviderError),
|
|
69
|
+
) -> RetryResult[T]:
|
|
70
|
+
policy = policy or RetryPolicy()
|
|
71
|
+
records: list[AttemptRecord] = []
|
|
72
|
+
for i in range(policy.max_attempts):
|
|
73
|
+
try:
|
|
74
|
+
value = fn()
|
|
75
|
+
records.append(AttemptRecord(i + 1, None, None))
|
|
76
|
+
return RetryResult(value, tuple(records))
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
if not isinstance(exc, TransientProviderError) or not classifier(exc):
|
|
79
|
+
raise
|
|
80
|
+
delay = None
|
|
81
|
+
if i + 1 < policy.max_attempts:
|
|
82
|
+
r = random_value()
|
|
83
|
+
if not 0 <= r <= 1:
|
|
84
|
+
raise ValueError("random value out of range")
|
|
85
|
+
bounded = min(
|
|
86
|
+
policy.max_delay_seconds,
|
|
87
|
+
policy.base_delay_seconds * policy.backoff_multiplier**i,
|
|
88
|
+
)
|
|
89
|
+
delay = bounded * (1 + policy.jitter_ratio * (2 * r - 1))
|
|
90
|
+
sleep(delay)
|
|
91
|
+
records.append(AttemptRecord(i + 1, type(exc).__name__, delay))
|
|
92
|
+
if i + 1 == policy.max_attempts:
|
|
93
|
+
err = RetryExhaustedError(
|
|
94
|
+
tuple(
|
|
95
|
+
AttemptRecord(x.attempt, x.exception_type, x.retry_delay_seconds)
|
|
96
|
+
for x in records
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
err.__cause__ = exc
|
|
100
|
+
raise err from exc
|
|
101
|
+
raise AssertionError
|