ohmydata 0.0.1__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.
- ohmydata/__init__.py +5 -0
- ohmydata/core/__init__.py +52 -0
- ohmydata/core/errors.py +66 -0
- ohmydata/core/policy.py +101 -0
- ohmydata/core/provenance.py +93 -0
- ohmydata/core/rate_limit.py +53 -0
- ohmydata/core/snapshot.py +292 -0
- ohmydata/core/specs.py +100 -0
- ohmydata/providers/__init__.py +5 -0
- ohmydata/providers/tushare/__init__.py +44 -0
- ohmydata/providers/tushare/client.py +265 -0
- ohmydata/providers/tushare/endpoints.py +544 -0
- ohmydata/providers/tushare/errors.py +36 -0
- ohmydata/providers/tushare/recipes/__init__.py +15 -0
- ohmydata/providers/tushare/recipes/etf_adjusted_bars.py +177 -0
- ohmydata-0.0.1.dist-info/METADATA +160 -0
- ohmydata-0.0.1.dist-info/RECORD +18 -0
- ohmydata-0.0.1.dist-info/WHEEL +4 -0
ohmydata/__init__.py
ADDED
|
@@ -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
|
+
]
|
ohmydata/core/errors.py
ADDED
|
@@ -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")
|
ohmydata/core/policy.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from .policy import AttemptRecord
|
|
8
|
+
from .specs import RequestSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmptyDisposition(str, Enum):
|
|
12
|
+
NOT_EMPTY = "NOT_EMPTY"
|
|
13
|
+
ALLOWED_EMPTY = "ALLOWED_EMPTY"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FetchProvenance:
|
|
18
|
+
provider: str
|
|
19
|
+
endpoint: str
|
|
20
|
+
request_identity: str
|
|
21
|
+
effective_parameters: dict[str, Any]
|
|
22
|
+
requested_fields: tuple[str, ...]
|
|
23
|
+
retrieved_at: datetime
|
|
24
|
+
attempts: tuple[AttemptRecord, ...]
|
|
25
|
+
row_count: int
|
|
26
|
+
columns: tuple[str, ...]
|
|
27
|
+
warnings: tuple[str, ...]
|
|
28
|
+
snapshot_identities: tuple[str, ...]
|
|
29
|
+
empty_disposition: EmptyDisposition
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_request(cls, spec: RequestSpec, **kwargs: Any) -> "FetchProvenance":
|
|
33
|
+
return cls(
|
|
34
|
+
spec.provider,
|
|
35
|
+
spec.endpoint,
|
|
36
|
+
spec.request_identity,
|
|
37
|
+
spec.effective_parameters,
|
|
38
|
+
tuple(spec.fields),
|
|
39
|
+
**kwargs,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if (
|
|
44
|
+
self.retrieved_at.tzinfo is None
|
|
45
|
+
or self.row_count < 0
|
|
46
|
+
or len(set(self.columns)) != len(self.columns)
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("invalid provenance")
|
|
49
|
+
if (self.row_count == 0) != (self.empty_disposition == EmptyDisposition.ALLOWED_EMPTY):
|
|
50
|
+
raise ValueError("empty disposition mismatch")
|
|
51
|
+
object.__setattr__(self, "retrieved_at", self.retrieved_at.astimezone(UTC))
|
|
52
|
+
for name in ("attempts", "requested_fields", "columns", "warnings", "snapshot_identities"):
|
|
53
|
+
object.__setattr__(self, name, tuple(getattr(self, name)))
|
|
54
|
+
object.__setattr__(self, "effective_parameters", self._freeze(self.effective_parameters))
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _freeze(value: Any) -> Any:
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
source = cast(dict[str, Any], value)
|
|
60
|
+
return MappingProxyType({k: FetchProvenance._freeze(v) for k, v in source.items()})
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
return tuple(FetchProvenance._freeze(v) for v in cast(list[Any], value))
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def attempt_count(self) -> int:
|
|
67
|
+
return len(self.attempts)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"provider": self.provider,
|
|
72
|
+
"endpoint": self.endpoint,
|
|
73
|
+
"request_identity": self.request_identity,
|
|
74
|
+
"effective_parameters": self._thaw(self.effective_parameters),
|
|
75
|
+
"requested_fields": list(self.requested_fields),
|
|
76
|
+
"retrieved_at": self.retrieved_at.astimezone(UTC).isoformat(),
|
|
77
|
+
"attempt_count": self.attempt_count,
|
|
78
|
+
"attempts": [a.__dict__ for a in self.attempts],
|
|
79
|
+
"row_count": self.row_count,
|
|
80
|
+
"columns": list(self.columns),
|
|
81
|
+
"warnings": list(self.warnings),
|
|
82
|
+
"snapshot_identities": list(self.snapshot_identities),
|
|
83
|
+
"empty_disposition": self.empty_disposition.value,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _thaw(value: Any) -> Any:
|
|
88
|
+
if isinstance(value, MappingProxyType):
|
|
89
|
+
source = cast(dict[str, Any], value)
|
|
90
|
+
return {k: FetchProvenance._thaw(v) for k, v in source.items()}
|
|
91
|
+
if isinstance(value, tuple):
|
|
92
|
+
return [FetchProvenance._thaw(v) for v in cast(tuple[Any, ...], value)]
|
|
93
|
+
return value
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RateLimitPolicy:
|
|
10
|
+
min_interval_seconds: float
|
|
11
|
+
|
|
12
|
+
def __post_init__(self):
|
|
13
|
+
if (
|
|
14
|
+
isinstance(self.min_interval_seconds, bool)
|
|
15
|
+
or not math.isfinite(float(self.min_interval_seconds))
|
|
16
|
+
or self.min_interval_seconds < 0
|
|
17
|
+
):
|
|
18
|
+
raise ValueError("negative interval")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RateLimitDecision:
|
|
23
|
+
waited_seconds: float
|
|
24
|
+
acquired_at: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimiter:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
policy: RateLimitPolicy,
|
|
31
|
+
clock: Callable[[], float] = time.monotonic,
|
|
32
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
33
|
+
):
|
|
34
|
+
self.policy, self.clock, self.sleep, self._last = policy, clock, sleep, None
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def acquire(self) -> RateLimitDecision:
|
|
38
|
+
with self._lock:
|
|
39
|
+
now = self.clock()
|
|
40
|
+
if not math.isfinite(now):
|
|
41
|
+
raise ValueError("clock must return finite value")
|
|
42
|
+
wait = (
|
|
43
|
+
0
|
|
44
|
+
if self._last is None
|
|
45
|
+
else max(0, self.policy.min_interval_seconds - (now - self._last))
|
|
46
|
+
)
|
|
47
|
+
if wait:
|
|
48
|
+
self.sleep(wait)
|
|
49
|
+
now = self.clock()
|
|
50
|
+
if not math.isfinite(now):
|
|
51
|
+
raise ValueError("clock must return finite value")
|
|
52
|
+
self._last = now
|
|
53
|
+
return RateLimitDecision(wait, now)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from .errors import SnapshotConflictError, SnapshotIntegrityError
|
|
15
|
+
from .specs import RequestSpec
|
|
16
|
+
|
|
17
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
18
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
_MANIFEST_KEYS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"manifest_schema_version",
|
|
22
|
+
"provider",
|
|
23
|
+
"endpoint",
|
|
24
|
+
"canonical_request",
|
|
25
|
+
"request_identity",
|
|
26
|
+
"response_sha256",
|
|
27
|
+
"response_byte_size",
|
|
28
|
+
"serialization_identifier",
|
|
29
|
+
"retrieved_at",
|
|
30
|
+
"snapshot_identity",
|
|
31
|
+
"mode",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SnapshotMode(str, Enum):
|
|
37
|
+
APPEND = "append"
|
|
38
|
+
FROZEN = "frozen"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SnapshotRef:
|
|
43
|
+
path: Path
|
|
44
|
+
snapshot_identity: str
|
|
45
|
+
mode: SnapshotMode
|
|
46
|
+
provider: str
|
|
47
|
+
endpoint: str
|
|
48
|
+
request_identity: str
|
|
49
|
+
response_sha256: str
|
|
50
|
+
serialization_identifier: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SnapshotReplay:
|
|
55
|
+
payload: bytes
|
|
56
|
+
manifest: dict[str, Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class _ValidatedSnapshot:
|
|
61
|
+
ref: SnapshotRef
|
|
62
|
+
replay: SnapshotReplay
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe(value: Any) -> bool:
|
|
66
|
+
return (
|
|
67
|
+
isinstance(value, str)
|
|
68
|
+
and value not in {".", ".."}
|
|
69
|
+
and _IDENTIFIER.fullmatch(value) is not None
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _identity(
|
|
74
|
+
request_identity: str, response_sha256: str, serialization: str, mode: SnapshotMode
|
|
75
|
+
) -> str:
|
|
76
|
+
return hashlib.sha256(
|
|
77
|
+
(request_identity + response_sha256 + serialization + mode.value).encode()
|
|
78
|
+
).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SnapshotStore:
|
|
82
|
+
def __init__(self, root: Path):
|
|
83
|
+
self.root = Path(os.path.abspath(root))
|
|
84
|
+
|
|
85
|
+
def _final_path(
|
|
86
|
+
self,
|
|
87
|
+
provider: str,
|
|
88
|
+
endpoint: str,
|
|
89
|
+
request_identity: str,
|
|
90
|
+
mode: SnapshotMode,
|
|
91
|
+
serialization: str,
|
|
92
|
+
response_sha256: str,
|
|
93
|
+
) -> Path:
|
|
94
|
+
base = self.root / provider / endpoint / request_identity / mode.value
|
|
95
|
+
if mode is SnapshotMode.APPEND:
|
|
96
|
+
return (
|
|
97
|
+
base / hashlib.sha256(serialization.encode("utf-8")).hexdigest() / response_sha256
|
|
98
|
+
)
|
|
99
|
+
return base
|
|
100
|
+
|
|
101
|
+
def _read_validated(self, path: Path) -> _ValidatedSnapshot:
|
|
102
|
+
path = Path(os.path.abspath(path))
|
|
103
|
+
try:
|
|
104
|
+
manifest = json.loads(
|
|
105
|
+
(path / "manifest.json").read_text(encoding="utf-8"),
|
|
106
|
+
parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)),
|
|
107
|
+
)
|
|
108
|
+
payload = (path / "response.bin").read_bytes()
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
raise SnapshotIntegrityError("invalid snapshot files") from exc
|
|
111
|
+
if not isinstance(manifest, dict):
|
|
112
|
+
raise SnapshotIntegrityError("manifest fields mismatch")
|
|
113
|
+
manifest = cast(dict[str, Any], manifest)
|
|
114
|
+
if frozenset(manifest) != _MANIFEST_KEYS:
|
|
115
|
+
raise SnapshotIntegrityError("manifest fields mismatch")
|
|
116
|
+
if (
|
|
117
|
+
type(manifest["manifest_schema_version"]) is not int
|
|
118
|
+
or manifest["manifest_schema_version"] != 1
|
|
119
|
+
):
|
|
120
|
+
raise SnapshotIntegrityError("manifest schema mismatch")
|
|
121
|
+
provider, endpoint, serialization = (
|
|
122
|
+
manifest["provider"],
|
|
123
|
+
manifest["endpoint"],
|
|
124
|
+
manifest["serialization_identifier"],
|
|
125
|
+
)
|
|
126
|
+
if not _safe(provider) or not _safe(endpoint) or not _safe(serialization):
|
|
127
|
+
raise SnapshotIntegrityError("unsafe identifier")
|
|
128
|
+
mode_raw = manifest["mode"]
|
|
129
|
+
if not isinstance(mode_raw, str) or mode_raw not in {m.value for m in SnapshotMode}:
|
|
130
|
+
raise SnapshotIntegrityError("mode mismatch")
|
|
131
|
+
mode = SnapshotMode(mode_raw)
|
|
132
|
+
request_identity = manifest["request_identity"]
|
|
133
|
+
response_sha = manifest["response_sha256"]
|
|
134
|
+
snapshot_identity = manifest["snapshot_identity"]
|
|
135
|
+
if not all(
|
|
136
|
+
isinstance(x, str) and _HEX64.fullmatch(x)
|
|
137
|
+
for x in (request_identity, response_sha, snapshot_identity)
|
|
138
|
+
):
|
|
139
|
+
raise SnapshotIntegrityError("identity mismatch")
|
|
140
|
+
canonical = manifest["canonical_request"]
|
|
141
|
+
if not isinstance(canonical, dict):
|
|
142
|
+
raise SnapshotIntegrityError("canonical request mismatch")
|
|
143
|
+
canonical = cast(dict[str, Any], canonical)
|
|
144
|
+
if set(canonical) != {
|
|
145
|
+
"provider",
|
|
146
|
+
"endpoint",
|
|
147
|
+
"parameters",
|
|
148
|
+
"fields",
|
|
149
|
+
}:
|
|
150
|
+
raise SnapshotIntegrityError("canonical request mismatch")
|
|
151
|
+
if canonical["provider"] != provider or canonical["endpoint"] != endpoint:
|
|
152
|
+
raise SnapshotIntegrityError("canonical request mismatch")
|
|
153
|
+
if not isinstance(canonical["parameters"], dict) or not isinstance(
|
|
154
|
+
canonical["fields"], list
|
|
155
|
+
):
|
|
156
|
+
raise SnapshotIntegrityError("canonical request mismatch")
|
|
157
|
+
try:
|
|
158
|
+
canonical_bytes = json.dumps(
|
|
159
|
+
canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
160
|
+
).encode("utf-8")
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
raise SnapshotIntegrityError("canonical request mismatch") from exc
|
|
163
|
+
if hashlib.sha256(canonical_bytes).hexdigest() != request_identity:
|
|
164
|
+
raise SnapshotIntegrityError("canonical request mismatch")
|
|
165
|
+
if type(manifest["response_byte_size"]) is not int or manifest["response_byte_size"] != len(
|
|
166
|
+
payload
|
|
167
|
+
):
|
|
168
|
+
raise SnapshotIntegrityError("response size mismatch")
|
|
169
|
+
if hashlib.sha256(payload).hexdigest() != response_sha:
|
|
170
|
+
raise SnapshotIntegrityError("response hash mismatch")
|
|
171
|
+
if not isinstance(manifest["retrieved_at"], str):
|
|
172
|
+
raise SnapshotIntegrityError("timestamp mismatch")
|
|
173
|
+
try:
|
|
174
|
+
timestamp = datetime.fromisoformat(manifest["retrieved_at"])
|
|
175
|
+
if timestamp.tzinfo is None or timestamp.utcoffset() != UTC.utcoffset(None):
|
|
176
|
+
raise ValueError
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
raise SnapshotIntegrityError("timestamp mismatch") from exc
|
|
179
|
+
if _identity(request_identity, response_sha, serialization, mode) != snapshot_identity:
|
|
180
|
+
raise SnapshotIntegrityError("snapshot identity mismatch")
|
|
181
|
+
expected_path = self._final_path(
|
|
182
|
+
provider, endpoint, request_identity, mode, serialization, response_sha
|
|
183
|
+
)
|
|
184
|
+
if path != expected_path:
|
|
185
|
+
raise SnapshotIntegrityError("snapshot path mismatch")
|
|
186
|
+
ref = SnapshotRef(
|
|
187
|
+
path,
|
|
188
|
+
snapshot_identity,
|
|
189
|
+
mode,
|
|
190
|
+
provider,
|
|
191
|
+
endpoint,
|
|
192
|
+
request_identity,
|
|
193
|
+
response_sha,
|
|
194
|
+
serialization,
|
|
195
|
+
)
|
|
196
|
+
return _ValidatedSnapshot(ref, SnapshotReplay(payload, dict(manifest)))
|
|
197
|
+
|
|
198
|
+
def _resolve_existing(self, final: Path, desired: SnapshotRef) -> SnapshotRef:
|
|
199
|
+
winner = self._read_validated(final).ref
|
|
200
|
+
same_request = (winner.provider, winner.endpoint, winner.request_identity, winner.mode) == (
|
|
201
|
+
desired.provider,
|
|
202
|
+
desired.endpoint,
|
|
203
|
+
desired.request_identity,
|
|
204
|
+
desired.mode,
|
|
205
|
+
)
|
|
206
|
+
same_content = (
|
|
207
|
+
winner.response_sha256 == desired.response_sha256
|
|
208
|
+
and winner.serialization_identifier == desired.serialization_identifier
|
|
209
|
+
)
|
|
210
|
+
if same_request and same_content:
|
|
211
|
+
return winner
|
|
212
|
+
if desired.mode is SnapshotMode.FROZEN:
|
|
213
|
+
raise SnapshotConflictError("frozen snapshot conflict")
|
|
214
|
+
raise SnapshotIntegrityError("append snapshot collision")
|
|
215
|
+
|
|
216
|
+
def write(
|
|
217
|
+
self,
|
|
218
|
+
spec: RequestSpec,
|
|
219
|
+
payload: bytes,
|
|
220
|
+
retrieved_at: datetime,
|
|
221
|
+
serialization: str,
|
|
222
|
+
mode: SnapshotMode = SnapshotMode.APPEND,
|
|
223
|
+
) -> SnapshotRef:
|
|
224
|
+
if (
|
|
225
|
+
type(payload) is not bytes
|
|
226
|
+
or type(retrieved_at) is not datetime
|
|
227
|
+
or retrieved_at.tzinfo is None
|
|
228
|
+
or retrieved_at.utcoffset() is None
|
|
229
|
+
):
|
|
230
|
+
raise TypeError("invalid snapshot input")
|
|
231
|
+
if not _safe(serialization):
|
|
232
|
+
raise ValueError("invalid snapshot input")
|
|
233
|
+
if type(mode) is not SnapshotMode:
|
|
234
|
+
raise TypeError("invalid snapshot mode")
|
|
235
|
+
response_sha = hashlib.sha256(payload).hexdigest()
|
|
236
|
+
final = self._final_path(
|
|
237
|
+
spec.provider, spec.endpoint, spec.request_identity, mode, serialization, response_sha
|
|
238
|
+
)
|
|
239
|
+
desired = SnapshotRef(
|
|
240
|
+
final,
|
|
241
|
+
_identity(spec.request_identity, response_sha, serialization, mode),
|
|
242
|
+
mode,
|
|
243
|
+
spec.provider,
|
|
244
|
+
spec.endpoint,
|
|
245
|
+
spec.request_identity,
|
|
246
|
+
response_sha,
|
|
247
|
+
serialization,
|
|
248
|
+
)
|
|
249
|
+
if final.exists():
|
|
250
|
+
return self._resolve_existing(final, desired)
|
|
251
|
+
manifest = {
|
|
252
|
+
"manifest_schema_version": 1,
|
|
253
|
+
"provider": spec.provider,
|
|
254
|
+
"endpoint": spec.endpoint,
|
|
255
|
+
"canonical_request": spec.canonical_payload,
|
|
256
|
+
"request_identity": spec.request_identity,
|
|
257
|
+
"response_sha256": response_sha,
|
|
258
|
+
"response_byte_size": len(payload),
|
|
259
|
+
"serialization_identifier": serialization,
|
|
260
|
+
"retrieved_at": retrieved_at.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
|
261
|
+
"snapshot_identity": desired.snapshot_identity,
|
|
262
|
+
"mode": mode.value,
|
|
263
|
+
}
|
|
264
|
+
final.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
tmp = Path(tempfile.mkdtemp(prefix=".tmp-", dir=final.parent))
|
|
266
|
+
try:
|
|
267
|
+
(tmp / "response.bin").write_bytes(payload)
|
|
268
|
+
(tmp / "manifest.json").write_text(
|
|
269
|
+
json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
|
|
270
|
+
encoding="utf-8",
|
|
271
|
+
)
|
|
272
|
+
try:
|
|
273
|
+
os.rename(tmp, final)
|
|
274
|
+
except OSError:
|
|
275
|
+
if final.exists():
|
|
276
|
+
return self._resolve_existing(final, desired)
|
|
277
|
+
raise
|
|
278
|
+
return self._read_validated(final).ref
|
|
279
|
+
finally:
|
|
280
|
+
if tmp.exists():
|
|
281
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
282
|
+
|
|
283
|
+
def replay(self, ref: SnapshotRef, expected: RequestSpec | None = None) -> SnapshotReplay:
|
|
284
|
+
validated = self._read_validated(ref.path)
|
|
285
|
+
if validated.ref != ref:
|
|
286
|
+
raise SnapshotIntegrityError("snapshot reference mismatch")
|
|
287
|
+
if expected is not None and (
|
|
288
|
+
validated.ref.request_identity != expected.request_identity
|
|
289
|
+
or validated.replay.manifest["canonical_request"] != expected.canonical_payload
|
|
290
|
+
):
|
|
291
|
+
raise SnapshotIntegrityError("request mismatch")
|
|
292
|
+
return SnapshotReplay(validated.replay.payload, deepcopy(validated.replay.manifest))
|