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,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,5 @@
1
+ from .base import MessageEncoder
2
+ from .json_encoder import JsonEncoder
3
+ from .msgpack_encoder import MsgpackEncoder
4
+
5
+ __all__ = ["MessageEncoder", "JsonEncoder", "MsgpackEncoder"]
@@ -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)