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,202 @@
1
+ """Logging configuration helpers for Eve Auth Manager.
2
+
3
+ Provides the default dictConfig-based logging setup used by the application,
4
+ including console output and rotating file handlers.
5
+
6
+ Notes:
7
+ The logging configuration dictionary is intended to be customized in the
8
+ handlers and loggers sections if the application needs different output
9
+ destinations or verbosity.
10
+ """
11
+
12
+ import logging
13
+ import logging.config
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+ logger.addHandler(logging.NullHandler())
19
+
20
+
21
+ def setup_logging(log_dir: Path) -> None:
22
+ """Configure application logging handlers and formatters.
23
+
24
+ Creates the target log directory when needed, then installs the default
25
+ dictConfig-based logging setup for console and rotating file output.
26
+
27
+ Args:
28
+ log_dir: Directory where log files should be created.
29
+
30
+ Notes:
31
+ The configured root logger writes DEBUG-and-above messages to the info
32
+ rotating file, WARNING-and-above messages to the warning rotating
33
+ file, and CRITICAL-and-above messages to the console handler.
34
+ """
35
+ log_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ # dictConfig replaces root handlers; keep deferred buffers so they can be
38
+ # flushed after real handlers are installed.
39
+ root_logger = logging.getLogger()
40
+ deferred_handlers = [
41
+ h for h in root_logger.handlers if isinstance(h, DeferredHandler)
42
+ ]
43
+
44
+ log_config: dict[str, Any] = {
45
+ "version": 1,
46
+ "disable_existing_loggers": False,
47
+ "formatters": {
48
+ "consoleFormatter": {
49
+ "format": "%(asctime)s | %(name)s | %(levelname)s : %(message)s",
50
+ },
51
+ "fileFormatter": {
52
+ "format": "%(asctime)s | %(name)s | %(levelname)-8s : %(message)s",
53
+ },
54
+ "brief": {
55
+ "datefmt": "%H:%M:%S",
56
+ "format": "%(levelname)-8s; %(name)s; %(message)s;",
57
+ },
58
+ "single-line": {
59
+ "datefmt": "%H:%M:%S",
60
+ "format": "%(levelname)-8s; %(asctime)s; %(name)s; %(module)s:%(funcName)s;%(lineno)d: %(message)s",
61
+ },
62
+ "multi-process": {
63
+ "datefmt": "%H:%M:%S",
64
+ "format": "%(levelname)-8s; [%(process)d]; %(name)s; %(module)s:%(funcName)s;%(lineno)d: %(message)s",
65
+ },
66
+ "multi-thread": {
67
+ "datefmt": "%H:%M:%S",
68
+ "format": "%(levelname)-8s; %(threadName)s; %(name)s; %(module)s:%(funcName)s;%(lineno)d: %(message)s",
69
+ },
70
+ "verbose": {
71
+ "format": "%(levelname)-8s; [%(process)d]; %(threadName)s; %(name)s; %(module)s:%(funcName)s;%(lineno)d"
72
+ ": %(message)s"
73
+ },
74
+ "multiline": {
75
+ "format": "Level: %(levelname)s\nTime: %(asctime)s\nProcess: %(process)d\nThread: %(threadName)s\nLogger"
76
+ ": %(name)s\nPath: %(module)s:%(lineno)d\nFunction :%(funcName)s\nMessage: %(message)s\n"
77
+ },
78
+ "mine": {
79
+ "format": "%(asctime)s | %(levelname)-8s | %(funcName)s | %(message)s | [in %(pathname)s | %(lineno)d]"
80
+ },
81
+ "mine-multi": {
82
+ "format": "%(asctime)s | %(levelname)-8s | %(funcName)s | [in %(pathname)s | %(lineno)d]\n\t %(message)s"
83
+ },
84
+ },
85
+ "handlers": {
86
+ "file": {
87
+ "filename": log_dir / "debug.log",
88
+ "level": "DEBUG",
89
+ "class": "logging.FileHandler",
90
+ "formatter": "mine",
91
+ },
92
+ "console": {
93
+ "level": "CRITICAL",
94
+ "class": "logging.StreamHandler",
95
+ "formatter": "consoleFormatter",
96
+ },
97
+ "rot_file_info": {
98
+ "class": "logging.handlers.RotatingFileHandler",
99
+ "formatter": "mine-multi",
100
+ "level": "INFO",
101
+ "filename": log_dir / "rotating_info.log",
102
+ "mode": "a",
103
+ "encoding": "utf-8",
104
+ "maxBytes": 10000000,
105
+ "backupCount": 10,
106
+ },
107
+ "rot_file_warn": {
108
+ "class": "logging.handlers.RotatingFileHandler",
109
+ "formatter": "mine-multi",
110
+ "level": "WARNING",
111
+ "filename": log_dir / "rotating_warn.log",
112
+ "mode": "a",
113
+ "encoding": "utf-8",
114
+ "maxBytes": 500000,
115
+ "backupCount": 4,
116
+ },
117
+ },
118
+ "loggers": {
119
+ "": {
120
+ "handlers": ["rot_file_info", "rot_file_warn", "console"],
121
+ "level": "DEBUG",
122
+ },
123
+ },
124
+ }
125
+ logging.config.dictConfig(log_config)
126
+
127
+ for deferred in deferred_handlers:
128
+ if deferred not in root_logger.handlers:
129
+ root_logger.addHandler(deferred)
130
+
131
+
132
+ class DeferredHandler(logging.Handler):
133
+ """Buffers log records until real handlers are ready, then replays them."""
134
+
135
+ def __init__(self, level: int = logging.NOTSET) -> None:
136
+ """Initialize an in-memory record buffer."""
137
+ super().__init__(level)
138
+ self.buffer: list[logging.LogRecord] = []
139
+
140
+ def emit(self, record: logging.LogRecord) -> None:
141
+ """Store records until configured handlers are available."""
142
+ self.buffer.append(record)
143
+
144
+ def flush_to(self, handlers: list[logging.Handler]) -> None:
145
+ """Replay buffered records through the given handlers, then clear."""
146
+ for record in self.buffer:
147
+ for handler in handlers:
148
+ if record.levelno >= handler.level:
149
+ handler.handle(record)
150
+ self.buffer.clear()
151
+
152
+
153
+ def init_deferred_handler() -> None:
154
+ """Initialize a deferred handler for logging.
155
+
156
+ This funtion sets up a deferred handler that can be used to buffer log messages in
157
+ memory before they are flushed to the appropriate handlers.
158
+
159
+ This is useful for capturing log messages emitted before the logging configuration
160
+ is fully set up. The deferred handler will buffer these messages in memory and can
161
+ be flushed to the appropriate handlers once they are configured.
162
+
163
+ DeferredHandler is added to the root logger, and it captures all log messages at
164
+ DEBUG level and above.
165
+
166
+ It must be flushed to the appropriate handlers once they are configured to log the
167
+ buffered messages.
168
+ """
169
+ root_logger = logging.getLogger()
170
+ if any(isinstance(h, DeferredHandler) for h in root_logger.handlers):
171
+ return
172
+ root_logger.setLevel(logging.DEBUG) # capture everything; filter at handler level
173
+ deferred = DeferredHandler()
174
+ root_logger.addHandler(deferred)
175
+
176
+
177
+ def flush_deferred_handler() -> None:
178
+ """Replay buffered records into whatever handlers are currently configured.
179
+
180
+ Call this after your real logging setup (dictConfig, basicConfig, manual
181
+ addHandler calls, etc.) has run. Finds any DeferredHandler on the root
182
+ logger, replays its buffer into all *other* handlers currently attached,
183
+ then removes itself.
184
+
185
+ Safe to call multiple times or when no DeferredHandler is present — it's
186
+ a no-op in that case rather than an error.
187
+ """
188
+ root_logger = logging.getLogger()
189
+
190
+ deferred_handlers = [
191
+ h for h in root_logger.handlers if isinstance(h, DeferredHandler)
192
+ ]
193
+ if not deferred_handlers:
194
+ return # nothing to flush, or already finalized
195
+
196
+ real_handlers = [
197
+ h for h in root_logger.handlers if not isinstance(h, DeferredHandler)
198
+ ]
199
+
200
+ for deferred in deferred_handlers:
201
+ deferred.flush_to(real_handlers)
202
+ root_logger.removeHandler(deferred)
File without changes
@@ -0,0 +1,24 @@
1
+ """Rate-limiter implementations used by request orchestration.
2
+
3
+ Exports:
4
+ - `AiolimiterRateLimiter`: concrete limiter wrapper around
5
+ `aiolimiter.AsyncLimiter`.
6
+ - `AiolimiterRateLimiterFactory`: dependency-injection factory used by
7
+ requester construction.
8
+
9
+ Typical usage:
10
+
11
+ ```python
12
+ from api_request.rate_limit import AiolimiterRateLimiterFactory
13
+
14
+ factory = AiolimiterRateLimiterFactory(max_rate=100.0, time_period=60.0)
15
+ limiter = factory()
16
+
17
+ async with limiter.limit("market/orders"):
18
+ ...
19
+ ```
20
+ """
21
+
22
+ from .aio_limiter import AiolimiterRateLimiter, AiolimiterRateLimiterFactory
23
+
24
+ __all__ = ["AiolimiterRateLimiter", "AiolimiterRateLimiterFactory"]
@@ -0,0 +1,89 @@
1
+ """Rate limiter implementation using aiolimiter.
2
+
3
+ This module provides a protocol-compatible wrapper around one shared
4
+ `aiolimiter.AsyncLimiter` and a small factory for requester construction.
5
+ """
6
+
7
+ from contextlib import AbstractAsyncContextManager
8
+ from dataclasses import dataclass
9
+
10
+ from aiolimiter import AsyncLimiter
11
+
12
+ from pfmsoft.api_request.rate_limit.protocols import (
13
+ RateLimiterFactoryProtocol,
14
+ RateLimiterProtocol,
15
+ )
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class AiolimiterRateLimiter(RateLimiterProtocol):
20
+ """Shared rate limiter backed by one AsyncLimiter instance.
21
+
22
+ This implementation enforces a single global bucket for all operations.
23
+ The provided `subject` value is currently ignored.
24
+
25
+ """
26
+
27
+ limiter: AsyncLimiter
28
+ """The shared AsyncLimiter used by all gated operations."""
29
+
30
+ def limit(self, subject: str | None) -> AbstractAsyncContextManager[None]:
31
+ """Return the shared AsyncLimiter as an async context manager.
32
+
33
+ NOTE: `subject` is accepted for protocol compatibility and future
34
+ extensibility. It is currently ignored.
35
+
36
+ Args:
37
+ subject: Domain input for the gated operation.
38
+
39
+ Returns:
40
+ An async context manager that acquires capacity from the shared limiter.
41
+ """
42
+ return self.limiter
43
+
44
+
45
+ @dataclass(slots=True, frozen=True)
46
+ class AiolimiterRateLimiterFactory(RateLimiterFactoryProtocol):
47
+ """Factory that builds shared aiolimiter-backed rate limiters.
48
+
49
+ Use one factory per limiter configuration and one produced limiter per
50
+ requester/task-group that should share budget.
51
+
52
+ This factory is designed to be passed into requester constructors that build
53
+ their shared limiter in `__aenter__`.
54
+
55
+ Example:
56
+ ```python
57
+ from api_request.rate_limit import AiolimiterRateLimiterFactory
58
+
59
+ factory = AiolimiterRateLimiterFactory(max_rate=100.0, time_period=60.0)
60
+ limiter = factory()
61
+ async with limiter.limit("esi-status"):
62
+ ...
63
+ ```
64
+ """
65
+
66
+ max_rate: float
67
+ """The maximum acquisitions allowed within `time_period`.
68
+
69
+ This should be greater than zero.
70
+ """
71
+ time_period: float = 60.0
72
+ """The limiting window length in seconds.
73
+
74
+ This should be greater than zero.
75
+ """
76
+
77
+ def __call__(self) -> AiolimiterRateLimiter:
78
+ """Build a shared rate limiter instance.
79
+
80
+ Returns:
81
+ A shared rate limiter backed by one configured AsyncLimiter.
82
+
83
+ Raises:
84
+ ValueError: Propagated from `AsyncLimiter` for invalid constructor
85
+ arguments.
86
+ """
87
+ return AiolimiterRateLimiter(
88
+ limiter=AsyncLimiter(self.max_rate, self.time_period)
89
+ )
@@ -0,0 +1,101 @@
1
+ """Rate-limiter protocol contracts.
2
+
3
+ This module defines the protocol shape used to gate outbound request work.
4
+ Concrete implementations live in sibling modules.
5
+
6
+ Protocol contract:
7
+ - Each `limit(subject)` call returns an async context manager used to gate
8
+ one operation.
9
+ - Implementations may choose how `subject` influences behavior.
10
+
11
+ The default concrete implementation in this package currently uses one shared
12
+ bucket and ignores `subject`.
13
+
14
+ Typical usage:
15
+ limiter = AiolimiterRateLimiterFactory(max_rate=100.0, time_period=60.0)()
16
+ async with limiter.limit("market/orders"):
17
+ # perform one rate-limited operation
18
+ ...
19
+
20
+ Typed construction with ApiRequester:
21
+ from uuid import UUID, uuid4
22
+
23
+ from api_request import ApiRequester, Request
24
+ from api_request.rate_limit import AiolimiterRateLimiterFactory
25
+
26
+ # Replace with your chosen cache factory.
27
+ cache_factory = ...
28
+ rate_limiter_factory = AiolimiterRateLimiterFactory(
29
+ max_rate=100.0,
30
+ time_period=60.0,
31
+ )
32
+
33
+ requests: dict[UUID, Request] = {
34
+ uuid4(): Request(
35
+ request_key=uuid4(),
36
+ url="https://esi.evetech.net/latest/status/",
37
+ method="GET",
38
+ rate_key="public-status",
39
+ )
40
+ }
41
+
42
+
43
+ async def run() -> None:
44
+ async with ApiRequester(
45
+ cache_factory=cache_factory,
46
+ rate_limiter_factory=rate_limiter_factory,
47
+ ) as requester:
48
+ await requester.process_requests(requests)
49
+ """
50
+
51
+ from contextlib import AbstractAsyncContextManager
52
+ from typing import Protocol
53
+
54
+
55
+ class RateLimiterProtocol(Protocol):
56
+ """Protocol for a rate limiter that gates async work.
57
+
58
+ Implementations may produce one-shot limiters for each operation or may
59
+ return a shared limiter context that gates all operations.
60
+
61
+ The protocol does not require any grouping strategy. Implementations may
62
+ ignore `subject`, or they may use it to select or derive limiter behavior.
63
+ """
64
+
65
+ def limit(self, subject: str | None) -> AbstractAsyncContextManager[None]:
66
+ """Create an async gate for one operation.
67
+
68
+ Depending on implementation, `subject` may influence behavior. For
69
+ example, a limiter might group requests by endpoint family, tenant, or
70
+ priority class.
71
+
72
+ Examples:
73
+ async with rate_limiter.limit(subject):
74
+ # performs the operation that is subject to rate limiting, after the
75
+ # gate is acquired.
76
+
77
+ Args:
78
+ subject: Domain input that may influence limiter behavior. Prefer a
79
+ stable, hashable value that represents the grouping intent.
80
+
81
+ Returns:
82
+ An async context manager that acquires limiter capacity.
83
+
84
+ Notes:
85
+ The contract does not require distinct limiter instances per call.
86
+ Returning a shared context manager object is valid.
87
+ """
88
+ ...
89
+
90
+
91
+ class RateLimiterFactoryProtocol(Protocol):
92
+ """Protocol for callables that build shared rate-limiter instances.
93
+
94
+ This protocol is used to create configured rate limiters for use in
95
+ requesters. One produced limiter instance is typically reused by one
96
+ requester/task-group so concurrent work coordinates against shared budget.
97
+ """
98
+
99
+ def __call__(self) -> RateLimiterProtocol:
100
+ """Build and return a configured rate-limiter instance."""
101
+ ...
@@ -0,0 +1,11 @@
1
+ """Request orchestration package.
2
+
3
+ This package contains:
4
+ - Public request/response data models.
5
+ - Internal intermediate models used by the orchestration pipeline.
6
+ - The requester implementation and protocol contract.
7
+
8
+ Most callers should import public request types from `api_request` package
9
+ root exports (`Request`, `Response`, `ResponseMetadata`, `Source`) and use
10
+ `ApiRequester` as an async context manager.
11
+ """