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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. 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()}