py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Concatenate, TypeAlias, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from database_wrapper_pgsql import DBWrapperPgsqlAsync
|
|
7
|
+
from typing_extensions import ParamSpec
|
|
8
|
+
|
|
9
|
+
from py_app_runner.http_exception import HTTPException
|
|
10
|
+
from py_app_runner.request_handler.handlers import RequestHandlerHelper
|
|
11
|
+
from py_app_runner.throttle import Throttle
|
|
12
|
+
|
|
13
|
+
rate_limit_logger = logging.getLogger(__name__ + ".rate_limit")
|
|
14
|
+
audit_logger = logging.getLogger(__name__ + ".audit")
|
|
15
|
+
|
|
16
|
+
P = ParamSpec("P")
|
|
17
|
+
R = TypeVar("R")
|
|
18
|
+
T = TypeVar("T", bound=type)
|
|
19
|
+
SelfT = TypeVar("SelfT", bound=RequestHandlerHelper)
|
|
20
|
+
|
|
21
|
+
AsyncMethod: TypeAlias = Callable[Concatenate[SelfT, P], Awaitable[R]]
|
|
22
|
+
SyncMethod: TypeAlias = Callable[Concatenate[SelfT, P], R]
|
|
23
|
+
|
|
24
|
+
# Receives (db_wrapper, *, event_type, user_id, details, ip, user_agent).
|
|
25
|
+
AuditSink: TypeAlias = Callable[..., Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def action(name: str) -> Callable[[SyncMethod], SyncMethod]:
|
|
29
|
+
def decorator(func: SyncMethod) -> SyncMethod:
|
|
30
|
+
# function objects don't have this attribute in typeshed -> tell the type checker
|
|
31
|
+
cast(object, func)._action_name = name # type: ignore[attr-defined]
|
|
32
|
+
|
|
33
|
+
@wraps(func)
|
|
34
|
+
def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
35
|
+
return func(self, *args, **kwargs)
|
|
36
|
+
|
|
37
|
+
cast(object, wrapper)._action_name = name # type: ignore[attr-defined]
|
|
38
|
+
return wrapper
|
|
39
|
+
|
|
40
|
+
return decorator
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def authenticated(func: AsyncMethod) -> AsyncMethod:
|
|
44
|
+
@wraps(func)
|
|
45
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
46
|
+
bh = getattr(self, "bridge_handler", None)
|
|
47
|
+
if bh is None or getattr(bh, "current_user", None) is None:
|
|
48
|
+
raise HTTPException("Not authorized", 4010, http_status=401)
|
|
49
|
+
return await func(self, *args, **kwargs)
|
|
50
|
+
|
|
51
|
+
# Marker so @require_auth_for_actions does not wrap an already-guarded method twice.
|
|
52
|
+
# functools.wraps copies __dict__ outwards, so decorators applied above this one
|
|
53
|
+
# (e.g. @action) carry the marker too.
|
|
54
|
+
cast(object, wrapper)._auth_wrapped = True # type: ignore[attr-defined]
|
|
55
|
+
return wrapper
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def require_auth_for_actions(cls: T) -> T:
|
|
59
|
+
"""
|
|
60
|
+
Class decorator: wraps every method that has `_action_name` with @authenticated.
|
|
61
|
+
|
|
62
|
+
Walks the full MRO, not just `vars(cls)`: dispatch in RequestHandlerHelper resolves
|
|
63
|
+
actions via `dir()`, so inherited @action methods are routable and must be guarded
|
|
64
|
+
as well.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
for name in dir(cls):
|
|
68
|
+
attr = getattr(cls, name, None)
|
|
69
|
+
action_name = getattr(attr, "_action_name", None)
|
|
70
|
+
if action_name is None or not callable(attr):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if getattr(attr, "_auth_wrapped", False):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Important: keep the marker so dispatch still works
|
|
77
|
+
wrapped = authenticated(attr) # type: ignore[reportUnknownLambdaType]
|
|
78
|
+
cast(object, wrapped)._action_name = action_name # type: ignore[attr-defined]
|
|
79
|
+
setattr(cls, name, wrapped)
|
|
80
|
+
|
|
81
|
+
return cls
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def with_tx(func: AsyncMethod) -> AsyncMethod:
|
|
85
|
+
@wraps(func)
|
|
86
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
87
|
+
pg_conn = getattr(self, "pg_conn", None)
|
|
88
|
+
if pg_conn is None:
|
|
89
|
+
raise HTTPException("Database connection not linked (pg_conn missing)", 5000)
|
|
90
|
+
|
|
91
|
+
async with pg_conn.transaction():
|
|
92
|
+
return await func(self, *args, **kwargs)
|
|
93
|
+
|
|
94
|
+
return wrapper
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def with_db(func: AsyncMethod) -> AsyncMethod:
|
|
98
|
+
@wraps(func)
|
|
99
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
100
|
+
bh = getattr(self, "bridge_handler", None)
|
|
101
|
+
if bh is None:
|
|
102
|
+
raise HTTPException("Bridge handler not linked", 5000)
|
|
103
|
+
|
|
104
|
+
async with bh.db_pools.main_db_pool as (pg_conn, pg_cur):
|
|
105
|
+
if not pg_conn or not pg_cur:
|
|
106
|
+
raise HTTPException("Failed to connect to database server", 5000)
|
|
107
|
+
|
|
108
|
+
self.pg_conn = pg_conn
|
|
109
|
+
self.pg_cur = pg_cur
|
|
110
|
+
self.db_wrapper = DBWrapperPgsqlAsync(pg_cur)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return await func(self, *args, **kwargs)
|
|
114
|
+
finally:
|
|
115
|
+
for attr in ("pg_conn", "pg_cur", "db_wrapper"):
|
|
116
|
+
if hasattr(self, attr):
|
|
117
|
+
delattr(self, attr)
|
|
118
|
+
|
|
119
|
+
return wrapper
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def with_cache(func: AsyncMethod) -> AsyncMethod:
|
|
123
|
+
@wraps(func)
|
|
124
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
125
|
+
bh = getattr(self, "bridge_handler", None)
|
|
126
|
+
if bh is None:
|
|
127
|
+
raise HTTPException("Bridge handler not linked", 5000)
|
|
128
|
+
|
|
129
|
+
async with bh.db_pools.cache_db_pool as redis_con:
|
|
130
|
+
if not redis_con:
|
|
131
|
+
raise HTTPException("Failed to connect to cache server", 5000)
|
|
132
|
+
|
|
133
|
+
self.redis_con = redis_con
|
|
134
|
+
try:
|
|
135
|
+
return await func(self, *args, **kwargs)
|
|
136
|
+
finally:
|
|
137
|
+
if hasattr(self, "redis_con"):
|
|
138
|
+
delattr(self, "redis_con")
|
|
139
|
+
|
|
140
|
+
return wrapper
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def with_cache_and_db(func: AsyncMethod) -> AsyncMethod:
|
|
144
|
+
@wraps(func)
|
|
145
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
146
|
+
bh = getattr(self, "bridge_handler", None)
|
|
147
|
+
if bh is None:
|
|
148
|
+
raise HTTPException("Bridge handler not linked", 5000)
|
|
149
|
+
|
|
150
|
+
async with bh.db_pools.cache_db_pool as redis_con:
|
|
151
|
+
if not redis_con:
|
|
152
|
+
raise HTTPException("Failed to connect to cache server", 5000)
|
|
153
|
+
|
|
154
|
+
self.redis_con = redis_con
|
|
155
|
+
try:
|
|
156
|
+
async with bh.db_pools.main_db_pool as (pg_conn, pg_cur):
|
|
157
|
+
if not pg_conn or not pg_cur:
|
|
158
|
+
raise HTTPException("Failed to connect to database server", 5000)
|
|
159
|
+
|
|
160
|
+
self.pg_conn = pg_conn
|
|
161
|
+
self.pg_cur = pg_cur
|
|
162
|
+
self.db_wrapper = DBWrapperPgsqlAsync(pg_cur)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return await func(self, *args, **kwargs)
|
|
166
|
+
finally:
|
|
167
|
+
for attr in ("pg_conn", "pg_cur", "db_wrapper"):
|
|
168
|
+
if hasattr(self, attr):
|
|
169
|
+
delattr(self, attr)
|
|
170
|
+
finally:
|
|
171
|
+
if hasattr(self, "redis_con"):
|
|
172
|
+
delattr(self, "redis_con")
|
|
173
|
+
|
|
174
|
+
return wrapper
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rate_limit(max_requests: int, window_seconds: int) -> Callable[[AsyncMethod], AsyncMethod]:
|
|
178
|
+
"""
|
|
179
|
+
Redis-backed fixed-window rate limiter.
|
|
180
|
+
Key is derived from user ID (if authenticated) or client IP.
|
|
181
|
+
Must be applied AFTER @with_cache or @with_cache_and_db (so self.redis_con exists).
|
|
182
|
+
Returns HTTP 429 with Retry-After header when limit is exceeded.
|
|
183
|
+
|
|
184
|
+
Counting is done by py_app_runner.throttle, which is also callable directly for
|
|
185
|
+
anything that is not a bridge action - a login form keyed by email, say. Sharing the
|
|
186
|
+
implementation is what keeps the window semantics in one place; before, this decorator
|
|
187
|
+
was the only thing that could count, so every other caller invented its own.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def decorator(func: AsyncMethod) -> AsyncMethod:
|
|
191
|
+
@wraps(func)
|
|
192
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
193
|
+
redis_con = getattr(self, "redis_con", None)
|
|
194
|
+
if redis_con is None:
|
|
195
|
+
rate_limit_logger.warning("rate_limit requires Redis; skipping enforcement")
|
|
196
|
+
return await func(self, *args, **kwargs)
|
|
197
|
+
|
|
198
|
+
bh = getattr(self, "bridge_handler", None)
|
|
199
|
+
user = getattr(bh, "current_user", None) if bh else None
|
|
200
|
+
|
|
201
|
+
if user and getattr(user, "id", None):
|
|
202
|
+
identity = f"user:{user.id}"
|
|
203
|
+
elif bh:
|
|
204
|
+
identity = f"ip:{bh.request.remote_ip}"
|
|
205
|
+
else:
|
|
206
|
+
identity = "unknown"
|
|
207
|
+
|
|
208
|
+
action_name = getattr(func, "_action_name", func.__name__)
|
|
209
|
+
# Scope by handler class as well: the same action name in two services
|
|
210
|
+
# would otherwise share a single bucket.
|
|
211
|
+
scope = f"{type(self).__module__}.{type(self).__qualname__}"
|
|
212
|
+
key = f"rl:{scope}:{action_name}:{identity}"
|
|
213
|
+
|
|
214
|
+
attempt = await Throttle(redis_con).hit(key, max_requests, window_seconds)
|
|
215
|
+
|
|
216
|
+
if not attempt.allowed:
|
|
217
|
+
raise HTTPException(
|
|
218
|
+
f"Rate limit exceeded. Try again in {attempt.retry_after} seconds.",
|
|
219
|
+
code=4029,
|
|
220
|
+
http_status=429,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return await func(self, *args, **kwargs)
|
|
224
|
+
|
|
225
|
+
return wrapper
|
|
226
|
+
|
|
227
|
+
return decorator
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def audited(
|
|
231
|
+
event_type: str | None = None,
|
|
232
|
+
*,
|
|
233
|
+
sensitive_fields: tuple[str, ...] = ("password",),
|
|
234
|
+
sink: AuditSink | None = None,
|
|
235
|
+
) -> Callable[[AsyncMethod], AsyncMethod]:
|
|
236
|
+
"""
|
|
237
|
+
Audit-log decorator. Records after the wrapped action succeeds.
|
|
238
|
+
|
|
239
|
+
Place it as the innermost decorator, closest to the method body; with a `sink`
|
|
240
|
+
it must come AFTER @with_db in the stack (needs self.db_wrapper). The record
|
|
241
|
+
goes to `sink(db_wrapper, event_type=..., user_id=..., details=..., ip=...,
|
|
242
|
+
user_agent=...)`; without a sink it is written to the audit logger only.
|
|
243
|
+
If *event_type* is None the action name from @action is used.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def decorator(func: AsyncMethod) -> AsyncMethod:
|
|
247
|
+
@wraps(func)
|
|
248
|
+
async def wrapper(self: SelfT, *args: P.args, **kwargs: P.kwargs) -> R: # pyrefly: ignore[not-a-type]
|
|
249
|
+
result = await func(self, *args, **kwargs)
|
|
250
|
+
|
|
251
|
+
# Best-effort: an audit failure must never fail the action itself.
|
|
252
|
+
try:
|
|
253
|
+
db_wrapper = getattr(self, "db_wrapper", None)
|
|
254
|
+
if sink is not None and db_wrapper is None:
|
|
255
|
+
audit_logger.warning("@audited with a sink requires @with_db; skipping audit")
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
resolved_event = event_type or getattr(func, "_action_name", func.__name__)
|
|
259
|
+
|
|
260
|
+
bh = getattr(self, "bridge_handler", None)
|
|
261
|
+
user = getattr(bh, "current_user", None) if bh else None
|
|
262
|
+
user_id: int | None = getattr(user, "id", None)
|
|
263
|
+
|
|
264
|
+
ip: str | None = None
|
|
265
|
+
user_agent: str | None = None
|
|
266
|
+
if bh:
|
|
267
|
+
ip = bh.request.remote_ip
|
|
268
|
+
user_agent = bh.request.headers.get("User-Agent")
|
|
269
|
+
|
|
270
|
+
# Build details from input_data (first positional arg after self)
|
|
271
|
+
details = _sanitize_input(args[0] if args else {}, sensitive_fields)
|
|
272
|
+
|
|
273
|
+
if sink is not None:
|
|
274
|
+
await sink(
|
|
275
|
+
db_wrapper,
|
|
276
|
+
event_type=resolved_event,
|
|
277
|
+
user_id=user_id,
|
|
278
|
+
details=details,
|
|
279
|
+
ip=ip,
|
|
280
|
+
user_agent=user_agent,
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
audit_logger.info(
|
|
284
|
+
"audit event=%s user_id=%s ip=%s details=%s",
|
|
285
|
+
resolved_event,
|
|
286
|
+
user_id,
|
|
287
|
+
ip,
|
|
288
|
+
details,
|
|
289
|
+
)
|
|
290
|
+
except Exception:
|
|
291
|
+
audit_logger.exception("@audited failed for event_type=%s", event_type)
|
|
292
|
+
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
return wrapper
|
|
296
|
+
|
|
297
|
+
return decorator
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _sanitize_input(input_data: Any, sensitive_fields: tuple[str, ...]) -> dict[str, Any]:
|
|
301
|
+
"""Strip sensitive fields from input_data before storing in audit details."""
|
|
302
|
+
if not isinstance(input_data, dict):
|
|
303
|
+
return {}
|
|
304
|
+
return {k: "***" if k in sensitive_fields else v for k, v in input_data.items()}
|