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.
Files changed (40) hide show
  1. pfmsoft/api_request/__init__.py +37 -0
  2. pfmsoft/api_request/cache/__init__.py +36 -0
  3. pfmsoft/api_request/cache/memory_cache.py +177 -0
  4. pfmsoft/api_request/cache/metadata_helpers.py +55 -0
  5. pfmsoft/api_request/cache/models.py +83 -0
  6. pfmsoft/api_request/cache/protocols.py +166 -0
  7. pfmsoft/api_request/cache/sqlite_cache/__init__.py +7 -0
  8. pfmsoft/api_request/cache/sqlite_cache/connection_helpers.py +126 -0
  9. pfmsoft/api_request/cache/sqlite_cache/query_helpers.py +107 -0
  10. pfmsoft/api_request/cache/sqlite_cache/sqlite_cache.py +233 -0
  11. pfmsoft/api_request/cache/sqlite_cache/table_definitions.sql +23 -0
  12. pfmsoft/api_request/cli/__init__.py +17 -0
  13. pfmsoft/api_request/cli/cache/__init__.py +12 -0
  14. pfmsoft/api_request/cli/cache/info.py +11 -0
  15. pfmsoft/api_request/cli/helpers.py +41 -0
  16. pfmsoft/api_request/cli/main_typer.py +52 -0
  17. pfmsoft/api_request/cli/request/__init__.py +11 -0
  18. pfmsoft/api_request/cli/request/request.py +222 -0
  19. pfmsoft/api_request/cli/request/validate.py +11 -0
  20. pfmsoft/api_request/helpers/http_session_factory.py +80 -0
  21. pfmsoft/api_request/helpers/json_io.py +256 -0
  22. pfmsoft/api_request/helpers/save_text_file.py +43 -0
  23. pfmsoft/api_request/helpers/whenever/__init__.py +4 -0
  24. pfmsoft/api_request/helpers/whenever/instant_helpers.py +41 -0
  25. pfmsoft/api_request/logging_config.py +202 -0
  26. pfmsoft/api_request/py.typed +0 -0
  27. pfmsoft/api_request/rate_limit/__init__.py +24 -0
  28. pfmsoft/api_request/rate_limit/aio_limiter.py +89 -0
  29. pfmsoft/api_request/rate_limit/protocols.py +101 -0
  30. pfmsoft/api_request/request/__init__.py +11 -0
  31. pfmsoft/api_request/request/api_requester.py +681 -0
  32. pfmsoft/api_request/request/intermediate_models.py +143 -0
  33. pfmsoft/api_request/request/models.py +355 -0
  34. pfmsoft/api_request/request/protocols.py +54 -0
  35. pfmsoft/api_request/settings.py +65 -0
  36. pfmsoft_api_request-0.1.3.dist-info/METADATA +171 -0
  37. pfmsoft_api_request-0.1.3.dist-info/RECORD +40 -0
  38. pfmsoft_api_request-0.1.3.dist-info/WHEEL +4 -0
  39. pfmsoft_api_request-0.1.3.dist-info/entry_points.txt +3 -0
  40. pfmsoft_api_request-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,107 @@
1
+ """SQLite persistence helpers for the cache backend.
2
+
3
+ This module contains SQL-backed helpers for storing and retrieving
4
+ `CachedResponse` rows in the `WebCache` table.
5
+ """
6
+
7
+ import logging
8
+ import sqlite3
9
+ from uuid import UUID
10
+
11
+ from ..models import CachedResponse
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def write_cached_response(
17
+ connection: sqlite3.Connection, cache_key: str, cached_response: CachedResponse
18
+ ) -> None:
19
+ """Insert or replace a cached response row by key.
20
+
21
+ Args:
22
+ connection: Active SQLite connection.
23
+ cache_key: Cache key string (UUID text representation).
24
+ cached_response: Normalized cache model to persist.
25
+ """
26
+ query = """
27
+ INSERT INTO WebCache (
28
+ cache_key,
29
+ response_text,
30
+ response_metadata_json,
31
+ etag,
32
+ last_modified,
33
+ expires_at,
34
+ cache_timestamp
35
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
36
+ ON CONFLICT(cache_key) DO UPDATE SET
37
+ response_text = excluded.response_text,
38
+ response_metadata_json = excluded.response_metadata_json,
39
+ etag = excluded.etag,
40
+ last_modified = excluded.last_modified,
41
+ expires_at = excluded.expires_at,
42
+ cache_timestamp = excluded.cache_timestamp
43
+ """
44
+
45
+ with connection:
46
+ connection.execute(
47
+ query,
48
+ (
49
+ cache_key,
50
+ cached_response.response_text,
51
+ cached_response.response_metadata_json,
52
+ cached_response.etag,
53
+ cached_response.last_modified,
54
+ cached_response.expires_at,
55
+ cached_response.cache_timestamp,
56
+ ),
57
+ )
58
+
59
+
60
+ def query_cached_response(
61
+ connection: sqlite3.Connection, cache_key: str
62
+ ) -> CachedResponse | None:
63
+ """Query one cached response row by key.
64
+
65
+ Args:
66
+ connection: Active SQLite connection.
67
+ cache_key: Cache key string (UUID text representation).
68
+
69
+ Returns:
70
+ Matching cached response, or None when no row exists.
71
+ """
72
+ query = """
73
+ SELECT
74
+ cache_key,
75
+ response_text,
76
+ response_metadata_json,
77
+ etag,
78
+ last_modified,
79
+ expires_at,
80
+ cache_timestamp
81
+ FROM WebCache
82
+ WHERE cache_key = ?
83
+ """
84
+
85
+ row = connection.execute(query, (cache_key,)).fetchone()
86
+ if row is None:
87
+ return None
88
+
89
+ return CachedResponse(
90
+ cache_key=UUID(row["cache_key"]),
91
+ response_text=row["response_text"],
92
+ response_metadata_json=row["response_metadata_json"],
93
+ etag=row["etag"],
94
+ last_modified=row["last_modified"],
95
+ expires_at=row["expires_at"],
96
+ cache_timestamp=row["cache_timestamp"],
97
+ )
98
+
99
+
100
+ def delete_cached_response(connection: sqlite3.Connection, cache_key: str) -> None:
101
+ """Delete a cached response row by key.
102
+
103
+ This operation is idempotent and does not raise for missing keys.
104
+ """
105
+ query = "DELETE FROM WebCache WHERE cache_key = ?"
106
+ with connection:
107
+ connection.execute(query, (cache_key,))
@@ -0,0 +1,233 @@
1
+ """SQLite-backed cache provider for request orchestration.
2
+
3
+ This module provides a `CacheProtocol` implementation backed by a `WebCache`
4
+ table and a small factory for dependency injection.
5
+ """
6
+
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import Self
11
+ from uuid import UUID
12
+
13
+ from whenever import Instant
14
+
15
+ from ...request.models import ResponseMetadata
16
+ from ..metadata_helpers import merge_cached_revalidation_metadata
17
+ from ..models import CachedResponse, CacheInfo
18
+ from ..protocols import CacheFactoryProtocol, CacheProtocol
19
+ from . import query_helpers
20
+ from .connection_helpers import create_read_write_connection
21
+
22
+
23
+ class SqliteCache(CacheProtocol):
24
+ """SQLite-backed cache implementation.
25
+
26
+ This cache persists entries in the `WebCache` table and maps CRUD operations
27
+ to SQL statements through query helpers.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ db: str | Path | sqlite3.Connection,
33
+ ) -> None:
34
+ """Initialize cache with a database path or active SQLite connection.
35
+
36
+ Args:
37
+ db: Either a filesystem path to the SQLite database or an existing
38
+ sqlite3 connection.
39
+
40
+ Notes:
41
+ When initialized with a path, a read-write connection is created and
42
+ closed automatically via the context manager.
43
+
44
+ When initialized with an existing connection, that connection is never
45
+ closed by this class.
46
+ """
47
+ self._connection: sqlite3.Connection | None = None
48
+ self._close_connection_on_exit: bool = False
49
+ if isinstance(db, sqlite3.Connection):
50
+ self._connection = db
51
+ self._close_connection_on_exit = False
52
+ self._connection_path: Path | None = None
53
+ else:
54
+ self._connection_path = Path(db)
55
+
56
+ async def __aenter__(self) -> Self:
57
+ """Enter context and open path-owned connections when needed."""
58
+ if self._connection_path is not None:
59
+ self._connection = create_read_write_connection(self._connection_path)
60
+ self._close_connection_on_exit = True
61
+ return self
62
+
63
+ async def __aexit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc_value: BaseException | None,
67
+ traceback: TracebackType | None,
68
+ ) -> None:
69
+ """Exit context and close only connections owned by this instance."""
70
+ _ = exc_type, exc_value, traceback
71
+ if self._connection is not None:
72
+ if self._close_connection_on_exit:
73
+ self._connection.close()
74
+
75
+ def _ensure_connection(self) -> sqlite3.Connection:
76
+ """Ensure an active SQLite connection is present."""
77
+ if self._connection is None:
78
+ raise RuntimeError("No active SQLite connection")
79
+ return self._connection
80
+
81
+ async def get(self, cache_key: UUID) -> CachedResponse | None:
82
+ """Get a cached response by key, or None when missing."""
83
+ connection = self._ensure_connection()
84
+ return query_helpers.query_cached_response(connection, str(cache_key))
85
+
86
+ @staticmethod
87
+ def _ensure_validators(metadata: ResponseMetadata) -> None:
88
+ """Ensure at least one cache validator is present."""
89
+ if metadata.etag is None and metadata.last_modified is None:
90
+ raise ValueError("Cached responses require etag or last_modified")
91
+
92
+ @staticmethod
93
+ def _build_cached_response(
94
+ *,
95
+ cache_key: UUID,
96
+ text: str,
97
+ metadata: ResponseMetadata,
98
+ ) -> CachedResponse:
99
+ """Build a CachedResponse from response text and metadata."""
100
+ SqliteCache._ensure_validators(metadata)
101
+ return CachedResponse(
102
+ cache_key=cache_key,
103
+ response_text=text,
104
+ response_metadata_json=metadata.serialize(),
105
+ etag=metadata.etag,
106
+ last_modified=metadata.last_modified,
107
+ expires_at=metadata.expires_at,
108
+ cache_timestamp=Instant.now().timestamp_nanos(),
109
+ )
110
+
111
+ async def set(
112
+ self, cache_key: UUID, text: str, metadata: ResponseMetadata
113
+ ) -> CachedResponse:
114
+ """Create or replace a cached response entry.
115
+
116
+ Raises:
117
+ ValueError: If both metadata validators are absent.
118
+ """
119
+ cached_response = self._build_cached_response(
120
+ cache_key=cache_key,
121
+ text=text,
122
+ metadata=metadata,
123
+ )
124
+ connection = self._ensure_connection()
125
+ query_helpers.write_cached_response(
126
+ connection,
127
+ str(cache_key),
128
+ cached_response,
129
+ )
130
+ return cached_response
131
+
132
+ async def update_304(
133
+ self, cache_key: UUID, metadata: ResponseMetadata
134
+ ) -> CachedResponse:
135
+ """Refresh a stale entry from 304 metadata while preserving body text.
136
+
137
+ Raises:
138
+ KeyError: If no existing entry is present.
139
+ ValueError: If merged metadata lacks both validators.
140
+ """
141
+ existing = await self.get(cache_key)
142
+ if existing is None:
143
+ raise KeyError(f"No cached response found for key {cache_key}")
144
+
145
+ existing_metadata = ResponseMetadata.deserialize(
146
+ existing.response_metadata_json
147
+ )
148
+ merged_metadata = merge_cached_revalidation_metadata(
149
+ cached=existing_metadata,
150
+ refreshed=metadata,
151
+ )
152
+
153
+ cached_response = self._build_cached_response(
154
+ cache_key=cache_key,
155
+ text=existing.response_text,
156
+ metadata=merged_metadata,
157
+ )
158
+ connection = self._ensure_connection()
159
+ query_helpers.write_cached_response(
160
+ connection,
161
+ str(cache_key),
162
+ cached_response,
163
+ )
164
+ return cached_response
165
+
166
+ async def delete(self, cache_key: UUID) -> None:
167
+ """Delete a cached response from the cache.
168
+
169
+ This operation is idempotent and does not raise for missing keys.
170
+ """
171
+ connection = self._ensure_connection()
172
+ query_helpers.delete_cached_response(connection, str(cache_key))
173
+
174
+ async def clear(
175
+ self, only_expired: bool = False, age_limit: int | None = None
176
+ ) -> None:
177
+ """Clear cached entries matching the requested filters.
178
+
179
+ Args:
180
+ only_expired: When true, remove only expired entries.
181
+ age_limit: When provided, remove entries with `cache_age` greater
182
+ than or equal to this nanosecond threshold.
183
+
184
+ Notes:
185
+ When both filters are provided, entries must satisfy both.
186
+ """
187
+ connection = self._ensure_connection()
188
+ if not only_expired and age_limit is None:
189
+ with connection:
190
+ connection.execute("DELETE FROM WebCache")
191
+ return
192
+
193
+ clauses: list[str] = []
194
+ params: list[int] = []
195
+
196
+ if only_expired:
197
+ clauses.append("(expires_at IS NOT NULL AND expires_at <= ?)")
198
+ params.append(Instant.now().timestamp())
199
+
200
+ if age_limit is not None:
201
+ cutoff = Instant.now().timestamp_nanos() - age_limit
202
+ clauses.append("cache_timestamp <= ?")
203
+ params.append(cutoff)
204
+
205
+ where_clause = " AND ".join(clauses)
206
+ query = f"DELETE FROM WebCache WHERE {where_clause}"
207
+ connection = self._ensure_connection()
208
+ with connection:
209
+ connection.execute(query, tuple(params))
210
+
211
+ async def flush(self) -> None:
212
+ """Commit pending writes on the active SQLite connection."""
213
+ # hot cache not yet implememnted
214
+ pass
215
+
216
+ async def cache_info(self) -> CacheInfo:
217
+ """Get summary information about the cache."""
218
+ connection = self._ensure_connection()
219
+ row = connection.execute("SELECT COUNT(*) AS size FROM WebCache").fetchone()
220
+ size = 0 if row is None else int(row["size"])
221
+ return CacheInfo(size=size)
222
+
223
+
224
+ class SqliteCacheFactory(CacheFactoryProtocol):
225
+ """Factory for creating `SqliteCache` instances backed by a DB path."""
226
+
227
+ def __init__(self, db_path: str | Path) -> None:
228
+ """Initialize with the SQLite database path."""
229
+ self._db_path = db_path
230
+
231
+ def __call__(self) -> CacheProtocol:
232
+ """Create and return a new SQLite-backed cache instance."""
233
+ return SqliteCache(self._db_path)
@@ -0,0 +1,23 @@
1
+ -- Table definitions for the api-request web cache SQLite database.
2
+ -- JSON payload columns are stored as TEXT.
3
+ -- Timestamps are stored as integers; units vary by column.
4
+
5
+
6
+ -- The WebCache table stores one row per cached response key.
7
+ -- cache_key: unique key identifying a cache entry.
8
+ -- response_text: serialized response body text.
9
+ -- response_metadata_json: JSON-encoded response metadata.
10
+ -- etag: ETag validator, if present.
11
+ -- last_modified: Last-Modified validator, if present.
12
+ -- expires_at: Unix timestamp in seconds when the entry becomes stale.
13
+ -- cache_timestamp: Unix timestamp in nanoseconds for write/update time.
14
+ CREATE TABLE IF NOT EXISTS WebCache (
15
+ ID INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ cache_key TEXT NOT NULL UNIQUE,
17
+ response_text TEXT NOT NULL,
18
+ response_metadata_json TEXT NOT NULL,
19
+ etag TEXT,
20
+ last_modified TEXT,
21
+ expires_at INTEGER,
22
+ cache_timestamp INTEGER NOT NULL
23
+ );
@@ -0,0 +1,17 @@
1
+ """CLI command modules for api-request.
2
+
3
+ This package contains Typer applications and helper functions used by the
4
+ `eve-auth`/`api-request` command-line entrypoints.
5
+ """
6
+
7
+ import typer
8
+
9
+ from .cache import app as cache_app
10
+ from .request import app as request_app
11
+
12
+ app = typer.Typer(no_args_is_help=True)
13
+
14
+ app.add_typer(
15
+ cache_app, name="cache", help="Commands for working with the cache database."
16
+ )
17
+ app.add_typer(request_app, name="request", help="Commands for making API requests.")
@@ -0,0 +1,12 @@
1
+ """Commands for working with the cache database."""
2
+
3
+ import typer
4
+
5
+ from .info import app as info_app
6
+
7
+ app = typer.Typer(
8
+ no_args_is_help=True,
9
+ name="cache",
10
+ help="Commands for working with the cache database.",
11
+ )
12
+ app.add_typer(info_app)
@@ -0,0 +1,11 @@
1
+ import typer
2
+
3
+ app = typer.Typer(no_args_is_help=True)
4
+
5
+
6
+ app.command()
7
+
8
+
9
+ def info(ctx: typer.Context) -> None:
10
+ """Display information about the cache database."""
11
+ typer.echo("Cache Info Placeholder...")
@@ -0,0 +1,41 @@
1
+ """Helper functions for the api-request CLI."""
2
+
3
+ import sys
4
+ from typing import cast
5
+
6
+ from typer import Context
7
+
8
+ from pfmsoft.api_request.settings import SETTINGS_KEY, ApiRequestSettings
9
+
10
+
11
+ def get_api_request_settings_from_context(ctx: Context) -> ApiRequestSettings:
12
+ """Return ApiRequestSettings stored in the Typer context.
13
+
14
+ Args:
15
+ ctx: Typer command context whose obj mapping should contain the
16
+ initialized ApiRequestSettings.
17
+
18
+ Returns:
19
+ ApiRequestSettings stored under the `settings.SETTINGS_KEY` key.
20
+
21
+ Raises:
22
+ ValueError: If the context does not contain initialized ApiRequestSettings.
23
+ """
24
+ if ctx.obj is None or SETTINGS_KEY not in ctx.obj:
25
+ raise ValueError("ApiRequestSettings not found in context.")
26
+ return cast(ApiRequestSettings, ctx.obj[SETTINGS_KEY])
27
+
28
+
29
+ def get_stdin() -> str:
30
+ """Read piped or redirected stdin content until EOF.
31
+
32
+ Returns:
33
+ Full stdin content as a string.
34
+
35
+ Raises:
36
+ ValueError: If stdin is attached to an interactive terminal instead
37
+ of a pipe or redirected input source.
38
+ """
39
+ if sys.stdin.isatty():
40
+ raise ValueError("Error: provide a file path or pipe data via stdin.")
41
+ return sys.stdin.read()
@@ -0,0 +1,52 @@
1
+ """Main entry point for the api-request CLI.
2
+
3
+ NOTE: This is not currently the entry point, as so far there is only one command.
4
+ If additional commands are added, this module will be the entry point and will
5
+ dispatch to subcommands.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import asdict
10
+
11
+ import typer
12
+
13
+ from pfmsoft.api_request import __app_name__, __version__
14
+ from pfmsoft.api_request.cli import app as api_request_app
15
+ from pfmsoft.api_request.cli.helpers import get_api_request_settings_from_context
16
+ from pfmsoft.api_request.logging_config import setup_logging
17
+ from pfmsoft.api_request.settings import SETTINGS_KEY, get_settings
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def default_options(ctx: typer.Context) -> None:
23
+ """Initialize settings and logging for standalone CLI execution.
24
+
25
+ Args:
26
+ ctx: Typer command context used to store shared application settings
27
+ for downstream subcommands.
28
+
29
+ Notes:
30
+ The resolved ApiRequestSettings object is stored in ctx.obj under the
31
+ `api-request-settings` key.
32
+ """
33
+ settings = get_settings()
34
+ setup_logging(log_dir=settings.logging_directory)
35
+ ctx.obj = {SETTINGS_KEY: settings}
36
+ logger.info(
37
+ f"Starting {__app_name__} v{__version__} with settings: {asdict(settings)!r}"
38
+ )
39
+
40
+
41
+ app = typer.Typer(callback=default_options, no_args_is_help=True)
42
+
43
+
44
+ @app.command()
45
+ def version(ctx: typer.Context) -> None:
46
+ """Print the version of the api-request CLI."""
47
+ settings = get_api_request_settings_from_context(ctx)
48
+ typer.echo(f"{__app_name__} v{__version__}")
49
+ typer.echo(f"Settings: {asdict(settings)!r}")
50
+
51
+
52
+ app.add_typer(api_request_app)
@@ -0,0 +1,11 @@
1
+ """Commands for working with requests."""
2
+
3
+ import typer
4
+
5
+ from .request import app as request_app
6
+ from .validate import app as validate_app
7
+
8
+ app = typer.Typer(no_args_is_help=True)
9
+
10
+ app.add_typer(request_app)
11
+ app.add_typer(validate_app)