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,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bridge service should be treated as a transport layer for the other services.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
from argparse import Namespace
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from importlib import import_module
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import uvloop
|
|
17
|
+
from database_wrapper_pgsql import PgsqlWithPoolingAsync
|
|
18
|
+
from database_wrapper_redis import RedisDbAsync, RedisDbWithPoolAsync
|
|
19
|
+
from tornado import escape, httpserver, netutil, process
|
|
20
|
+
from tornado.platform.asyncio import AsyncIOMainLoop
|
|
21
|
+
|
|
22
|
+
from py_app_runner.bridge.web_app import WebApplication
|
|
23
|
+
from py_app_runner.config import is_env_dev
|
|
24
|
+
from py_app_runner.db_pools import DbPools
|
|
25
|
+
from py_app_runner.pybridge import PyBridge
|
|
26
|
+
from py_app_runner.registry import AppRegistry
|
|
27
|
+
from py_app_runner.request_handler.handlers import WebApplicationBase
|
|
28
|
+
from py_app_runner.utils import json_decode, json_encode, run_blocking, workers_auto
|
|
29
|
+
from py_app_runner.wbcm.wb_connection_manager import WbConnectionManager
|
|
30
|
+
|
|
31
|
+
ServerStarter = Callable[
|
|
32
|
+
[WebApplicationBase, Namespace, logging.Logger, list[socket.socket] | None],
|
|
33
|
+
Awaitable[None],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def start_server_dev(
|
|
38
|
+
app: WebApplicationBase,
|
|
39
|
+
args: Namespace,
|
|
40
|
+
log: logging.Logger,
|
|
41
|
+
sockets: list[socket.socket] | None = None, # unused
|
|
42
|
+
) -> None:
|
|
43
|
+
# Enable autoreload
|
|
44
|
+
from tornado import autoreload
|
|
45
|
+
|
|
46
|
+
autoreload.start()
|
|
47
|
+
|
|
48
|
+
# Start the server
|
|
49
|
+
server = httpserver.HTTPServer(app, xheaders=True, max_body_size=app.settings.get("max_body_size"))
|
|
50
|
+
server.listen(args.port, address=args.address) # no fork, no reuse_port
|
|
51
|
+
|
|
52
|
+
# Graceful shutdown on SIGINT/SIGTERM
|
|
53
|
+
shutdown = asyncio.Event()
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
56
|
+
loop.add_signal_handler(sig, shutdown.set)
|
|
57
|
+
|
|
58
|
+
log.info("Web server started in DEV mode (autoreload ON)")
|
|
59
|
+
try:
|
|
60
|
+
await shutdown.wait()
|
|
61
|
+
|
|
62
|
+
finally:
|
|
63
|
+
# Shutdown the server
|
|
64
|
+
server.stop()
|
|
65
|
+
await asyncio.sleep(0.1)
|
|
66
|
+
await server.close_all_connections()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def start_server_prod(
|
|
70
|
+
app: WebApplicationBase,
|
|
71
|
+
args: Namespace,
|
|
72
|
+
log: logging.Logger,
|
|
73
|
+
sockets: list[socket.socket] | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
assert sockets, "Prod starter requires pre-bound sockets"
|
|
76
|
+
|
|
77
|
+
# Set asyncio event loop for tornado
|
|
78
|
+
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
79
|
+
AsyncIOMainLoop().install()
|
|
80
|
+
|
|
81
|
+
# Disable debug stuff
|
|
82
|
+
app.settings.update(autoreload=False)
|
|
83
|
+
|
|
84
|
+
# Start the server
|
|
85
|
+
server = httpserver.HTTPServer(app, xheaders=True, max_body_size=app.settings.get("max_body_size"))
|
|
86
|
+
server.add_sockets(sockets)
|
|
87
|
+
|
|
88
|
+
# Graceful shutdown on SIGINT/SIGTERM
|
|
89
|
+
shutdown = asyncio.Event()
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
92
|
+
loop.add_signal_handler(sig, shutdown.set)
|
|
93
|
+
|
|
94
|
+
log.info("Web server started in PROD mode")
|
|
95
|
+
try:
|
|
96
|
+
await shutdown.wait()
|
|
97
|
+
finally:
|
|
98
|
+
server.stop()
|
|
99
|
+
await asyncio.sleep(0.1)
|
|
100
|
+
await server.close_all_connections()
|
|
101
|
+
await asyncio.sleep(0) # let callbacks run once
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def child_process_initializer(
|
|
105
|
+
application: WebApplicationBase,
|
|
106
|
+
args: Namespace,
|
|
107
|
+
base_logger: logging.Logger,
|
|
108
|
+
sockets: list[socket.socket] | None,
|
|
109
|
+
start_server: ServerStarter,
|
|
110
|
+
) -> None:
|
|
111
|
+
config = AppRegistry.config()
|
|
112
|
+
|
|
113
|
+
base_logger.info(f"Child starting, task_id={process.task_id()}")
|
|
114
|
+
|
|
115
|
+
# Init redis
|
|
116
|
+
redisPool = RedisDbWithPoolAsync(config["db"]["redis"])
|
|
117
|
+
|
|
118
|
+
# Init postgres
|
|
119
|
+
pgPool = PgsqlWithPoolingAsync(
|
|
120
|
+
db_config=config["db"]["main"],
|
|
121
|
+
instance_name=f"services_bridge_{config['environment']}",
|
|
122
|
+
)
|
|
123
|
+
await pgPool.open_pool()
|
|
124
|
+
|
|
125
|
+
# Init database pools
|
|
126
|
+
dbPools = DbPools(cache_db_pool=redisPool, main_db_pool=pgPool)
|
|
127
|
+
application.set_db_pools(dbPools)
|
|
128
|
+
|
|
129
|
+
# Init connection manager
|
|
130
|
+
wb_redis = RedisDbAsync(config["db"]["redis"])
|
|
131
|
+
cm = WbConnectionManager(cache_db=wb_redis, debug=args.debug_wbcm)
|
|
132
|
+
# Daemon: a wedged manager must not keep the process alive after the join below
|
|
133
|
+
# gives up.
|
|
134
|
+
bg_thread = threading.Thread(target=cm.start_in_new_loop, name="wbcm", daemon=True)
|
|
135
|
+
bg_thread.start()
|
|
136
|
+
application.set_wb_connection_manager(cm)
|
|
137
|
+
|
|
138
|
+
# Start the server
|
|
139
|
+
try:
|
|
140
|
+
base_logger.info(f"Web service child process started (task_id={process.task_id()})")
|
|
141
|
+
await start_server(application, args, base_logger, sockets)
|
|
142
|
+
|
|
143
|
+
finally:
|
|
144
|
+
# Stop processing messages, thus stopping the background thread
|
|
145
|
+
await cm.stop()
|
|
146
|
+
|
|
147
|
+
# join() blocks; keep it off the event loop and bounded so a stuck manager
|
|
148
|
+
# cannot hang the shutdown.
|
|
149
|
+
await run_blocking(bg_thread.join, 10.0)
|
|
150
|
+
if bg_thread.is_alive():
|
|
151
|
+
base_logger.warning("WbConnectionManager thread did not stop in time")
|
|
152
|
+
|
|
153
|
+
# Close the database pools
|
|
154
|
+
await redisPool.close()
|
|
155
|
+
await pgPool.close_pool()
|
|
156
|
+
|
|
157
|
+
# Remove references
|
|
158
|
+
application.set_db_pools(None) # type: ignore
|
|
159
|
+
application.set_pybridge(None) # type: ignore
|
|
160
|
+
application.set_wb_connection_manager(None) # type: ignore
|
|
161
|
+
|
|
162
|
+
base_logger.info("Web service child process stopped")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
####################
|
|
166
|
+
### Init Service ###
|
|
167
|
+
####################
|
|
168
|
+
async def init_service(
|
|
169
|
+
args: Namespace,
|
|
170
|
+
pybridge: PyBridge,
|
|
171
|
+
base_logger: logging.Logger,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Init service"""
|
|
174
|
+
config = AppRegistry.config()
|
|
175
|
+
|
|
176
|
+
# Replace tornado's default json encoder/decoder
|
|
177
|
+
escape.json_encode = json_encode
|
|
178
|
+
escape.json_decode = json_decode
|
|
179
|
+
|
|
180
|
+
# Load all the endpoint services
|
|
181
|
+
for service_name in config["services"]:
|
|
182
|
+
service = pybridge.load_service_pybridge(service_name)
|
|
183
|
+
if service:
|
|
184
|
+
base_logger.info(f"Loaded service: {service_name}")
|
|
185
|
+
if hasattr(service, "service_name"):
|
|
186
|
+
service_name = service.service_name
|
|
187
|
+
pybridge.cache_service(service_name, service)
|
|
188
|
+
|
|
189
|
+
# Load additional endpoints / services
|
|
190
|
+
number_of_routes = 0
|
|
191
|
+
if hasattr(service, "bridge_routes"):
|
|
192
|
+
routes = service.bridge_routes()
|
|
193
|
+
number_of_routes = len(routes)
|
|
194
|
+
for route_name in routes:
|
|
195
|
+
route_handler = routes[route_name]
|
|
196
|
+
pybridge.cache_service(route_name, route_handler)
|
|
197
|
+
|
|
198
|
+
base_logger.info(f"Loaded service: {service_name}; Additional routes: {number_of_routes}")
|
|
199
|
+
else:
|
|
200
|
+
base_logger.info(f"No endpoint for the service: {service_name}")
|
|
201
|
+
|
|
202
|
+
# Auto-discover project routes and optional app settings
|
|
203
|
+
routes = None
|
|
204
|
+
app_settings: dict[str, Any] = {}
|
|
205
|
+
try:
|
|
206
|
+
routes_module = import_module("services.bridge.routes")
|
|
207
|
+
if hasattr(routes_module, "get_routes"):
|
|
208
|
+
routes = routes_module.get_routes()
|
|
209
|
+
base_logger.info(f"Loaded {len(routes)} route(s) from services.bridge.routes")
|
|
210
|
+
else:
|
|
211
|
+
base_logger.warning("services.bridge.routes found but missing get_routes()")
|
|
212
|
+
|
|
213
|
+
# Projects can pass tornado Application settings (websocket_max_message_size, ...)
|
|
214
|
+
# plus max_body_size, which the server starters hand to HTTPServer.
|
|
215
|
+
if hasattr(routes_module, "get_app_settings"):
|
|
216
|
+
app_settings = routes_module.get_app_settings() or {}
|
|
217
|
+
base_logger.info(f"Loaded app settings from services.bridge.routes: {sorted(app_settings)}")
|
|
218
|
+
except ModuleNotFoundError as e:
|
|
219
|
+
# Only swallow "the routes module itself is absent". A missing import *inside*
|
|
220
|
+
# routes.py must not be reported as "no routes" and start a bridge that serves
|
|
221
|
+
# nothing.
|
|
222
|
+
if e.name not in ("services", "services.bridge", "services.bridge.routes"):
|
|
223
|
+
raise
|
|
224
|
+
base_logger.warning("No services.bridge.routes found — bridge has no routes")
|
|
225
|
+
|
|
226
|
+
# Initialize our application
|
|
227
|
+
app_kwargs: dict[str, Any] = {
|
|
228
|
+
"autoreload": False,
|
|
229
|
+
"xheaders": True,
|
|
230
|
+
}
|
|
231
|
+
app_kwargs.update(app_settings)
|
|
232
|
+
application = WebApplication(routes=routes, **app_kwargs)
|
|
233
|
+
application.set_pybridge(pybridge)
|
|
234
|
+
|
|
235
|
+
# Dev mode: run single process without forking
|
|
236
|
+
# This is needed to have autoreload working properly
|
|
237
|
+
if is_env_dev(config):
|
|
238
|
+
await child_process_initializer(application, args, base_logger, None, start_server_dev)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Bind sockets and start processes
|
|
242
|
+
sockets = netutil.bind_sockets(args.port, address=args.address, reuse_port=True)
|
|
243
|
+
|
|
244
|
+
# The parent typically runs as container PID 1, which ignores signals with no
|
|
245
|
+
# handler installed - `docker stop` would hang and end in SIGKILL. Forward
|
|
246
|
+
# TERM/INT to the process group so the children shut down gracefully.
|
|
247
|
+
def _forward_to_children(signum: int, frame: Any) -> None:
|
|
248
|
+
signal.signal(signum, signal.SIG_IGN) # killpg below also targets ourselves
|
|
249
|
+
os.killpg(0, signum)
|
|
250
|
+
|
|
251
|
+
signal.signal(signal.SIGTERM, _forward_to_children)
|
|
252
|
+
signal.signal(signal.SIGINT, _forward_to_children)
|
|
253
|
+
|
|
254
|
+
process.fork_processes(workers_auto() if not config["debug"] else 1)
|
|
255
|
+
|
|
256
|
+
# Children continue past the fork; restore default handling so the asyncio
|
|
257
|
+
# signal handlers installed by the server starter are the only ones active.
|
|
258
|
+
if process.task_id() is not None:
|
|
259
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
260
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
261
|
+
asyncio.run(child_process_initializer(application, args, base_logger, sockets, start_server_prod))
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Parent: fork_processes only returns here once all children have exited.
|
|
265
|
+
base_logger.info("Web service parent exiting (all children stopped)")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from argparse import ArgumentParser, _SubParsersAction # type: ignore
|
|
3
|
+
|
|
4
|
+
from py_app_runner.pybridge import PyBridge
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
###########################
|
|
8
|
+
### Register Subparsers ###
|
|
9
|
+
###########################
|
|
10
|
+
def reg_subparsers(
|
|
11
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
12
|
+
pybridge: PyBridge,
|
|
13
|
+
baseLogger: logging.Logger,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Command line subparsers"""
|
|
16
|
+
|
|
17
|
+
bridge_parser = subparsers.add_parser(
|
|
18
|
+
"bridge",
|
|
19
|
+
description="Bridge api handler service",
|
|
20
|
+
help="Bridge api handler service",
|
|
21
|
+
)
|
|
22
|
+
bridge_parser.add_argument("--address", type=str, default="0.0.0.0", help="Address to bind to")
|
|
23
|
+
bridge_parser.add_argument("--port", type=int, default=4100, help="Port")
|
|
24
|
+
bridge_parser.add_argument("--debug-wbcm", action="store_true", help="Enable debug for WBCM")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from py_app_runner.http_exception import HTTPException
|
|
4
|
+
|
|
5
|
+
# from memory_profiler import profile
|
|
6
|
+
from py_app_runner.request_handler.handlers import WebHandlerBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# * ApiHandler
|
|
10
|
+
class ApiHandler(WebHandlerBase):
|
|
11
|
+
"""Http request handler"""
|
|
12
|
+
|
|
13
|
+
def initialize(self) -> None: # type: ignore
|
|
14
|
+
# Override default logger
|
|
15
|
+
loggerName = f"{__name__}.{self.__class__.__name__}"
|
|
16
|
+
self.logger = logging.getLogger(loggerName)
|
|
17
|
+
|
|
18
|
+
########################
|
|
19
|
+
### Request Handling ###
|
|
20
|
+
########################
|
|
21
|
+
# def set_default_headers(self):
|
|
22
|
+
# # self.set_header("Access-Control-Allow-Origin", "*")
|
|
23
|
+
# origin = self.request.headers.get( # type: ignore
|
|
24
|
+
# "Origin", "*"
|
|
25
|
+
# ) # use current requesting origin
|
|
26
|
+
# self.set_header("Access-Control-Allow-Origin", origin) # type: ignore
|
|
27
|
+
# self.set_header(
|
|
28
|
+
# "Access-Control-Allow-Headers",
|
|
29
|
+
# "X-API-Key,Authorization,Accept,Origin,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,
|
|
30
|
+
# If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range",
|
|
31
|
+
# )
|
|
32
|
+
# self.set_header("Access-Control-Allow-Methods", "POST,GET,OPTIONS")
|
|
33
|
+
# self.set_header("Access-Control-Max-Age", 10800)
|
|
34
|
+
# self.set_header("Access-Control-Request-Headers", "*")
|
|
35
|
+
# self.set_header("Access-Control-Allow-Credentials", "true")
|
|
36
|
+
|
|
37
|
+
async def options(self, service: str | None = None, action: str | None = None) -> None: # type: ignore
|
|
38
|
+
self.set_header("Content-Type", "text/plain charset=UTF-8")
|
|
39
|
+
self.set_header("Content-Length", 0)
|
|
40
|
+
self.set_status(204)
|
|
41
|
+
await self.finish() # type: ignore
|
|
42
|
+
|
|
43
|
+
# @profile
|
|
44
|
+
async def get(self, service: str | None = None, action: str | None = None) -> None: # type: ignore
|
|
45
|
+
"""Get request"""
|
|
46
|
+
try:
|
|
47
|
+
async with self.timer.aenter("bridge.get"):
|
|
48
|
+
await self.get_v1(service=service, action=action)
|
|
49
|
+
|
|
50
|
+
except HTTPException as e:
|
|
51
|
+
self.log_request(error=e)
|
|
52
|
+
self.error(e)
|
|
53
|
+
|
|
54
|
+
except Exception as e:
|
|
55
|
+
self.log_request(error=e)
|
|
56
|
+
self.logger.exception(f"Error processing request with exception: {e}")
|
|
57
|
+
# Anything reaching here is a server-side fault, not a bad request - a 4xx
|
|
58
|
+
# would tell the client to stop retrying and hide the failure from monitoring.
|
|
59
|
+
self.error(
|
|
60
|
+
"Found an error while processing your request. Please try again later."
|
|
61
|
+
" Send us a message if the problem persists.",
|
|
62
|
+
http_status=500,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
finally:
|
|
66
|
+
self.auth_token = None
|
|
67
|
+
|
|
68
|
+
# Print debug and free resources
|
|
69
|
+
self.timer.print_timer_stats()
|
|
70
|
+
self.timer.reset_timers()
|
|
71
|
+
|
|
72
|
+
async def post(self, service: str | None = None, action: str | None = None) -> None: # type: ignore[override]
|
|
73
|
+
"""Post request is just a forward to get request"""
|
|
74
|
+
await self.get(service, action)
|
|
75
|
+
|
|
76
|
+
##########
|
|
77
|
+
### V1 ###
|
|
78
|
+
##########
|
|
79
|
+
async def get_v1(self, service: str | None = None, action: str | None = None) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Requests always have to be in this format:
|
|
82
|
+
{
|
|
83
|
+
"action": "action_name",
|
|
84
|
+
"data": {
|
|
85
|
+
"input1": "value1",
|
|
86
|
+
"input2": "value2",
|
|
87
|
+
...
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
async with self.timer.aenter("bridge.get_v1.findService"):
|
|
93
|
+
# Move to finding a service
|
|
94
|
+
res = self.find_service(service_name=service)
|
|
95
|
+
if not res.result:
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
f"Could not find service by provided name: {service}",
|
|
98
|
+
code=1002,
|
|
99
|
+
http_status=400,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Bridge request handler
|
|
103
|
+
bridge_request, requires_api_key = res.result
|
|
104
|
+
if not bridge_request:
|
|
105
|
+
raise HTTPException(f"Service not found: {service}", code=1003, http_status=400)
|
|
106
|
+
|
|
107
|
+
# Timer
|
|
108
|
+
async with self.timer.aenter("bridge.get_v1.withPools"):
|
|
109
|
+
# Check for valid api key
|
|
110
|
+
if requires_api_key is not False:
|
|
111
|
+
status = await self.has_valid_api_key()
|
|
112
|
+
if isinstance(status, str):
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
f"Application is not authenticated: {status}",
|
|
115
|
+
code=1401,
|
|
116
|
+
http_status=401,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Timer
|
|
120
|
+
async with self.timer.aenter("bridge.get_v1.run_request_handler"):
|
|
121
|
+
# Find request data
|
|
122
|
+
request_data = self.get_request_data(action)
|
|
123
|
+
action = request_data.get("action", None)
|
|
124
|
+
input_data = request_data.get("data", {})
|
|
125
|
+
|
|
126
|
+
if not action:
|
|
127
|
+
raise HTTPException("Missing action", code=1005, http_status=400)
|
|
128
|
+
|
|
129
|
+
return_data = await bridge_request(action, input_data, self)
|
|
130
|
+
if return_data is not None:
|
|
131
|
+
self.write(return_data)
|
|
132
|
+
self.log_request(response=return_data, level="debug")
|
|
133
|
+
else:
|
|
134
|
+
# A service that returns nothing produced no payload, which is not an
|
|
135
|
+
# error - the WebSocket path stays silent in the same case. 204 has no
|
|
136
|
+
# body, so nothing must be written here.
|
|
137
|
+
self.set_status(204)
|
|
138
|
+
self.log_request(level="debug")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MessageEncoder(ABC):
|
|
6
|
+
"""Abstract base for WebSocket message encoding/decoding."""
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def encode(self, data: dict[str, Any]) -> str | bytes:
|
|
10
|
+
"""Encode a dict into a wire format (str for text frames, bytes for binary)."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def decode(self, raw: str | bytes) -> dict[str, Any]:
|
|
14
|
+
"""Decode a raw WebSocket message into a dict."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def is_binary(self) -> bool:
|
|
19
|
+
"""Whether this encoder produces binary frames (True) or text frames (False)."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def name(self) -> str:
|
|
24
|
+
"""Human-readable name for logging."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from py_app_runner.utils import json_decode, json_encode
|
|
4
|
+
|
|
5
|
+
from .base import MessageEncoder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonEncoder(MessageEncoder):
|
|
9
|
+
"""JSON message encoder for WebSocket frames."""
|
|
10
|
+
|
|
11
|
+
def encode(self, data: dict[str, Any]) -> str:
|
|
12
|
+
return json_encode(data)
|
|
13
|
+
|
|
14
|
+
def decode(self, raw: str | bytes) -> dict[str, Any]:
|
|
15
|
+
try:
|
|
16
|
+
return json_decode(raw)
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
raise ValueError(str(exc)) from exc
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_binary(self) -> bool:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return "json"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import math
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import msgpack
|
|
8
|
+
|
|
9
|
+
from .base import MessageEncoder
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _msgpack_default(obj: Any) -> Any:
|
|
13
|
+
"""Custom packer for types msgpack doesn't handle natively."""
|
|
14
|
+
if isinstance(obj, Decimal):
|
|
15
|
+
return float(obj)
|
|
16
|
+
|
|
17
|
+
if isinstance(obj, dt.datetime):
|
|
18
|
+
# Normalize to UTC: the wire format carries no offset.
|
|
19
|
+
if obj.tzinfo is not None:
|
|
20
|
+
obj = obj.astimezone(dt.UTC).replace(tzinfo=None)
|
|
21
|
+
return obj.strftime("%Y-%m-%dT%H:%M:%S")
|
|
22
|
+
|
|
23
|
+
if isinstance(obj, dt.date):
|
|
24
|
+
return obj.strftime("%Y-%m-%d")
|
|
25
|
+
|
|
26
|
+
if isinstance(obj, Enum):
|
|
27
|
+
return obj.value
|
|
28
|
+
|
|
29
|
+
if isinstance(obj, float):
|
|
30
|
+
if not math.isfinite(obj):
|
|
31
|
+
return None
|
|
32
|
+
return obj
|
|
33
|
+
|
|
34
|
+
if isinstance(obj, (int, str)):
|
|
35
|
+
return obj
|
|
36
|
+
|
|
37
|
+
return str(obj)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MsgpackEncoder(MessageEncoder):
|
|
41
|
+
"""MessagePack encoder for WebSocket binary frames."""
|
|
42
|
+
|
|
43
|
+
def encode(self, data: dict[str, Any]) -> bytes:
|
|
44
|
+
result: bytes = msgpack.packb(data, default=_msgpack_default, use_bin_type=True) # type: ignore[assignment]
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
def decode(self, raw: str | bytes) -> dict[str, Any]:
|
|
48
|
+
if isinstance(raw, str):
|
|
49
|
+
raw = raw.encode("utf-8")
|
|
50
|
+
return msgpack.unpackb(raw, raw=False)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_binary(self) -> bool:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def name(self) -> str:
|
|
58
|
+
return "msgpack"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from tornado.web import url as TornadoUrl
|
|
4
|
+
|
|
5
|
+
from py_app_runner.config import is_env_dev
|
|
6
|
+
from py_app_runner.registry import AppRegistry
|
|
7
|
+
from py_app_runner.request_handler.handlers import WebApplicationBase
|
|
8
|
+
|
|
9
|
+
RouteDefinition = tuple[str, type, str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebApplication(WebApplicationBase):
|
|
13
|
+
"""Tornado web application that builds its handler list from a project-defined routes list."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, routes: list[RouteDefinition] | None = None, **kwargs: Any):
|
|
16
|
+
handlers = []
|
|
17
|
+
if routes:
|
|
18
|
+
for pattern, handler_class, name in routes:
|
|
19
|
+
handlers.append(TornadoUrl(pattern, handler_class, name=name))
|
|
20
|
+
|
|
21
|
+
kwargs["handlers"] = handlers
|
|
22
|
+
# setdefault, so a project can override it through get_app_settings().
|
|
23
|
+
kwargs.setdefault("debug", is_env_dev(AppRegistry.config()))
|
|
24
|
+
# Without server pings, dead peers are never detected and writes to them
|
|
25
|
+
# buffer in memory unboundedly.
|
|
26
|
+
# Tornado clamps the timeout to the interval, so a larger value here would be
|
|
27
|
+
# discarded and logged as a warning.
|
|
28
|
+
kwargs.setdefault("websocket_ping_interval", 30)
|
|
29
|
+
kwargs.setdefault("websocket_ping_timeout", 30)
|
|
30
|
+
|
|
31
|
+
super().__init__(**kwargs)
|