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,182 @@
1
+ #!/usr/bin/env python3
2
+ # ruff: noqa: E402 # Ignore module level import not at top of file
3
+
4
+ import argparse
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+
10
+ import uvloop
11
+
12
+ from py_app_runner.config import is_env_dev, is_env_prod
13
+ from py_app_runner.logger_handlers import ConsoleHandler, InitSentry
14
+ from py_app_runner.pybridge import PyBridge
15
+ from py_app_runner.registry import AppRegistry
16
+ from py_app_runner.utils import json_encode
17
+
18
+
19
+ def main() -> None:
20
+ """Service initialization"""
21
+
22
+ # Ensure src/ is on the path so project modules (config, services, etc.) are importable
23
+ cwd = os.getcwd()
24
+ src_path = os.path.join(cwd, "src")
25
+ if os.path.isdir(src_path) and src_path not in sys.path:
26
+ sys.path.insert(0, src_path)
27
+
28
+ if not os.path.isfile(os.path.join(cwd, ".env")):
29
+ raise FileNotFoundError("There is no .env file, exiting...")
30
+
31
+ config = AppRegistry.config()
32
+
33
+ # Logging flags live on a help-less pre-parser so they can be parsed before
34
+ # subparsers exist; `-h` is then handled only by the full parser below and
35
+ # renders complete per-service help.
36
+ pre_parser = argparse.ArgumentParser(add_help=False)
37
+ pre_parser.add_argument(
38
+ "-v",
39
+ type=str,
40
+ choices=["debug", "info", "warning", "error", "disable"],
41
+ default="info",
42
+ help="Console logging level",
43
+ )
44
+ pre_parser.add_argument(
45
+ "-sv",
46
+ type=str,
47
+ choices=["debug", "info", "warning", "error", "disable"],
48
+ default="error",
49
+ help=(
50
+ "Sentry logging level, depends on -v. If -v is set to disable, then no error will be sent, "
51
+ "also if -v is set to info, and this is set to info, only errors will be reported."
52
+ ),
53
+ )
54
+ pre_parser.add_argument(
55
+ "-vf",
56
+ type=str,
57
+ default=None,
58
+ help="Console logging filter by module name",
59
+ )
60
+
61
+ parser = argparse.ArgumentParser(description="PyBridge services", parents=[pre_parser])
62
+ parser.add_argument(
63
+ "--interval",
64
+ type=int,
65
+ default=None,
66
+ help="""
67
+ Interval in seconds. Used in services that supports it.
68
+ A service should chose their own defaults.
69
+ """,
70
+ )
71
+ parser.add_argument(
72
+ "--run-once",
73
+ action="store_true",
74
+ help="Run job only once",
75
+ )
76
+ parser.add_argument(
77
+ "--dry-run",
78
+ action="store_true",
79
+ help="Dry run mode, no data will be saved, if subservice supports it",
80
+ )
81
+
82
+ # Parse args for logging output first
83
+ args, _unknown = pre_parser.parse_known_args()
84
+
85
+ # Init logging
86
+ consoleHandler = ConsoleHandler(filter=args.vf)
87
+ logger = logging.getLogger()
88
+ logger.addHandler(consoleHandler)
89
+
90
+ consoleLogger = logging.getLogger("console")
91
+ consoleLogger.addHandler(consoleHandler)
92
+ consoleLogger.propagate = False
93
+
94
+ if args.v == "disable":
95
+ logging.disable(logging.CRITICAL)
96
+ else:
97
+ logger.setLevel(getattr(logging, args.v.upper()))
98
+ consoleLogger.setLevel(getattr(logging, args.v.upper()))
99
+
100
+ config["debug"] = args.v.upper() == "DEBUG"
101
+
102
+ # Init sentry
103
+ if is_env_prod(config):
104
+ InitSentry(
105
+ args,
106
+ config["sentry"]["dsn"],
107
+ config["git_commit_hash"],
108
+ config["environment"],
109
+ )
110
+
111
+ if is_env_dev(config):
112
+ # Enable debug mode for asyncio in development environment
113
+ os.environ["PYTHONASYNCIODEBUG"] = "1"
114
+ else:
115
+ # Remove debug mode for asyncio in production and test environments
116
+ os.environ.pop("PYTHONASYNCIODEBUG", None)
117
+ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
118
+
119
+ # Looking for service argument parsers...
120
+ pybridge = PyBridge()
121
+ subparsers = parser.add_subparsers(
122
+ title="service",
123
+ description="Type of service",
124
+ help="Type of service",
125
+ dest="service",
126
+ )
127
+ subparsers.add_parser("print_config", help="Prints the config")
128
+
129
+ logger.debug("Looking for services subparsers...", extra={"prefix": "\n\n"})
130
+ for item in config["services"]:
131
+ service = pybridge.load_service_args(item)
132
+
133
+ logBuffer = f"{item}:"
134
+
135
+ # Register subparser
136
+ logBuffer += " subparser: "
137
+ if service and hasattr(service, "reg_subparsers"):
138
+ service.reg_subparsers(subparsers, pybridge, logger)
139
+ logBuffer += "Yes"
140
+ else:
141
+ logBuffer += "No"
142
+
143
+ # Log info
144
+ logger.debug(logBuffer)
145
+
146
+ # Second parse args for services
147
+ args = parser.parse_args()
148
+
149
+ # Start the service...
150
+ if args.service is None:
151
+ parser.print_help()
152
+ return
153
+
154
+ if args.service == "print_config":
155
+ logger.info("Config dump:")
156
+ print(json_encode(config, pretty=True))
157
+ return
158
+
159
+ logger.debug(f"\n\nLoad service: {args.service}")
160
+
161
+ service = pybridge.load_service_runner(args.service)
162
+
163
+ if not service:
164
+ logger.error(f'Service "{args.service}" not found!')
165
+ return
166
+
167
+ if not hasattr(service, "init_service"):
168
+ logger.error(f'"init_service" method not found on {args.service}!')
169
+ return
170
+
171
+ logger.debug(f'Starting service: "{args.service}"', extra={"prefix": "\n\n"})
172
+
173
+ # Start the service
174
+ try:
175
+ logger.info(f'Started service "{args.service}"')
176
+ asyncio.run(service.init_service(args, pybridge, logger))
177
+
178
+ except (KeyboardInterrupt, asyncio.CancelledError):
179
+ ... # Ignore this exception
180
+
181
+ except Exception as e:
182
+ logger.exception(f'Uncaught service "{args.service}" exception: {repr(e)}')
@@ -0,0 +1,5 @@
1
+ """Fixed-window rate limiting on Redis."""
2
+
3
+ from py_app_runner.throttle.throttle import Attempt, Throttle
4
+
5
+ __all__ = ["Attempt", "Throttle"]
@@ -0,0 +1,217 @@
1
+ """Rate limiting: count attempts against a key, and say when the caller may try again.
2
+
3
+ Fixed window rather than sliding: the window opens on the first hit and closes `window`
4
+ seconds later, whatever happens in between. The known cost is the boundary - a client can
5
+ spend a full allowance at the end of one window and another at the start of the next, so
6
+ for a limit of 5 per 15 minutes the true worst case is 10 in quick succession. That is
7
+ documented here rather than left to be discovered, and it is the right trade for the thing
8
+ this protects: a login form, not a billing meter.
9
+
10
+ attempt = await throttle.hit(f"login:{email}", 5, 900)
11
+ if not attempt.allowed:
12
+ raise HTTPException("Too many attempts", code=4029, http_status=429)
13
+
14
+ # ... and the moment the protected thing succeeds:
15
+ await throttle.clear(f"login:{email}")
16
+
17
+ Clearing on success is what stops a user who mistyped their password four times and then
18
+ got it right from staying one attempt away from a lockout for the rest of the window.
19
+ """
20
+
21
+ import hashlib
22
+ import logging
23
+ import time
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ _logger = logging.getLogger(__name__)
28
+
29
+ _DEFAULT_PREFIX = "throttle:"
30
+
31
+ # One round trip, and exact. Read-modify-write over separate commands would let simultaneous
32
+ # requests read the same count and each let an extra attempt through; a script costs the same
33
+ # latency and has no such window, so there is no reason to accept the race.
34
+ #
35
+ # The reset timestamp is stored rather than derived from the key's TTL. It is what decides
36
+ # whether a window is over, so it has to survive a backend that dropped the expiry - which
37
+ # is exactly what a crash between a SET and its EXPIRE leaves behind.
38
+ _HIT_SCRIPT = """
39
+ local hits = tonumber(redis.call('HGET', KEYS[1], 'hits'))
40
+ local reset = tonumber(redis.call('HGET', KEYS[1], 'reset'))
41
+ local now = tonumber(ARGV[1])
42
+ local window = tonumber(ARGV[2])
43
+
44
+ if hits == nil or reset == nil or reset <= now then
45
+ hits = 0
46
+ reset = now + window
47
+ end
48
+
49
+ hits = hits + 1
50
+
51
+ redis.call('HSET', KEYS[1], 'hits', hits, 'reset', reset)
52
+ redis.call('EXPIRE', KEYS[1], math.max(1, reset - now))
53
+
54
+ return {hits, reset}
55
+ """
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Attempt:
60
+ """The state of one key after an attempt.
61
+
62
+ A value object rather than a bool because every caller that denies a request also has
63
+ to tell the client when to come back, and a bool makes that a second lookup.
64
+ """
65
+
66
+ allowed: bool
67
+ limit: int
68
+ hits: int
69
+ remaining: int
70
+ retry_after: int
71
+ reset_at: int
72
+
73
+ def headers(self) -> dict[str, str]:
74
+ """The standard advertisement headers. `Retry-After` only when denied - sending it
75
+ on a successful response tells a well-behaved client to back off when it need not."""
76
+
77
+ headers = {
78
+ "X-RateLimit-Limit": str(self.limit),
79
+ "X-RateLimit-Remaining": str(self.remaining),
80
+ # An absolute unix timestamp, not a delta: a delta computed at send time is
81
+ # already wrong by the time it is read.
82
+ "X-RateLimit-Reset": str(self.reset_at),
83
+ }
84
+
85
+ if not self.allowed:
86
+ headers["Retry-After"] = str(self.retry_after)
87
+
88
+ return headers
89
+
90
+
91
+ class Throttle:
92
+ def __init__(self, redis_con: Any, prefix: str = _DEFAULT_PREFIX, fail_open: bool = True) -> None:
93
+ self.redis_con = redis_con
94
+ self.prefix = prefix
95
+ # Redis being unreachable is an outage of the cache, not of the application.
96
+ # Refusing every login until it comes back turns a degraded dependency into a total
97
+ # one, so the default is to allow and log. It is a choice, not a silence.
98
+ self.fail_open = fail_open
99
+
100
+ @classmethod
101
+ def from_config(cls, redis_con: Any, config: dict[str, Any]) -> "Throttle":
102
+ settings = config.get("throttle") or {}
103
+ return cls(
104
+ redis_con=redis_con,
105
+ prefix=settings.get("prefix") or _DEFAULT_PREFIX,
106
+ fail_open=settings.get("fail_open") is not False,
107
+ )
108
+
109
+ def cache_key(self, key: str) -> str:
110
+ """Hash the caller's key.
111
+
112
+ It is routinely an email address or an IP, and a shared Redis keyspace is readable
113
+ by anything else that connects to it. Hashing costs nothing here and means the
114
+ keyspace is not a list of who has been failing to log in.
115
+ """
116
+
117
+ return self.prefix + hashlib.sha256(key.encode("utf-8")).hexdigest()
118
+
119
+ async def hit(self, key: str, max_attempts: int, window: int) -> Attempt:
120
+ """Count one attempt and report the result.
121
+
122
+ The attempt is counted whether or not it is allowed, so hammering neither resets
123
+ the window nor extends it.
124
+ """
125
+
126
+ self._assert_limit(max_attempts, window)
127
+ now = int(time.time())
128
+
129
+ try:
130
+ hits, reset_at = await self._run_hit(key, now, window)
131
+ except Exception as e:
132
+ return self._unavailable(e, max_attempts, now, window)
133
+
134
+ return self._attempt(hits, reset_at, max_attempts, now)
135
+
136
+ async def check(self, key: str, max_attempts: int) -> Attempt:
137
+ """Peek without counting. Use it to show a client where it stands."""
138
+
139
+ self._assert_limit(max_attempts, 1)
140
+ now = int(time.time())
141
+
142
+ try:
143
+ stored = await self.redis_con.hmget(self.cache_key(key), "hits", "reset")
144
+ except Exception as e:
145
+ return self._unavailable(e, max_attempts, now, 0)
146
+
147
+ hits, reset_at = self._parse(stored)
148
+ if hits is None or reset_at is None or reset_at <= now:
149
+ # Nothing stored, or a window that has closed: a full allowance. reset_at is
150
+ # `now` rather than `now + window` because check() is not told the window
151
+ # length and must not invent one.
152
+ return Attempt(True, max_attempts, 0, max_attempts, 0, now)
153
+
154
+ return self._attempt(hits, reset_at, max_attempts, now)
155
+
156
+ async def clear(self, key: str) -> None:
157
+ """Forget a key. Call it the moment the protected thing succeeds."""
158
+
159
+ try:
160
+ await self.redis_con.delete(self.cache_key(key))
161
+ except Exception as e:
162
+ if not self.fail_open:
163
+ raise
164
+
165
+ _logger.warning("throttle: could not clear a counter: %s", e)
166
+
167
+ ###############
168
+ ### Interna ###
169
+ ###############
170
+
171
+ async def _run_hit(self, key: str, now: int, window: int) -> tuple[int, int]:
172
+ result = await self.redis_con.eval(_HIT_SCRIPT, 1, self.cache_key(key), now, window)
173
+ return (int(result[0]), int(result[1]))
174
+
175
+ def _attempt(self, hits: int, reset_at: int, max_attempts: int, now: int) -> Attempt:
176
+ # Increment-then-compare, so hits == max is the last allowed attempt and
177
+ # hits == max + 1 is the first denial.
178
+ allowed = hits <= max_attempts
179
+
180
+ return Attempt(
181
+ allowed=allowed,
182
+ limit=max_attempts,
183
+ hits=hits,
184
+ remaining=max(0, max_attempts - hits),
185
+ retry_after=0 if allowed else max(0, reset_at - now),
186
+ reset_at=reset_at,
187
+ )
188
+
189
+ def _parse(self, stored: Any) -> tuple[int | None, int | None]:
190
+ """Read stored state, treating anything unrecognisable as absent.
191
+
192
+ A key of this name holding something else - a leftover from another tool, a
193
+ half-written value - starts a fresh window rather than raising. Throwing here would
194
+ take down the login form over a stray cache entry.
195
+ """
196
+
197
+ if not stored or len(stored) != 2:
198
+ return (None, None)
199
+
200
+ try:
201
+ return (int(stored[0]), int(stored[1]))
202
+ except (TypeError, ValueError):
203
+ return (None, None)
204
+
205
+ def _unavailable(self, error: Exception, max_attempts: int, now: int, window: int) -> Attempt:
206
+ if not self.fail_open:
207
+ raise error
208
+
209
+ _logger.warning("throttle: %s", error)
210
+ return Attempt(True, max_attempts, 0, max_attempts, 0, now + window)
211
+
212
+ def _assert_limit(self, max_attempts: int, window: int) -> None:
213
+ if max_attempts < 1:
214
+ raise ValueError(f"A throttle limit is at least 1 attempt; got {max_attempts}.")
215
+
216
+ if window < 1:
217
+ raise ValueError(f"A throttle window is at least 1 second; got {window}.")