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.
@@ -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.
@@ -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.
@@ -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,5 @@
1
+ """Provisional offline-only Oh My Data package scaffold."""
2
+
3
+ __version__ = "0.0.0"
4
+
5
+ __all__ = ["__version__"]
@@ -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