pfmsoft-api-request 0.1.3__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.
- pfmsoft/api_request/__init__.py +37 -0
- pfmsoft/api_request/cache/__init__.py +36 -0
- pfmsoft/api_request/cache/memory_cache.py +177 -0
- pfmsoft/api_request/cache/metadata_helpers.py +55 -0
- pfmsoft/api_request/cache/models.py +83 -0
- pfmsoft/api_request/cache/protocols.py +166 -0
- pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
- pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
- pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
- pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
- pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
- pfmsoft/api_request/cli/__init__.py +17 -0
- pfmsoft/api_request/cli/cache/__init__.py +12 -0
- pfmsoft/api_request/cli/cache/info.py +11 -0
- pfmsoft/api_request/cli/helpers.py +41 -0
- pfmsoft/api_request/cli/main_typer.py +52 -0
- pfmsoft/api_request/cli/request/__init__.py +11 -0
- pfmsoft/api_request/cli/request/request.py +222 -0
- pfmsoft/api_request/cli/request/validate.py +11 -0
- pfmsoft/api_request/helpers/http_session_factory.py +80 -0
- pfmsoft/api_request/helpers/json_io.py +256 -0
- pfmsoft/api_request/helpers/save_text_file.py +43 -0
- pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
- pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
- pfmsoft/api_request/logging_config.py +202 -0
- pfmsoft/api_request/py.typed +0 -0
- pfmsoft/api_request/rate_limit/__init__.py +24 -0
- pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
- pfmsoft/api_request/rate_limit/protocols.py +101 -0
- pfmsoft/api_request/request/__init__.py +11 -0
- pfmsoft/api_request/request/api_requester.py +681 -0
- pfmsoft/api_request/request/intermediate_models.py +143 -0
- pfmsoft/api_request/request/models.py +355 -0
- pfmsoft/api_request/request/protocols.py +54 -0
- pfmsoft/api_request/settings.py +65 -0
- pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
- pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
- pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
- pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
- pfmsoft_api_request-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""api-request: A command line first interface to the Eve Online API."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
__project_namespace__ = "pfmsoft"
|
|
6
|
+
__author__ = "Chad Lowe"
|
|
7
|
+
__email__ = "pfmsoft.dev@gmail.com"
|
|
8
|
+
__app_name__ = "pfmsoft-api-request"
|
|
9
|
+
"""Name of this module is defined here for consitency, used in determining the app_dir
|
|
10
|
+
and other paths."""
|
|
11
|
+
__description__ = "A command line first interface to the Eve Online API"
|
|
12
|
+
__version__ = version(__app_name__)
|
|
13
|
+
__release__ = __version__
|
|
14
|
+
__url__ = "https://github.com/DonalChilde/pfmsoft-api-request"
|
|
15
|
+
__license__ = "MIT"
|
|
16
|
+
|
|
17
|
+
from .request.api_requester import ApiRequester
|
|
18
|
+
from .request.models import (
|
|
19
|
+
FailedResponse,
|
|
20
|
+
Request,
|
|
21
|
+
Requests,
|
|
22
|
+
Response,
|
|
23
|
+
ResponseMetadata,
|
|
24
|
+
Responses,
|
|
25
|
+
Source,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ApiRequester",
|
|
30
|
+
"FailedResponse",
|
|
31
|
+
"Request",
|
|
32
|
+
"Requests",
|
|
33
|
+
"Response",
|
|
34
|
+
"ResponseMetadata",
|
|
35
|
+
"Responses",
|
|
36
|
+
"Source",
|
|
37
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Cache implementations and helpers for request orchestration.
|
|
2
|
+
|
|
3
|
+
Exports:
|
|
4
|
+
- `InMemoryCache` and `InMemoryCacheFactory` for ephemeral, process-local
|
|
5
|
+
caching.
|
|
6
|
+
- `SqliteCache` and `SqliteCacheFactory` for persistent SQLite-backed
|
|
7
|
+
caching.
|
|
8
|
+
- `merge_cached_revalidation_metadata` for `304 Not Modified`
|
|
9
|
+
revalidation merge semantics.
|
|
10
|
+
|
|
11
|
+
Typical usage:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from api_request import ApiRequester
|
|
15
|
+
from api_request.cache import SqliteCacheFactory
|
|
16
|
+
from api_request.rate_limit import AiolimiterRateLimiterFactory
|
|
17
|
+
|
|
18
|
+
async with ApiRequester(
|
|
19
|
+
cache_factory=SqliteCacheFactory("./.cache/api-request.sqlite3"),
|
|
20
|
+
rate_limiter_factory=AiolimiterRateLimiterFactory(max_rate=100.0),
|
|
21
|
+
) as requester:
|
|
22
|
+
...
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .memory_cache import InMemoryCache, InMemoryCacheFactory
|
|
27
|
+
from .metadata_helpers import merge_cached_revalidation_metadata
|
|
28
|
+
from .sqlite_cache.sqlite_cache import SqliteCache, SqliteCacheFactory
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"InMemoryCache",
|
|
32
|
+
"merge_cached_revalidation_metadata",
|
|
33
|
+
"SqliteCache",
|
|
34
|
+
"InMemoryCacheFactory",
|
|
35
|
+
"SqliteCacheFactory",
|
|
36
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""In-memory cache implementations for API requests.
|
|
2
|
+
|
|
3
|
+
These caches are primarily intended for tests and local development where a
|
|
4
|
+
persistent backing store is unnecessary.
|
|
5
|
+
|
|
6
|
+
Behavior summary:
|
|
7
|
+
- Storage is process-local and non-durable.
|
|
8
|
+
- `set` performs upsert semantics.
|
|
9
|
+
- `update_304` preserves cached body text and merges metadata.
|
|
10
|
+
- `flush` is a no-op.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Self
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from whenever import Instant
|
|
18
|
+
|
|
19
|
+
from ..request.models import ResponseMetadata
|
|
20
|
+
from .metadata_helpers import merge_cached_revalidation_metadata
|
|
21
|
+
from .models import CachedResponse, CacheInfo
|
|
22
|
+
from .protocols import CacheFactoryProtocol, CacheProtocol
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InMemoryCache(CacheProtocol):
|
|
26
|
+
"""Dictionary-backed cache implementation.
|
|
27
|
+
|
|
28
|
+
The cache stores `CachedResponse` objects in memory and performs no I/O.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
"""Initialize an empty in-memory cache."""
|
|
33
|
+
self._entries: dict[UUID, CachedResponse] = {}
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> Self:
|
|
36
|
+
"""Enter the asynchronous context manager."""
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
async def __aexit__(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException] | None,
|
|
42
|
+
exc_value: BaseException | None,
|
|
43
|
+
traceback: TracebackType | None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Exit the asynchronous context manager."""
|
|
46
|
+
_ = exc_type, exc_value, traceback
|
|
47
|
+
|
|
48
|
+
async def get(self, cache_key: UUID) -> CachedResponse | None:
|
|
49
|
+
"""Get a cached response by key, or None when missing."""
|
|
50
|
+
return self._entries.get(cache_key)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _ensure_validators(metadata: ResponseMetadata) -> None:
|
|
54
|
+
"""Ensure at least one cache validator is present."""
|
|
55
|
+
if metadata.etag is None and metadata.last_modified is None:
|
|
56
|
+
raise ValueError("Cached responses require etag or last_modified")
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _build_cached_response(
|
|
60
|
+
*,
|
|
61
|
+
cache_key: UUID,
|
|
62
|
+
text: str,
|
|
63
|
+
metadata: ResponseMetadata,
|
|
64
|
+
) -> CachedResponse:
|
|
65
|
+
"""Build a CachedResponse from response text and metadata."""
|
|
66
|
+
InMemoryCache._ensure_validators(metadata)
|
|
67
|
+
return CachedResponse(
|
|
68
|
+
cache_key=cache_key,
|
|
69
|
+
response_text=text,
|
|
70
|
+
response_metadata_json=metadata.serialize(),
|
|
71
|
+
etag=metadata.etag,
|
|
72
|
+
last_modified=metadata.last_modified,
|
|
73
|
+
expires_at=metadata.expires_at,
|
|
74
|
+
cache_timestamp=Instant.now().timestamp_nanos(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def set(
|
|
78
|
+
self, cache_key: UUID, text: str, metadata: ResponseMetadata
|
|
79
|
+
) -> CachedResponse:
|
|
80
|
+
"""Create or replace a cached response entry.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If both metadata validators are absent.
|
|
84
|
+
"""
|
|
85
|
+
cached_response = self._build_cached_response(
|
|
86
|
+
cache_key=cache_key,
|
|
87
|
+
text=text,
|
|
88
|
+
metadata=metadata,
|
|
89
|
+
)
|
|
90
|
+
self._entries[cache_key] = cached_response
|
|
91
|
+
return cached_response
|
|
92
|
+
|
|
93
|
+
async def update_304(
|
|
94
|
+
self, cache_key: UUID, metadata: ResponseMetadata
|
|
95
|
+
) -> CachedResponse:
|
|
96
|
+
"""Refresh a stale entry from 304 metadata while preserving body text.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
KeyError: If no existing entry is present.
|
|
100
|
+
ValueError: If merged metadata lacks both validators.
|
|
101
|
+
"""
|
|
102
|
+
existing = self._entries.get(cache_key)
|
|
103
|
+
if existing is None:
|
|
104
|
+
raise KeyError(f"No cached response found for key {cache_key}")
|
|
105
|
+
|
|
106
|
+
existing_metadata = ResponseMetadata.deserialize(
|
|
107
|
+
existing.response_metadata_json
|
|
108
|
+
)
|
|
109
|
+
merged_metadata = merge_cached_revalidation_metadata(
|
|
110
|
+
cached=existing_metadata,
|
|
111
|
+
refreshed=metadata,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
cached_response = self._build_cached_response(
|
|
115
|
+
cache_key=cache_key,
|
|
116
|
+
text=existing.response_text,
|
|
117
|
+
metadata=merged_metadata,
|
|
118
|
+
)
|
|
119
|
+
self._entries[cache_key] = cached_response
|
|
120
|
+
return cached_response
|
|
121
|
+
|
|
122
|
+
async def delete(self, cache_key: UUID) -> None:
|
|
123
|
+
"""Delete a cached response from the cache.
|
|
124
|
+
|
|
125
|
+
This operation is idempotent and does not raise for missing keys.
|
|
126
|
+
"""
|
|
127
|
+
self._entries.pop(cache_key, None)
|
|
128
|
+
|
|
129
|
+
async def clear(
|
|
130
|
+
self, only_expired: bool = False, age_limit: int | None = None
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Clear cached entries matching the requested filters.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
only_expired: When true, remove only expired entries.
|
|
136
|
+
age_limit: When provided, remove entries with `cache_age` greater
|
|
137
|
+
than or equal to this nanosecond threshold.
|
|
138
|
+
|
|
139
|
+
Notes:
|
|
140
|
+
When both filters are provided, entries must satisfy both.
|
|
141
|
+
"""
|
|
142
|
+
if not only_expired and age_limit is None:
|
|
143
|
+
self._entries.clear()
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
removable_keys: list[UUID] = []
|
|
147
|
+
for cache_key, cached_response in self._entries.items():
|
|
148
|
+
is_expired_match = not only_expired or cached_response.is_expired
|
|
149
|
+
is_age_match = age_limit is None or cached_response.cache_age >= age_limit
|
|
150
|
+
if is_expired_match and is_age_match:
|
|
151
|
+
removable_keys.append(cache_key)
|
|
152
|
+
|
|
153
|
+
for cache_key in removable_keys:
|
|
154
|
+
del self._entries[cache_key]
|
|
155
|
+
|
|
156
|
+
async def flush(self) -> None:
|
|
157
|
+
"""Flush the cache.
|
|
158
|
+
|
|
159
|
+
This implementation has no buffered writes, so flush is a no-op.
|
|
160
|
+
"""
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
async def cache_info(self) -> CacheInfo:
|
|
164
|
+
"""Return summary information about the cache."""
|
|
165
|
+
return CacheInfo(size=len(self._entries))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class InMemoryCacheFactory(CacheFactoryProtocol):
|
|
169
|
+
"""Factory for creating InMemoryCache instances.
|
|
170
|
+
|
|
171
|
+
The InMemoryCache class takes no arguments, so this factory simply returns a new
|
|
172
|
+
instance of InMemoryCache.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __call__(self) -> CacheProtocol:
|
|
176
|
+
"""Create and return a new in-memory cache instance."""
|
|
177
|
+
return InMemoryCache()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Helpers for cache-specific response metadata handling.
|
|
2
|
+
|
|
3
|
+
The primary helper in this module preserves cached-body semantics when a stale
|
|
4
|
+
entry is revalidated with a `304 Not Modified` network response.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pfmsoft.api_request.request.models import ResponseMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def merge_cached_revalidation_metadata(
|
|
11
|
+
*,
|
|
12
|
+
cached: ResponseMetadata,
|
|
13
|
+
refreshed: ResponseMetadata,
|
|
14
|
+
) -> ResponseMetadata:
|
|
15
|
+
"""Merge a 304 revalidation response into cached response metadata.
|
|
16
|
+
|
|
17
|
+
The returned metadata continues to describe the cached body representation,
|
|
18
|
+
so it preserves cached success/body fields while applying newer header and
|
|
19
|
+
timing fields from the `304` revalidation response.
|
|
20
|
+
|
|
21
|
+
Field precedence:
|
|
22
|
+
- Preserved from `cached`: `status_code`, `reason_phrase`,
|
|
23
|
+
`bytes_downloaded`.
|
|
24
|
+
- Taken from `refreshed`: `url`, `elapsed`, `received_timestamp`.
|
|
25
|
+
- Headers: merged case-insensitively where refreshed header values
|
|
26
|
+
overwrite matching cached header names.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
cached: Metadata associated with the currently stored cached body.
|
|
30
|
+
refreshed: Metadata from a `304 Not Modified` revalidation response.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A merged metadata value that still represents a successful cached body.
|
|
34
|
+
"""
|
|
35
|
+
merged_headers = list(cached.headers)
|
|
36
|
+
header_indexes = {
|
|
37
|
+
key.lower(): index for index, (key, _) in enumerate(merged_headers)
|
|
38
|
+
}
|
|
39
|
+
for key, value in refreshed.headers:
|
|
40
|
+
lower_key = key.lower()
|
|
41
|
+
if lower_key in header_indexes:
|
|
42
|
+
merged_headers[header_indexes[lower_key]] = (key, value)
|
|
43
|
+
continue
|
|
44
|
+
header_indexes[lower_key] = len(merged_headers)
|
|
45
|
+
merged_headers.append((key, value))
|
|
46
|
+
|
|
47
|
+
return ResponseMetadata(
|
|
48
|
+
status_code=cached.status_code,
|
|
49
|
+
reason_phrase=cached.reason_phrase,
|
|
50
|
+
url=refreshed.url,
|
|
51
|
+
elapsed=refreshed.elapsed,
|
|
52
|
+
bytes_downloaded=cached.bytes_downloaded,
|
|
53
|
+
headers=tuple(merged_headers),
|
|
54
|
+
received_timestamp=refreshed.received_timestamp,
|
|
55
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Immutable cache model types used by cache implementations.
|
|
2
|
+
|
|
3
|
+
Time unit conventions:
|
|
4
|
+
- `expires_at` uses Unix seconds.
|
|
5
|
+
- `cache_timestamp` and `cache_age` use Unix nanoseconds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from whenever import Instant
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
16
|
+
class CachedResponse:
|
|
17
|
+
"""Represents one cached HTTP response payload and metadata snapshot.
|
|
18
|
+
|
|
19
|
+
Instances are immutable so cache providers can safely return shared values
|
|
20
|
+
without defensive copies.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
cache_key: UUID
|
|
24
|
+
"""Unique cache key associated with the stored response."""
|
|
25
|
+
response_text: str
|
|
26
|
+
"""Serialized response body text."""
|
|
27
|
+
response_metadata_json: str
|
|
28
|
+
"""JSON-encoded ResponseMetadata document for the cached body."""
|
|
29
|
+
etag: str | None = None
|
|
30
|
+
"""Cached ETag validator, if present."""
|
|
31
|
+
last_modified: str | None = None
|
|
32
|
+
"""Cached Last-Modified validator, if present."""
|
|
33
|
+
expires_at: int | None = None
|
|
34
|
+
"""Expiration instant in Unix seconds, or None when unknown."""
|
|
35
|
+
cache_timestamp: int
|
|
36
|
+
"""Write/update instant in Unix nanoseconds."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_expired(self) -> bool:
|
|
40
|
+
"""Return True when the cached entry is expired.
|
|
41
|
+
|
|
42
|
+
Entries with `expires_at=None` are treated as not expired.
|
|
43
|
+
"""
|
|
44
|
+
if self.expires_at is None:
|
|
45
|
+
return False
|
|
46
|
+
return Instant.now().timestamp() >= self.expires_at
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def cache_age(self) -> int:
|
|
50
|
+
"""Return cache entry age in nanoseconds."""
|
|
51
|
+
return Instant.now().timestamp_nanos() - self.cache_timestamp
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CachedResponseStatus(StrEnum):
|
|
55
|
+
"""Categorization for cache lookup outcomes."""
|
|
56
|
+
|
|
57
|
+
HIT = "HIT"
|
|
58
|
+
"""A cache entry existed and was used without revalidation."""
|
|
59
|
+
MISS = "MISS"
|
|
60
|
+
"""No cache entry existed for the requested key."""
|
|
61
|
+
STALE = "STALE"
|
|
62
|
+
"""A cache entry existed but required revalidation."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CacheAction(StrEnum):
|
|
66
|
+
"""Lifecycle actions emitted by cache-aware request flows."""
|
|
67
|
+
|
|
68
|
+
ADDED_TO_CACHE = "ADDED_TO_CACHE"
|
|
69
|
+
"""A response was written to cache for the first time or as replacement."""
|
|
70
|
+
CACHED_RESPONSE_USED = "CACHED_RESPONSE_USED"
|
|
71
|
+
"""A previously cached response was returned directly."""
|
|
72
|
+
CACHE_304_REFRESH_METADATA = "CACHE_304_REFRESH_METADATA"
|
|
73
|
+
"""A stale cache entry was refreshed from a 304 revalidation."""
|
|
74
|
+
CACHE_304_UPDATE_RESPONSE = "CACHE_304_UPDATE_RESPONSE"
|
|
75
|
+
"""A stale cache entry was replaced by fresh response content."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True, kw_only=True, frozen=True)
|
|
79
|
+
class CacheInfo:
|
|
80
|
+
"""Summary information returned by cache providers."""
|
|
81
|
+
|
|
82
|
+
size: int = -1
|
|
83
|
+
"""Number of stored entries, or -1 when unavailable."""
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Cache protocols and factory contracts for API requests.
|
|
2
|
+
|
|
3
|
+
The protocol in this module defines the behavioral contract shared by cache
|
|
4
|
+
implementations such as in-memory and SQLite providers.
|
|
5
|
+
|
|
6
|
+
Typical usage:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
cache_factory: CacheFactory = ...
|
|
10
|
+
cache = cache_factory()
|
|
11
|
+
async with cache:
|
|
12
|
+
cached = await cache.get(cache_key)
|
|
13
|
+
if cached is None:
|
|
14
|
+
cached = await cache.set(cache_key, text, metadata)
|
|
15
|
+
```
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Protocol, Self
|
|
21
|
+
from uuid import UUID
|
|
22
|
+
|
|
23
|
+
from pfmsoft.api_request.cache.models import CachedResponse, CacheInfo
|
|
24
|
+
from pfmsoft.api_request.request.models import ResponseMetadata
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CacheProtocol(Protocol):
|
|
28
|
+
"""Behavioral contract for cache providers used by request execution.
|
|
29
|
+
|
|
30
|
+
Implementations are expected to support async context management for
|
|
31
|
+
lifecycle concerns (for example, opening and closing database connections).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> Self:
|
|
35
|
+
"""Enter the asynchronous context manager."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def __aexit__(
|
|
39
|
+
self,
|
|
40
|
+
exc_type: type[BaseException] | None,
|
|
41
|
+
exc_value: BaseException | None,
|
|
42
|
+
traceback: TracebackType | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Exit the asynchronous context manager."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
async def get(self, cache_key: UUID) -> CachedResponse | None:
|
|
48
|
+
"""Get a cached response by key.
|
|
49
|
+
|
|
50
|
+
Returns `None` when no entry exists.
|
|
51
|
+
"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
async def set(
|
|
55
|
+
self, cache_key: UUID, text: str, metadata: ResponseMetadata
|
|
56
|
+
) -> CachedResponse:
|
|
57
|
+
"""Create or replace a cached response for a key.
|
|
58
|
+
|
|
59
|
+
Implementations must treat inputs as immutable and build a new
|
|
60
|
+
`CachedResponse` from the provided values.
|
|
61
|
+
|
|
62
|
+
Contract:
|
|
63
|
+
- Upsert behavior: create when missing, replace when present.
|
|
64
|
+
- `cache_timestamp` is set to the current update time in nanoseconds.
|
|
65
|
+
- `etag` and `last_modified` are derived from `metadata`.
|
|
66
|
+
- At least one validator (`etag` or `last_modified`) must be present;
|
|
67
|
+
raise `ValueError` if both are missing.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
cache_key: Cache entry key.
|
|
71
|
+
text: Response body text to store.
|
|
72
|
+
metadata: Response metadata used to populate validator/expiry fields.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The stored `CachedResponse`.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If both `metadata.etag` and `metadata.last_modified` are missing.
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
async def update_304(
|
|
83
|
+
self, cache_key: UUID, metadata: ResponseMetadata
|
|
84
|
+
) -> CachedResponse:
|
|
85
|
+
"""Refresh an existing cached response from a 304 revalidation.
|
|
86
|
+
|
|
87
|
+
This operation updates metadata fields while preserving the existing
|
|
88
|
+
cached body text and cached-success representation.
|
|
89
|
+
|
|
90
|
+
Contract:
|
|
91
|
+
- Entry must already exist for `cache_key`.
|
|
92
|
+
- Response text is preserved from the existing entry.
|
|
93
|
+
- Stored and returned metadata must continue to represent the cached body
|
|
94
|
+
as a successful response rather than a raw `304 Not Modified`
|
|
95
|
+
response.
|
|
96
|
+
- Metadata-derived fields are refreshed by merging the existing
|
|
97
|
+
cached metadata with the revalidation response headers and timing
|
|
98
|
+
data.
|
|
99
|
+
- The merged result is then used to populate `response_metadata_json`,
|
|
100
|
+
`etag`, `last_modified`, and `expires_at`.
|
|
101
|
+
- `cache_timestamp` is set to the current update time in nanoseconds.
|
|
102
|
+
- At least one validator (`etag` or `last_modified`) must be present in
|
|
103
|
+
`metadata`; raise `ValueError` if both are missing.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
cache_key: Cache entry key.
|
|
107
|
+
metadata: Fresh metadata returned from a `304 Not Modified`
|
|
108
|
+
revalidation request. Implementations merge this with the
|
|
109
|
+
existing cached metadata so the stored representation remains a
|
|
110
|
+
successful cached response.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The updated `CachedResponse`.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
KeyError: If `cache_key` does not exist.
|
|
117
|
+
ValueError: If both `metadata.etag` and `metadata.last_modified` are missing.
|
|
118
|
+
"""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
async def delete(self, cache_key: UUID) -> None:
|
|
122
|
+
"""Delete a cached response.
|
|
123
|
+
|
|
124
|
+
This operation is idempotent: deleting a missing key does not raise.
|
|
125
|
+
"""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
async def clear(
|
|
129
|
+
self, only_expired: bool = False, age_limit: int | None = None
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Clear cache entries matching filter criteria.
|
|
132
|
+
|
|
133
|
+
Filter behavior:
|
|
134
|
+
- `only_expired=True`: remove only expired entries.
|
|
135
|
+
- `age_limit` set: remove entries whose age is greater than or equal
|
|
136
|
+
to the provided nanosecond age threshold.
|
|
137
|
+
- both set: remove entries that satisfy both criteria.
|
|
138
|
+
- neither set: remove all entries.
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
async def flush(self) -> None:
|
|
143
|
+
"""Flush pending cache writes to durable storage.
|
|
144
|
+
|
|
145
|
+
Implementations that do not buffer writes may treat this as a no-op.
|
|
146
|
+
"""
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
async def cache_info(self) -> CacheInfo:
|
|
150
|
+
"""Get cache information such as size and number of entries."""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
CacheFactory = Callable[[], CacheProtocol]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CacheFactoryProtocol(Protocol):
|
|
158
|
+
"""Callable contract for building `CacheProtocol` instances.
|
|
159
|
+
|
|
160
|
+
Factories return a new cache provider instance that can be used by request
|
|
161
|
+
execution components.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __call__(self) -> CacheProtocol:
|
|
165
|
+
"""Build and return a configured cache instance."""
|
|
166
|
+
...
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SQLite cache backend implementation details.
|
|
2
|
+
|
|
3
|
+
This package contains:
|
|
4
|
+
- `sqlite_cache.py`: `CacheProtocol` implementation backed by SQLite.
|
|
5
|
+
- `connection_helpers.py`: connection/URI/schema bootstrapping helpers.
|
|
6
|
+
- `query_helpers.py`: SQL CRUD helpers for `CachedResponse` rows.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SQLite connection and schema bootstrap helpers.
|
|
2
|
+
|
|
3
|
+
These helpers centralize URI construction and consistent connection setup for
|
|
4
|
+
cache backends and tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from importlib.resources import files as resource_files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_table_def_parent = "pfmsoft.api_request.cache.sqlite_cache"
|
|
17
|
+
_table_def_sql = "table_definitions.sql"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_only_uri(db_path: str) -> str:
|
|
21
|
+
"""Build a read-only SQLite URI for use with sqlite3.connect(uri=True).
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
db_path: Filesystem path to the SQLite database.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
URI string with mode=ro.
|
|
28
|
+
"""
|
|
29
|
+
return f"file:{db_path}?mode=ro"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_write_uri(db_path: str) -> str:
|
|
33
|
+
"""Build a read-write/create SQLite URI for sqlite3.connect(uri=True).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
db_path: Filesystem path to the SQLite database.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
URI string with mode=rwc.
|
|
40
|
+
"""
|
|
41
|
+
return f"file:{db_path}?mode=rwc"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_read_only_connection(db_path: str | Path) -> sqlite3.Connection:
|
|
45
|
+
"""Create a read-only SQLite connection for an existing database.
|
|
46
|
+
|
|
47
|
+
The caller is responsible for closing the connection when done.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
db_path: Path to an existing SQLite database file.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Open SQLite connection configured with sqlite3.Row row factory.
|
|
54
|
+
|
|
55
|
+
Notes:
|
|
56
|
+
The target database is expected to already contain the required schema.
|
|
57
|
+
Read-only connections cannot modify schema or persisted rows.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(db_path, Path):
|
|
60
|
+
db_path = str(db_path.resolve())
|
|
61
|
+
uri = read_only_uri(db_path)
|
|
62
|
+
connection = sqlite3.connect(uri, uri=True)
|
|
63
|
+
connection.row_factory = sqlite3.Row
|
|
64
|
+
return connection
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_read_write_connection(db_path: str | Path) -> sqlite3.Connection:
|
|
68
|
+
"""Create a read-write SQLite connection and ensure the packaged schema exists.
|
|
69
|
+
|
|
70
|
+
The caller is responsible for closing the connection when done.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
db_path: Path to the SQLite database file.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Open SQLite connection configured with sqlite3.Row row factory.
|
|
77
|
+
|
|
78
|
+
Notes:
|
|
79
|
+
Missing tables and other schema objects defined in the packaged SQL
|
|
80
|
+
script are created before the connection is returned.
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(db_path, Path):
|
|
83
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
db_path = str(db_path.resolve())
|
|
85
|
+
else:
|
|
86
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
db_path = str(Path(db_path).resolve())
|
|
88
|
+
uri = read_write_uri(db_path)
|
|
89
|
+
connection = sqlite3.connect(uri, uri=True)
|
|
90
|
+
logger.info(f"Created read-write connection to database at {db_path}")
|
|
91
|
+
connection.row_factory = sqlite3.Row
|
|
92
|
+
table_defs = resource_files(_table_def_parent).joinpath(_table_def_sql).read_text()
|
|
93
|
+
with connection:
|
|
94
|
+
connection.executescript(table_defs)
|
|
95
|
+
logger.info("Ensured database schema is created.")
|
|
96
|
+
return connection
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@contextmanager
|
|
100
|
+
def db_connection_manager(
|
|
101
|
+
db_path: str | Path, read_only: bool = True
|
|
102
|
+
) -> Iterator[sqlite3.Connection]:
|
|
103
|
+
"""Yield a SQLite connection and close it automatically on exit.
|
|
104
|
+
|
|
105
|
+
Delegates to the read-only or read-write connection factory based on the
|
|
106
|
+
`read_only` flag.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
db_path: Path to the SQLite database file.
|
|
110
|
+
read_only: Whether to open the connection in read-only mode.
|
|
111
|
+
|
|
112
|
+
Yields:
|
|
113
|
+
Open SQLite connection configured with sqlite3.Row row factory.
|
|
114
|
+
"""
|
|
115
|
+
connection: sqlite3.Connection | None = None
|
|
116
|
+
try:
|
|
117
|
+
if read_only:
|
|
118
|
+
logger.info(f"Opening read-only connection to database at {db_path}")
|
|
119
|
+
connection = create_read_only_connection(db_path)
|
|
120
|
+
else:
|
|
121
|
+
logger.info(f"Opening read-write connection to database at {db_path}")
|
|
122
|
+
connection = create_read_write_connection(db_path)
|
|
123
|
+
yield connection
|
|
124
|
+
finally:
|
|
125
|
+
if connection is not None:
|
|
126
|
+
connection.close()
|