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,143 @@
1
+ """Intermediate request and response models for request orchestration.
2
+
3
+ These models are used internally by the request pipeline to represent work in
4
+ progress and normalized intermediate outcomes before conversion into the public
5
+ response models.
6
+
7
+ They are importable across implementation modules and tests, but they are not
8
+ part of the stable public package API and may change during internal refactors.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+ from uuid import UUID
14
+
15
+ from pfmsoft.api_request.request.models import Request, ResponseMetadata, Source
16
+
17
+
18
+ @dataclass(slots=True, kw_only=True)
19
+ class IntermediateRequestBase:
20
+ """Base model for intermediate request pipeline objects."""
21
+
22
+ request: Request
23
+ """The original request that generated this intermediate object."""
24
+
25
+
26
+ @dataclass(slots=True, kw_only=True)
27
+ class IntermediateResponseBase(IntermediateRequestBase):
28
+ """Base model for intermediate responses used during request processing."""
29
+
30
+
31
+ @dataclass(slots=True, kw_only=True)
32
+ class FailedRequestBase(IntermediateResponseBase):
33
+ """Base model for failed intermediate responses."""
34
+
35
+
36
+ @dataclass(slots=True, kw_only=True)
37
+ class FailNoResponse(FailedRequestBase):
38
+ """Intermediate failure used when no response was received."""
39
+
40
+ error_message: str
41
+ """An error message describing the failure."""
42
+
43
+
44
+ @dataclass(slots=True, kw_only=True)
45
+ class FailWithResponse(FailedRequestBase):
46
+ """Intermediate failure used when a response indicates failure."""
47
+
48
+ metadata: ResponseMetadata
49
+ """The metadata of the response, including status code and headers."""
50
+ json: Any
51
+ """The parsed JSON body of the response."""
52
+
53
+
54
+ @dataclass(slots=True, kw_only=True)
55
+ class UnprocessedRequest(IntermediateRequestBase):
56
+ """Intermediate request that has not yet been sent."""
57
+
58
+
59
+ @dataclass(slots=True, kw_only=True)
60
+ class SuccessfulResponseBase(IntermediateResponseBase):
61
+ """Base model for successful intermediate responses."""
62
+
63
+ metadata: ResponseMetadata
64
+ """The metadata of the response, including status code and headers."""
65
+ json: Any
66
+ """The parsed JSON body of the response."""
67
+ source: Source
68
+ """The source of the response, for example cache or network."""
69
+
70
+
71
+ @dataclass(slots=True, kw_only=True)
72
+ class SuccessfulResponse(SuccessfulResponseBase):
73
+ """Successful intermediate response used during request processing."""
74
+
75
+
76
+ @dataclass(slots=True, kw_only=True)
77
+ class CachableRequest(UnprocessedRequest):
78
+ """Intermediate request that has an associated cache key."""
79
+
80
+ cache_key: UUID
81
+ """The cache key associated with this request."""
82
+
83
+
84
+ @dataclass(slots=True, kw_only=True)
85
+ class RequestFromStaleCache(CachableRequest):
86
+ """A stale cached request that needs revalidation."""
87
+
88
+ etag: str | None
89
+ """The ETag of the cached response."""
90
+ last_modified: str | None = None
91
+ """The Last-Modified header of the cached response, if available."""
92
+
93
+ @property
94
+ def conditional_headers(self) -> dict[str, str]:
95
+ """Build conditional request headers for stale-cache revalidation.
96
+
97
+ Returns:
98
+ A dictionary containing `If-None-Match` and/or
99
+ `If-Modified-Since`.
100
+
101
+ Raises:
102
+ ValueError: If both validators are missing.
103
+ """
104
+ headers: dict[str, str] = {}
105
+ if not self.etag and not self.last_modified:
106
+ raise ValueError(
107
+ "At least one of ETag or Last-Modified must be provided for a stale cache request."
108
+ )
109
+ if self.etag:
110
+ headers["If-None-Match"] = self.etag
111
+ if self.last_modified:
112
+ headers["If-Modified-Since"] = self.last_modified
113
+ return headers
114
+
115
+
116
+ @dataclass(slots=True, kw_only=True)
117
+ class Response304FromStaleCache(SuccessfulResponseBase):
118
+ """A cached-body response refreshed by an HTTP 304 revalidation."""
119
+
120
+ source: Source = Source.CACHE_304
121
+
122
+
123
+ @dataclass(slots=True, kw_only=True)
124
+ class Response200FromStaleCache(SuccessfulResponseBase):
125
+ """A refreshed cached response replaced by an HTTP 200 revalidation."""
126
+
127
+ source: Source = Source.CACHE_200
128
+
129
+
130
+ __all__ = [
131
+ "CachableRequest",
132
+ "FailNoResponse",
133
+ "FailWithResponse",
134
+ "FailedRequestBase",
135
+ "IntermediateRequestBase",
136
+ "IntermediateResponseBase",
137
+ "RequestFromStaleCache",
138
+ "Response200FromStaleCache",
139
+ "Response304FromStaleCache",
140
+ "SuccessfulResponse",
141
+ "SuccessfulResponseBase",
142
+ "UnprocessedRequest",
143
+ ]
@@ -0,0 +1,355 @@
1
+ """Public request/response models used by API orchestration.
2
+
3
+ This module defines immutable dataclasses for request and response objects,
4
+ plus convenience properties for common HTTP metadata lookups.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from enum import StrEnum
10
+ from typing import Any
11
+ from uuid import UUID, uuid4
12
+
13
+ from pydantic import RootModel
14
+ from whenever import Instant
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ type PARAMETER = str | int | float
19
+ """Supported query-parameter value types."""
20
+
21
+
22
+ #######################################################################################
23
+ # Request Models
24
+ #######################################################################################
25
+
26
+
27
+ @dataclass(slots=True, kw_only=True)
28
+ class Request:
29
+ """Represents one outbound HTTP request definition.
30
+
31
+ The model is immutable and can be safely shared across async tasks.
32
+ """
33
+
34
+ request_key: UUID = field(default_factory=uuid4)
35
+ """The UUID key for the request."""
36
+ url: str
37
+ """The URL of the API request."""
38
+ method: str
39
+ """The HTTP method of the API request (e.g., GET, POST)."""
40
+ headers: dict[str, str] = field(default_factory=dict[str, str])
41
+ """The headers of the API request."""
42
+ body: Any | None = None
43
+ """The body of the API request, if applicable."""
44
+ parameters: dict[str, PARAMETER] = field(default_factory=dict[str, PARAMETER])
45
+ """The query parameters of the API request."""
46
+ cache_key: UUID | None = None
47
+ """The UUID key for the cached response. If None, the response is not cached."""
48
+ rate_key: str | None = None
49
+ """Optional key used by rate-limiter implementations to group requests."""
50
+
51
+ def purge_secrets(self) -> None:
52
+ """Purge authorization headers from the request for security purposes."""
53
+ # replace access tokens with REDACTED in headers
54
+ redacted_headers = {
55
+ k: "REDACTED" if k.lower() == "authorization" else v
56
+ for k, v in self.headers.items()
57
+ }
58
+ self.headers = redacted_headers
59
+
60
+
61
+ #######################################################################################
62
+ # Response Models
63
+ #######################################################################################
64
+ class Source(StrEnum):
65
+ """Enumeration of possible sources for a response."""
66
+
67
+ CACHE = "cache"
68
+ """The response was retrieved from the cache."""
69
+ CACHE_304 = "cache-304"
70
+ """The cached response was revalidated and remained current."""
71
+ CACHE_200 = "cache-200"
72
+ """The cached response was revalidated and replaced by fresh content."""
73
+ NETWORK = "network"
74
+ """The response was retrieved from the network."""
75
+
76
+
77
+ @dataclass(slots=True, kw_only=True, frozen=True)
78
+ class X_ratelimit:
79
+ """Parsed X-RateLimit header values."""
80
+
81
+ group: str
82
+ """X-RateLimit group identifier."""
83
+ limit: str
84
+ """Configured request budget for the current window."""
85
+ remaining: str
86
+ """Remaining budget in the current window."""
87
+ used: str
88
+ """Consumed budget in the current window."""
89
+
90
+
91
+ @dataclass(slots=True, kw_only=True)
92
+ class ResponseMetadata:
93
+ """Represents transport metadata for a single HTTP response.
94
+
95
+ Time unit conventions:
96
+ - `elapsed` is stored in microseconds.
97
+ - `received_timestamp` is stored in Unix nanoseconds.
98
+ - `expires_at` (derived) is stored in Unix seconds.
99
+ """
100
+
101
+ status_code: int
102
+ """The HTTP status code of the response."""
103
+ reason_phrase: str
104
+ """The reason phrase associated with the status code."""
105
+ url: str
106
+ """The URL that was requested to obtain this response."""
107
+ elapsed: int
108
+ """The time taken to receive the response, in microseconds, because the source is a timedelta."""
109
+ bytes_downloaded: int
110
+ """The number of bytes downloaded in the response body."""
111
+ headers: tuple[tuple[str, str], ...] = field(
112
+ default_factory=tuple[tuple[str, str], ...]
113
+ )
114
+ """The headers of the response as a tuple of key-value pairs."""
115
+ received_timestamp: int = -1
116
+ """The timestamp when the response was received, as a Unix timestamp in nanoseconds."""
117
+ _headers_lower: dict[str, str] = field(
118
+ init=False, repr=False, default_factory=dict[str, str]
119
+ )
120
+ """A lower case version of the headers for easier access to common headers like ETag and Last-Modified."""
121
+
122
+ def __post_init__(self):
123
+ """Build a lowercase header lookup map for convenience accessors.
124
+
125
+ If duplicate header names differ only by case, the later value wins
126
+ in the lowercase map and a warning is logged.
127
+ """
128
+ self._init_lowercase_headers()
129
+
130
+ def _init_lowercase_headers(self):
131
+ """Initialize the lowercase headers mapping."""
132
+ self._headers_lower = {k.lower(): v for k, v in self.headers}
133
+ if len(self.headers) != len(self._headers_lower):
134
+ logger.warning(
135
+ "Duplicate headers found when converting to lower case. This may lead to "
136
+ "unexpected behavior when accessing headers. Original headers: %s, Lower "
137
+ "case headers: %s",
138
+ self.headers,
139
+ self._headers_lower,
140
+ )
141
+
142
+ @property
143
+ def headers_lower(self) -> dict[str, str]:
144
+ """Return lowercase header mapping used by convenience properties."""
145
+ return self._headers_lower
146
+
147
+ @property
148
+ def received_at(self) -> Instant:
149
+ """Convert `received_timestamp` to an Instant.
150
+
151
+ Raises:
152
+ ValueError: If `received_timestamp` is unset (`-1`).
153
+ """
154
+ if self.received_timestamp != -1:
155
+ return Instant.from_timestamp_nanos(self.received_timestamp)
156
+ raise ValueError("Received timestamp is not set.")
157
+
158
+ @property
159
+ def etag(self) -> str | None:
160
+ """Extract the ETag from the response headers, if present."""
161
+ return self.headers_lower.get("etag")
162
+
163
+ @property
164
+ def last_modified(self) -> str | None:
165
+ """Extract the Last-Modified header from the response headers, if present."""
166
+ return self.headers_lower.get("last-modified")
167
+
168
+ @property
169
+ def expires(self) -> str | None:
170
+ """Extract the Expires header from the response headers, if present."""
171
+ return self.headers_lower.get("expires")
172
+
173
+ @property
174
+ def date(self) -> str | None:
175
+ """Extract the Date header from the response headers, if present."""
176
+ return self.headers_lower.get("date")
177
+
178
+ @property
179
+ def date_as_instant(self) -> Instant | None:
180
+ """Convert the Date header to an Instant, if possible."""
181
+ date_str = self.date
182
+ if date_str:
183
+ try:
184
+ return Instant.parse_rfc2822(date_str)
185
+ except ValueError:
186
+ pass
187
+ return None
188
+
189
+ @property
190
+ def cache_control(self) -> str | None:
191
+ """Extract the Cache-Control header from the response headers, if present."""
192
+ return self.headers_lower.get("cache-control")
193
+
194
+ @property
195
+ def max_age(self) -> int | None:
196
+ """Extract the max-age directive from the Cache-Control header, if present."""
197
+ cache_control = self.cache_control
198
+ if cache_control:
199
+ directives = cache_control.split(",")
200
+ for directive in directives:
201
+ if "max-age" in directive:
202
+ try:
203
+ return int(directive.split("=")[1].strip())
204
+ except IndexError, ValueError:
205
+ pass
206
+ return None
207
+
208
+ @property
209
+ def expires_at(self) -> int | None:
210
+ """Return cache expiration instant derived from response headers.
211
+
212
+ Precedence:
213
+ 1. `Cache-Control: max-age` with a valid `Date` header.
214
+ 2. `Expires` header.
215
+ 3. `None` when neither can be parsed.
216
+ """
217
+ if self.max_age is not None and self.date is not None:
218
+ try:
219
+ response_date = Instant.parse_rfc2822(self.date)
220
+ return response_date.add(seconds=self.max_age).timestamp()
221
+ except ValueError:
222
+ pass
223
+ if self.expires:
224
+ try:
225
+ return Instant.parse_rfc2822(self.expires).timestamp()
226
+ except ValueError:
227
+ pass
228
+ return None
229
+
230
+ @property
231
+ def pages(self) -> int:
232
+ """Extract the number of pages from the X-Pages header, if present."""
233
+ pages = self.headers_lower.get("x-pages", 1)
234
+ return int(pages)
235
+
236
+ @property
237
+ def ratelimit(self) -> X_ratelimit:
238
+ """Extract the rate limit information from the X-RateLimit headers, if present."""
239
+ group = self.headers_lower.get("x-ratelimit-group", "unknown")
240
+ limit = self.headers_lower.get("x-ratelimit-limit", "unknown")
241
+ remaining = self.headers_lower.get("x-ratelimit-remaining", "unknown")
242
+ used = self.headers_lower.get("x-ratelimit-used", "unknown")
243
+ return X_ratelimit(group=group, limit=limit, remaining=remaining, used=used)
244
+
245
+ def purge_secrets(self) -> None:
246
+ """Purge authorization headers from the response metadata for security purposes."""
247
+ # replace access tokens with REDACTED in headers
248
+ # check the headers tuple for any authorization headers and redact them
249
+ redacted_headers = tuple(
250
+ (k, "REDACTED") if k.lower() == "authorization" else (k, v)
251
+ for k, v in self.headers
252
+ )
253
+ self.headers = redacted_headers
254
+ self._init_lowercase_headers() # reinitialize lowercase headers after purging
255
+
256
+ def serialize(self, indent: int | None = None) -> str:
257
+ """Serialize the ResponseMetadata object to a JSON string."""
258
+ return ResponseMetadataRoot(self).model_dump_json(indent=indent)
259
+
260
+ @classmethod
261
+ def deserialize(cls, json_str: str) -> ResponseMetadata:
262
+ """Deserialize a ResponseMetadata object from a JSON string."""
263
+ return ResponseMetadataRoot.model_validate_json(json_str).root
264
+
265
+
266
+ @dataclass(slots=True, kw_only=True, frozen=True)
267
+ class Response:
268
+ """Represents one successful API response returned to callers."""
269
+
270
+ metadata: ResponseMetadata
271
+ """The metadata of the response, including status code, headers, etc."""
272
+ json: Any
273
+ """The parsed JSON body of the response."""
274
+ request: Request
275
+ """The original request that generated this response."""
276
+ source: Source
277
+ """The source of the response, for example cache or network."""
278
+
279
+ @classmethod
280
+ def deserialize(cls, json_str: str) -> Response:
281
+ """Deserialize a Response from JSON text."""
282
+ value = ResponseRoot.model_validate_json(json_str).root
283
+ return value
284
+
285
+ def purge_secrets(self) -> None:
286
+ """Purge authorization headers from the response metadata for security purposes."""
287
+ self.metadata.purge_secrets()
288
+ self.request.purge_secrets()
289
+
290
+ def serialize(self, indent: int | None = None) -> str:
291
+ """Serialize the Response object to a JSON string."""
292
+ return ResponseRoot(self).model_dump_json(indent=indent)
293
+
294
+
295
+ ResponseRoot = RootModel[Response]
296
+
297
+
298
+ @dataclass(slots=True, kw_only=True, frozen=True)
299
+ class FailedResponse:
300
+ """Represents a failed ESI response, typically due to an error status code."""
301
+
302
+ metadata: ResponseMetadata | None = None
303
+ """The metadata of the response, including status code, headers, etc. May be None
304
+ if the request failed before receiving a response."""
305
+ json: Any | None = None
306
+ """The parsed JSON body of the response. May be None if unavailable."""
307
+ request: Request
308
+ """The original request that generated this failed response."""
309
+ error_messages: list[str] = field(default_factory=list[str])
310
+ """An optional list of error messages describing the failure."""
311
+
312
+ def purge_secrets(self) -> None:
313
+ """Purge authorization headers from the response metadata for security purposes."""
314
+ if self.metadata:
315
+ self.metadata.purge_secrets()
316
+ self.request.purge_secrets()
317
+
318
+
319
+ @dataclass(slots=True, kw_only=True, frozen=True)
320
+ class Responses:
321
+ """Container for batched request outcomes."""
322
+
323
+ successful: dict[UUID, Response] = field(default_factory=dict[UUID, Response])
324
+ """A dictionary of successful responses, keyed by request UUID."""
325
+ failed: dict[UUID, FailedResponse] = field(
326
+ default_factory=dict[UUID, FailedResponse]
327
+ )
328
+ """A dictionary of failed responses, keyed by request UUID."""
329
+
330
+ def to_string(self, indent: int) -> str:
331
+ """Serialize this Responses container as JSON text.
332
+
333
+ Args:
334
+ indent: Indentation level passed to pydantic JSON serializer.
335
+ """
336
+ json_str = ResponsesRoot(root=self).model_dump_json(indent=indent)
337
+ return json_str
338
+
339
+ @classmethod
340
+ def from_string(cls, json_str: str) -> Responses:
341
+ """Deserialize a Responses container from JSON text."""
342
+ value = ResponsesRoot.model_validate_json(json_str).root
343
+ return value
344
+
345
+
346
+ type Requests = dict[UUID, Request]
347
+ """Batch request mapping keyed by request UUID."""
348
+
349
+ #######################################################################################
350
+ # Root Models
351
+ #######################################################################################
352
+ ResponseMetadataRoot = RootModel[ResponseMetadata]
353
+ ResponseRoot = RootModel[Response]
354
+ RequestsRoot = RootModel[Requests]
355
+ ResponsesRoot = RootModel[Responses]
@@ -0,0 +1,54 @@
1
+ """Request orchestration protocol contracts.
2
+
3
+ This module defines the public behavioral surface expected from requester
4
+ implementations.
5
+
6
+ Typical usage:
7
+
8
+ ```python
9
+ from uuid import uuid4
10
+
11
+ from api_request import ApiRequester, Request
12
+ from api_request.cache import InMemoryCache
13
+ from api_request.rate_limit import AiolimiterRateLimiterFactory
14
+
15
+ request = Request(
16
+ request_key=uuid4(),
17
+ method="GET",
18
+ url="https://esi.evetech.net/status/",
19
+ )
20
+
21
+ async with ApiRequester(
22
+ cache_factory=InMemoryCache,
23
+ rate_limiter_factory=AiolimiterRateLimiterFactory(max_rate=100.0),
24
+ ) as requester:
25
+ responses = await requester.process_requests({request.request_key: request})
26
+ ```
27
+ """
28
+
29
+ from typing import Protocol
30
+
31
+ from pfmsoft.api_request.request.models import (
32
+ Requests,
33
+ Responses,
34
+ )
35
+
36
+
37
+ class ApiRequesterProtocol(Protocol):
38
+ """Protocol for batched request orchestration.
39
+
40
+ Contract:
41
+ - Input keys in `requests` are preserved in output mappings.
42
+ - Successful request outcomes are returned in `Responses.successful`.
43
+ - Request-scoped failures are returned in `Responses.failed`.
44
+ - Implementations are typically used as async context managers to
45
+ provision HTTP/cache/rate-limit dependencies.
46
+ - Implementations may raise for fatal infrastructure/configuration errors.
47
+ """
48
+
49
+ async def process_requests(
50
+ self,
51
+ requests: Requests,
52
+ ) -> Responses:
53
+ """Process a batch of requests and return normalized success/failure maps."""
54
+ ...
@@ -0,0 +1,65 @@
1
+ """Settings for the api-request module."""
2
+
3
+ import logging
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+ from uuid import NAMESPACE_DNS, uuid5
7
+
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+ from typer import get_app_dir
10
+
11
+ from pfmsoft.api_request import (
12
+ __app_name__,
13
+ __project_namespace__,
14
+ __url__,
15
+ __version__,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ USER_AGENT = f"{__app_name__} ({__version__}) (+{__url__})"
21
+ APP_DOMAIN = f"{__project_namespace__}.{__app_name__}"
22
+ APP_NAMESPACE = uuid5(NAMESPACE_DNS, APP_DOMAIN)
23
+ ENV_PREFIX = APP_DOMAIN.replace(".", "_").replace("-", "_").upper() + "_"
24
+ SETTINGS_KEY = ENV_PREFIX + "SETTINGS"
25
+
26
+
27
+ @dataclass(slots=True, kw_only=True)
28
+ class ApiRequestSettings:
29
+ """Settings for the api-request module."""
30
+
31
+ application_directory: Path
32
+ logging_directory: Path
33
+ web_cache_path: Path
34
+
35
+
36
+ class ApiRequestSettingsPydantic(BaseSettings):
37
+ """Pydantic settings for the api-request module."""
38
+
39
+ application_directory: Path = Path(get_app_dir(__app_name__))
40
+
41
+ model_config = SettingsConfigDict(
42
+ env_prefix=ENV_PREFIX,
43
+ env_file=(".env", ".env.dev"),
44
+ env_file_encoding="utf-8",
45
+ )
46
+
47
+
48
+ def get_settings() -> ApiRequestSettings:
49
+ """Get the settings for the api-request module."""
50
+ logger.info("Loading settings from environment variables...")
51
+ logger.info(f"Environment variable prefix: {ENV_PREFIX}")
52
+ pydantic_settings = ApiRequestSettingsPydantic()
53
+ logger.info(
54
+ f"Loaded settings from environment variables: {pydantic_settings.model_dump()}"
55
+ )
56
+ application_directory = pydantic_settings.application_directory.resolve()
57
+ if not application_directory.exists():
58
+ application_directory.mkdir(parents=True, exist_ok=True)
59
+ settings = ApiRequestSettings(
60
+ application_directory=application_directory,
61
+ logging_directory=application_directory / "logs",
62
+ web_cache_path=application_directory / "api_requests_web_cache.sqlite",
63
+ )
64
+ logger.info(f"Resolved application settings: {asdict(settings)}")
65
+ return settings