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,63 @@
1
+ """What both drivers answer to.
2
+
3
+ A Protocol rather than a base class, so neither driver inherits anything it does not use and
4
+ a test double does not have to subclass to stand in for one.
5
+
6
+ The shapes are fixed to the database driver's vocabulary - `pending`, `delayed`, `reserved`
7
+ - even where redis models the same idea differently. There is one status command and it
8
+ prints one table, so the driver is what adapts.
9
+ """
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from py_app_runner.queue.job import Job
14
+
15
+
16
+ @runtime_checkable
17
+ class QueueDriver(Protocol):
18
+ async def push(
19
+ self,
20
+ name: str,
21
+ payload: dict[str, Any] | None = None,
22
+ delay: int = 0,
23
+ queue: str = "default",
24
+ priority: int = 0,
25
+ unique: str | None = None,
26
+ max_attempts: int = 3,
27
+ ) -> int:
28
+ """Queue a job, returning its id - or the id of the job already holding `unique`."""
29
+ ...
30
+
31
+ async def reserve(self, queues: list[str], timeout: int, worker: str) -> Job | None:
32
+ """Claim the next due job, or None. `queues` is a precedence order, not a merged
33
+ sort. The claim lasts `timeout` seconds and must become claimable again after that
34
+ without anybody having to release it."""
35
+ ...
36
+
37
+ async def delete(self, job: Job) -> None:
38
+ """Done. Forget it."""
39
+ ...
40
+
41
+ async def release(self, job: Job, delay: int = 0, error: str = "") -> None:
42
+ """Put it back for another attempt. Must not change `attempts` - reserving already
43
+ counted this one."""
44
+ ...
45
+
46
+ async def fail(self, job: Job, error: str) -> None:
47
+ """Out of attempts. Keep it where a human will find it."""
48
+ ...
49
+
50
+ async def pending(self, queue: str | None = None) -> int:
51
+ """How many could be picked up right now: excludes jobs not yet due and jobs another
52
+ worker currently holds."""
53
+ ...
54
+
55
+ async def stats(self) -> list[dict[str, Any]]: ...
56
+
57
+ async def failed_count(self) -> int: ...
58
+
59
+ async def failed_rows(self, limit: int) -> list[dict[str, Any]]: ...
60
+
61
+ async def retry_failed(self, job_id: int | None, max_attempts: int) -> int: ...
62
+
63
+ async def forget_failed(self, job_id: int | None, before: str | None) -> int: ...
@@ -0,0 +1,46 @@
1
+ """The job as a handler sees it, and the errors the queue raises."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ class QueueError(Exception):
8
+ """A queue that is configured or called wrongly. Never raised for a job that failed -
9
+ that is what the failed table is for."""
10
+
11
+
12
+ @dataclass
13
+ class Job:
14
+ id: int
15
+ queue: str
16
+ name: str
17
+ payload: dict[str, Any]
18
+ # The row exactly as it was written. Carried so a job moved to the failed table keeps
19
+ # byte-identical payload, rather than a re-encoding of a decoding of it.
20
+ payload_json: str
21
+ # Includes this attempt: reserving *is* the attempt, so the first run sees 1.
22
+ attempts: int
23
+ max_attempts: int
24
+ # Driver bookmark. Empty on the database driver, the stream entry id on redis.
25
+ handle: str = ""
26
+
27
+ _release_delay: int | None = field(default=None, repr=False)
28
+
29
+ def release(self, delay: int = 0) -> None:
30
+ """Put this job back for another attempt.
31
+
32
+ A deliberate "not now" - a rate limit upstream, a file that has not landed yet -
33
+ rather than a failure. It does not count against the attempt budget any differently
34
+ from a crash, because the claim already counted it.
35
+ """
36
+
37
+ self._release_delay = max(0, delay)
38
+
39
+ def was_released(self) -> bool:
40
+ return self._release_delay is not None
41
+
42
+ def release_delay(self) -> int:
43
+ return self._release_delay or 0
44
+
45
+ def is_last_attempt(self) -> bool:
46
+ return self.attempts >= self.max_attempts
@@ -0,0 +1,221 @@
1
+ """The worker loop.
2
+
3
+ Polls rather than blocks, so latency is bounded by `sleep` and nothing holds a connection
4
+ open waiting. One reserve, one job, one completion per iteration.
5
+
6
+ The per-job timeout is `asyncio.timeout()`, which genuinely cancels the task rather than
7
+ asking it to stop at a point it may never reach - a signal-based timeout cannot interrupt a
8
+ job blocked in a query, which is the case most worth interrupting. The visibility timeout
9
+ stays as the backstop for a worker that dies outright rather than merely overrunning.
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import time
15
+ import traceback
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from py_app_runner.queue.handler import resolve
20
+ from py_app_runner.queue.job import Job
21
+
22
+ Out = Callable[[str], None]
23
+
24
+ _logger = logging.getLogger(__name__)
25
+
26
+ # Five consecutive failures to reserve means the queue is unreadable, not that it is empty.
27
+ # Exiting lets a supervisor restart the process rather than having it sit there logging.
28
+ MAX_CONSECUTIVE_FAILURES = 5
29
+
30
+
31
+ def backoff(attempt: int, steps: list[int] | int | None = None) -> int:
32
+ """Delay before the next attempt, given how many have already been made.
33
+
34
+ A list gives one delay per attempt and repeats the last entry forever, so a job that
35
+ keeps failing settles at a sensible interval rather than growing without bound.
36
+ """
37
+
38
+ if steps is None:
39
+ steps = [10, 60, 300]
40
+
41
+ if isinstance(steps, int):
42
+ return max(0, steps)
43
+
44
+ if not steps:
45
+ return 0
46
+
47
+ return max(0, steps[min(max(0, attempt - 1), len(steps) - 1)])
48
+
49
+
50
+ class Worker:
51
+ def __init__(
52
+ self,
53
+ queue: Any,
54
+ handlers: dict[str, Any] | None = None,
55
+ backoff_steps: list[int] | int | None = None,
56
+ out: Out | None = None,
57
+ worker_id: str = "worker",
58
+ ) -> None:
59
+ self.queue = queue
60
+ self.handlers = handlers or {}
61
+ self.backoff_steps = backoff_steps
62
+ self.out = out or print
63
+ self.worker_id = worker_id
64
+ self.should_quit = False
65
+
66
+ def stop(self) -> None:
67
+ """Ask the loop to finish the job in hand and exit."""
68
+
69
+ self.should_quit = True
70
+
71
+ async def run(
72
+ self,
73
+ queues: list[str],
74
+ timeout: int = 300,
75
+ sleep: float = 1.0,
76
+ max_jobs: int = 0,
77
+ max_time: int = 0,
78
+ stop_when_empty: bool = False,
79
+ ) -> int:
80
+ """Returns a process exit code: 0 for every ordinary end, 1 only when the queue
81
+ could not be read repeatedly. A failed *job* never changes the exit code - that is
82
+ what the failed table is for."""
83
+
84
+ self.out(f"Worker {self.worker_id} watching {', '.join(queues)}")
85
+
86
+ started = time.monotonic()
87
+ done = 0
88
+ failures = 0
89
+
90
+ while True:
91
+ if self.should_quit:
92
+ self.out("Stopping: asked to shut down")
93
+ break
94
+
95
+ try:
96
+ job = await self.queue.reserve(queues, timeout, self.worker_id)
97
+ failures = 0
98
+ except Exception as e:
99
+ failures += 1
100
+ self.out(f"error: could not reserve a job: {e}")
101
+ if failures >= MAX_CONSECUTIVE_FAILURES:
102
+ self.out(f"Stopping: the queue has been unreadable {MAX_CONSECUTIVE_FAILURES} times running")
103
+ return 1
104
+
105
+ await self._rest(sleep)
106
+ continue
107
+
108
+ if job is None:
109
+ if stop_when_empty:
110
+ self.out("Nothing left to do")
111
+ break
112
+
113
+ reason = self._limit_reached(done, started, max_jobs, max_time)
114
+ if reason:
115
+ self.out(f"Stopping: {reason}")
116
+ break
117
+
118
+ await self._rest(sleep)
119
+ continue
120
+
121
+ await self._run_job(job, timeout)
122
+ done += 1
123
+
124
+ reason = self._limit_reached(done, started, max_jobs, max_time)
125
+ if reason:
126
+ self.out(f"Stopping: {reason}")
127
+ break
128
+
129
+ self.out(f"Ran {done} job(s)")
130
+ return 0
131
+
132
+ async def run_next(self, queues: list[str], timeout: int = 300) -> bool:
133
+ """Reserve and run exactly one job. The single-shot primitive, for tests - it
134
+ installs no signal handlers, applies no limits and does not catch reserve failures."""
135
+
136
+ job = await self.queue.reserve(queues, timeout, self.worker_id)
137
+ if job is None:
138
+ return False
139
+
140
+ await self._run_job(job, timeout)
141
+ return True
142
+
143
+ ###############
144
+ ### Running ###
145
+ ###############
146
+
147
+ async def _run_job(self, job: Job, timeout: int) -> None:
148
+ self.out(f"-> {job.name} #{job.id} (attempt {job.attempts}/{job.max_attempts})")
149
+ started = time.monotonic()
150
+
151
+ try:
152
+ # Resolution is inside the try on purpose: a handler that cannot be built fails
153
+ # the job through the same release/fail path as one that threw, rather than
154
+ # being a separate outcome nobody handles.
155
+ handler = resolve(job.name, self.handlers)
156
+ async with asyncio.timeout(timeout):
157
+ await handler(job.payload, job)
158
+ except asyncio.CancelledError:
159
+ # A cancellation that is not ours - the process is shutting down. Release the
160
+ # job so it is picked up promptly rather than waiting out its reservation, and
161
+ # let the cancellation continue.
162
+ await self._safe_release(job, 0, "Worker was cancelled mid-job")
163
+ raise
164
+ except Exception as e:
165
+ # QueueError included: a handler that cannot be resolved is a job that cannot
166
+ # run, and it goes through the same release/fail budget as any other failure.
167
+ await self._job_failed(job, e)
168
+ return
169
+
170
+ if job.was_released():
171
+ await self.queue.release(job, job.release_delay(), "")
172
+ self.out(f" released, back in {job.release_delay()}s")
173
+ return
174
+
175
+ await self.queue.delete(job)
176
+ self.out(f" done in {int((time.monotonic() - started) * 1000)}ms")
177
+
178
+ async def _job_failed(self, job: Job, error: BaseException) -> None:
179
+ detail = f"{type(error).__name__}: {error}\n{''.join(traceback.format_exception(error))}"
180
+
181
+ if job.is_last_attempt():
182
+ await self.queue.fail(job, detail)
183
+ self.out(f" failed for good: {error}")
184
+ # Both, deliberately: process output goes to a supervisor log nobody reads,
185
+ # and the application's own logging is where the rest of its problems surface.
186
+ _logger.error("Queue job %s #%s failed permanently: %s", job.name, job.id, error)
187
+ return
188
+
189
+ delay = backoff(job.attempts, self.backoff_steps)
190
+ await self.queue.release(job, delay, detail)
191
+ self.out(f" failed, retrying in {delay}s: {error}")
192
+
193
+ async def _safe_release(self, job: Job, delay: int, error: str) -> None:
194
+ try:
195
+ await self.queue.release(job, delay, error)
196
+ except Exception as e:
197
+ # Already unwinding; the reservation will expire on its own.
198
+ _logger.warning("Queue could not release job #%s during shutdown: %s", job.id, e)
199
+
200
+ ###############
201
+ ### Limits ####
202
+ ###############
203
+
204
+ def _limit_reached(self, done: int, started: float, max_jobs: int, max_time: int) -> str:
205
+ if max_jobs > 0 and done >= max_jobs:
206
+ return f"ran {done} jobs"
207
+
208
+ if max_time > 0 and (time.monotonic() - started) >= max_time:
209
+ return f"been going for {int(time.monotonic() - started)}s"
210
+
211
+ return ""
212
+
213
+ async def _rest(self, sleep: float) -> None:
214
+ """Sleep in short slices so a shutdown request is noticed promptly rather than after
215
+ a full poll interval."""
216
+
217
+ remaining = sleep
218
+ while remaining > 0 and not self.should_quit:
219
+ slice_length = min(0.25, remaining)
220
+ await asyncio.sleep(slice_length)
221
+ remaining -= slice_length
@@ -0,0 +1,54 @@
1
+ from typing import Any
2
+
3
+
4
+ class AppRegistry:
5
+ """
6
+ Singleton registry holding project-specific classes and configuration
7
+ that the framework needs. Must be configured before the runner starts.
8
+ """
9
+
10
+ _config: dict[str, Any] = {}
11
+ _users_model_cls: type | None = None
12
+ _api_keys_model_cls: type | None = None
13
+ _redis_channel: str = ""
14
+ _api_key_use_db: bool = False
15
+
16
+ @classmethod
17
+ def configure(
18
+ cls,
19
+ *,
20
+ config: dict[str, Any],
21
+ users_model: type,
22
+ api_keys_model: type,
23
+ redis_channel: str = "",
24
+ api_key_use_db: bool = False,
25
+ ) -> None:
26
+ cls._config = config
27
+ cls._users_model_cls = users_model
28
+ cls._api_keys_model_cls = api_keys_model
29
+ cls._redis_channel = redis_channel
30
+ cls._api_key_use_db = api_key_use_db
31
+
32
+ @classmethod
33
+ def config(cls) -> dict[str, Any]:
34
+ return cls._config
35
+
36
+ @classmethod
37
+ def users_model(cls) -> type:
38
+ if cls._users_model_cls is None:
39
+ raise RuntimeError("AppRegistry not configured: users_model is None")
40
+ return cls._users_model_cls
41
+
42
+ @classmethod
43
+ def api_keys_model(cls) -> type:
44
+ if cls._api_keys_model_cls is None:
45
+ raise RuntimeError("AppRegistry not configured: api_keys_model is None")
46
+ return cls._api_keys_model_cls
47
+
48
+ @classmethod
49
+ def redis_channel(cls) -> str:
50
+ return cls._redis_channel
51
+
52
+ @classmethod
53
+ def api_key_use_db(cls) -> bool:
54
+ return cls._api_key_use_db
File without changes
@@ -0,0 +1,123 @@
1
+ import datetime as dt
2
+ import uuid
3
+ from typing import Literal, TypedDict, cast
4
+
5
+ import jwt
6
+
7
+ from py_app_runner.registry import AppRegistry
8
+ from py_app_runner.utils import CustomJSONEncoder
9
+
10
+ JwtType = Literal["user", "device", "impersonation"]
11
+
12
+
13
+ class JwtPayload(TypedDict, total=False):
14
+ sub: str
15
+ typ: JwtType
16
+ iat: int
17
+ exp: int
18
+ jti: str
19
+ imp: str # impersonator public_id (only for typ="impersonation")
20
+
21
+
22
+ class AuthService:
23
+ def __init__(self, logger):
24
+ self.logger = logger
25
+
26
+ def _now(self) -> dt.datetime:
27
+ return dt.datetime.now(dt.UTC)
28
+
29
+ def create_access_jwt(
30
+ self,
31
+ *,
32
+ subject_public_id: str,
33
+ token_type: JwtType,
34
+ ttl_seconds: int = 15 * 60,
35
+ ) -> str:
36
+ """
37
+ Create short-lived access JWT.
38
+ 'sub' MUST be the public_id UUID string of the user/device.
39
+ """
40
+ now = self._now()
41
+ payload: dict = {
42
+ "sub": str(subject_public_id),
43
+ "typ": token_type,
44
+ "iat": int(now.timestamp()),
45
+ "exp": int((now + dt.timedelta(seconds=ttl_seconds)).timestamp()),
46
+ "jti": str(uuid.uuid4()),
47
+ }
48
+ secret = AppRegistry.config()["jwt"]["secret"]
49
+ return jwt.encode(payload, secret, algorithm="HS256", json_encoder=CustomJSONEncoder) # type: ignore
50
+
51
+ def create_impersonation_jwt(
52
+ self,
53
+ *,
54
+ target_public_id: str,
55
+ impersonator_public_id: str,
56
+ ttl_seconds: int = 30 * 60,
57
+ ) -> str:
58
+ """
59
+ Create an impersonation JWT.
60
+ sub = target user, imp = superadmin who initiated impersonation.
61
+ """
62
+ now = self._now()
63
+ payload: dict = {
64
+ "sub": str(target_public_id),
65
+ "typ": "impersonation",
66
+ "imp": str(impersonator_public_id),
67
+ "iat": int(now.timestamp()),
68
+ "exp": int((now + dt.timedelta(seconds=ttl_seconds)).timestamp()),
69
+ "jti": str(uuid.uuid4()),
70
+ }
71
+ secret = AppRegistry.config()["jwt"]["secret"]
72
+ return jwt.encode(payload, secret, algorithm="HS256", json_encoder=CustomJSONEncoder) # type: ignore
73
+
74
+ def verify_access_jwt(self, token: str, *, expected_type: JwtType) -> JwtPayload | None:
75
+ """
76
+ Verify JWT signature + exp, enforce expected typ.
77
+ Returns payload dict if valid else None.
78
+ """
79
+ try:
80
+ payload = cast(
81
+ JwtPayload,
82
+ jwt.decode(
83
+ token,
84
+ AppRegistry.config()["jwt"]["secret"],
85
+ algorithms=["HS256"],
86
+ options={
87
+ "require": ["exp", "iat", "sub", "typ"],
88
+ },
89
+ leeway=10, # seconds clock skew tolerance
90
+ ),
91
+ )
92
+ except jwt.ExpiredSignatureError:
93
+ self.logger.debug("JWT expired")
94
+ return None
95
+ except jwt.InvalidTokenError:
96
+ self.logger.debug("JWT invalid")
97
+ return None
98
+
99
+ if payload.get("typ") != expected_type:
100
+ self.logger.debug("JWT type mismatch: expected=%s got=%s", expected_type, payload.get("typ"))
101
+ return None
102
+
103
+ # Validate UUID format for sub/jti (defensive)
104
+ try:
105
+ uuid.UUID(payload["sub"])
106
+ uuid.UUID(payload["jti"])
107
+ except Exception:
108
+ self.logger.debug("JWT payload UUID fields invalid")
109
+ return None
110
+
111
+ # For impersonation tokens, also validate the impersonator UUID
112
+ if payload.get("typ") == "impersonation":
113
+ imp = payload.get("imp")
114
+ if not imp:
115
+ self.logger.debug("Impersonation JWT missing imp claim")
116
+ return None
117
+ try:
118
+ uuid.UUID(imp)
119
+ except Exception:
120
+ self.logger.debug("Impersonation JWT imp field invalid")
121
+ return None
122
+
123
+ return payload