Skip to content

Commit b9b699e

Browse files
author
Marius Isken
committed
server: adding basic tcp server
1 parent 0d1bbc3 commit b9b699e

3 files changed

Lines changed: 116 additions & 122 deletions

File tree

src/qseek/apps/qseek.py

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -255,41 +255,28 @@ def main() -> None:
255255
case "search":
256256
nest_asyncio.apply()
257257
from qseek.search import Search
258-
from qseek.server import WebServer
259258

260259
search = Search.from_config(args.config)
261260

262-
webserver = WebServer(search)
263-
264-
async def run() -> None:
265-
http = asyncio.create_task(webserver.start())
266-
await search.start(
261+
asyncio.run(
262+
search.start(
267263
force_rundir=args.force,
268264
create_backup=not args.no_backup,
269-
)
270-
await http
271-
272-
asyncio.run(run(), debug=loop_debug)
265+
),
266+
debug=loop_debug,
267+
)
273268

274269
case "continue":
275270
nest_asyncio.apply()
276271
from qseek.search import Search
277-
from qseek.server import WebServer
278272

279273
search = Search.load_rundir(args.rundir)
280274
if search._progress.time_progress:
281275
console.rule(f"Continuing search from {search._progress.time_progress}")
282276
else:
283277
console.rule("Starting search from scratch")
284278

285-
webserver = WebServer(search)
286-
287-
async def run() -> None:
288-
http = asyncio.create_task(webserver.start())
289-
await search.start()
290-
await http
291-
292-
asyncio.run(run(), debug=loop_debug)
279+
asyncio.run(search.start(), debug=loop_debug)
293280

294281
case "snuffler":
295282
from qseek.search import Search
@@ -380,18 +367,6 @@ async def worker() -> None:
380367

381368
start_ui(args.uris, reload=False)
382369

383-
case "serve":
384-
from qseek.search import Search
385-
from qseek.server import WebServer
386-
387-
search = Search.load_rundir(args.rundir)
388-
webserver = WebServer(search)
389-
390-
async def start() -> None:
391-
await webserver.start()
392-
393-
asyncio.run(start(), debug=loop_debug)
394-
395370
case "clear-cache":
396371
logger.info("clearing cache directory %s", CACHE_DIR)
397372
shutil.rmtree(CACHE_DIR)

src/qseek/search.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from qseek.pre_processing.frequency_filters import Bandpass
3939
from qseek.pre_processing.module import Downsample, PreProcessing
4040
from qseek.reduce import DelaySumReduce
41+
from qseek.server import WebServer
4142
from qseek.signals import Signal
4243
from qseek.stats import RuntimeStats, Stats
4344
from qseek.tracers.tracers import RayTracer, RayTracers
@@ -284,6 +285,10 @@ class Search(Model):
284285
default_factory=list,
285286
description="Event features to extract.",
286287
)
288+
webserver: WebServer | None = Field(
289+
default_factory=WebServer,
290+
description="Web server for serving search results and monitoring.",
291+
)
287292

288293
semblance_sampling_rate: SamplingRate = Field(
289294
default=100,
@@ -512,6 +517,10 @@ async def prepare(self) -> None:
512517
for magnitude in self.magnitudes:
513518
await magnitude.prepare(self.octree, self.stations)
514519

520+
if self.webserver:
521+
await self.webserver.prepare(search=self)
522+
BackgroundTasks.create_task(self.webserver.start())
523+
515524
csv_header = EventDetection.csv_header()
516525
for m in self.magnitudes:
517526
csv_header.extend(m.csv_header())
@@ -605,6 +614,8 @@ async def start(
605614
await self._catalog.export_detections(
606615
jitter_location=self.octree.smallest_node_size()
607616
)
617+
if self.webserver:
618+
self.webserver.stop()
608619
console.cancel()
609620
logger.info("finished search in %s", datetime_now() - processing_start)
610621
logger.info("detected %d events", self._catalog.n_events)
@@ -627,6 +638,9 @@ async def new_detections(self, detections: list[EventDetection]) -> None:
627638
)
628639
await self._new_detection.emit(detection)
629640

641+
if self.webserver:
642+
BackgroundTasks.create_task(self.webserver.new_detections(detections))
643+
630644
if not catalog.n_events:
631645
return
632646

src/qseek/server.py

Lines changed: 96 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,100 +2,59 @@
22

33
import logging
44
from importlib.metadata import version
5-
from typing import TYPE_CHECKING, AbstractSet, Any, Mapping, Type, TypeVar
6-
from uuid import UUID
5+
from ipaddress import IPv4Address
6+
from typing import TYPE_CHECKING, Literal
7+
from weakref import WeakSet
78

9+
import aiofiles
810
import aiohttp_cors
911
from aiohttp import web
10-
from pydantic import BaseModel, ValidationError
12+
from pydantic import Field, PrivateAttr
13+
14+
from qseek.base import Model
1115

1216
if TYPE_CHECKING:
17+
from qseek.models.detection import EventDetection
1318
from qseek.search import Search
1419

1520
logger = logging.getLogger(__name__)
16-
logger.setLevel(logging.WARNING)
17-
MAX_TRIES = 5
1821

19-
TBaseModel = TypeVar("TBaseModel", bound=BaseModel)
20-
AbstractSetIntStr = AbstractSet[int | str]
21-
MappingIntStrAny = Mapping[int | str, Any]
22-
23-
24-
class DetectionRequest(BaseModel):
25-
uid: UUID
26-
27-
28-
async def parse_pydantic_request(
29-
request: web.Request, model: Type[TBaseModel]
30-
) -> TBaseModel:
31-
"""Parse a aiohttp request and return a pydantic model.
32-
33-
Raises:
34-
web.HTTPMethodNotAllowed: If method is not GET/POST/PUT
35-
web.HTTPBadRequest: _description_
36-
web.HTTPError: _description_
37-
38-
Returns:
39-
TBaseModel: Parsed pydantic model.
40-
"""
41-
try:
42-
if request.method == "GET":
43-
query = {}
44-
for key, value in request.query.items():
45-
if key.endswith("[]"):
46-
key = key.strip("[]")
47-
if key not in query:
48-
query[key] = []
49-
query[key].append(value)
50-
else:
51-
query[key] = value
52-
53-
data = model.model_validate(query)
54-
elif request.method in ("POST", "PUT"):
55-
data = model.model_validate(await request.post())
56-
else:
57-
raise web.HTTPMethodNotAllowed(
58-
request.method, allowed_methods=("GET", "POST", "PUT")
59-
)
60-
except ValidationError as exc:
61-
raise web.HTTPBadRequest(
62-
text=exc.json(), content_type="application/json"
63-
) from exc
64-
except Exception as exc:
65-
raise web.HTTPError(text="Cannot parse request") from exc
66-
return data
67-
68-
69-
def pydantic_response(
70-
model: BaseModel,
71-
headers: dict[str, Any] | None = None,
72-
exclude: dict | None = None,
73-
) -> web.Response:
74-
try:
75-
return web.json_response(
76-
text=model.model_dump_json(exclude=exclude).replace("NaN", "null"),
77-
headers=headers,
78-
)
79-
except TypeError as exc:
80-
return web.HTTPServerError(reason=str(exc))
22+
MAX_TRIES = 5
8123

8224

83-
class WebServer:
84-
def __init__(self, search: Search) -> None:
85-
self.search = search
86-
self.app = web.Application(logger=logger)
25+
class WebServer(Model):
26+
host: IPv4Address = Field(
27+
default=IPv4Address("0.0.0.0"),
28+
description="Host to bind the server to. Default is all interfaces.",
29+
)
30+
port: int | Literal["auto"] = Field(
31+
default="auto",
32+
description="Port to bind the server to. Default is 'auto', which tries to "
33+
"bind to port 19000 and increments if the port is already in use.",
34+
)
35+
36+
_search: Search = PrivateAttr()
37+
_app: web.Application = PrivateAttr()
38+
_site: web.TCPSite | None = PrivateAttr(default=None)
39+
_open_websockets: WeakSet[web.WebSocketResponse] = PrivateAttr(
40+
default_factory=WeakSet
41+
)
42+
43+
def prepare(self, search: Search) -> None:
44+
self._search = search
45+
self._app = web.Application(logger=logger)
8746
self.add_routes()
8847

8948
def add_routes(self) -> None:
90-
router = self.app.router
49+
router = self._app.router
9150
router.add_get("/", self.get_index)
9251
router.add_get("/api/v1/detections", self.get_detections)
52+
router.add_get("/api/v1/receivers", self.get_receivers)
9353
router.add_get("/api/v1/search", self.get_search)
94-
router.add_get(r"/api/v1/detection/{uid}", self.get_detection)
9554

9655
# Configure default CORS settings.
9756
cors = aiohttp_cors.setup(
98-
self.app,
57+
self._app,
9958
defaults={
10059
"*": aiohttp_cors.ResourceOptions(
10160
allow_credentials=True,
@@ -117,34 +76,80 @@ async def get_index(self, request: web.Request) -> web.Response:
11776
qseek_version = version("qseek")
11877
return web.Response(text=f"{qseek_version} HTTP server running...")
11978

120-
async def get_detections(self, request: web.Request) -> web.Response:
121-
return pydantic_response(
122-
self.search._catalog,
123-
exclude={"detections": {"__all__": {"octree", "stations"}}},
79+
async def get_detections(
80+
self, request: web.Request, n_detections: int = -1
81+
) -> web.Response:
82+
async with aiofiles.open(self._search._catalog.detections_file, "r") as f:
83+
lines = await f.readlines()
84+
data = "{" + lines + "}"
85+
await web.Response(text=data, content_type="application/json")
86+
87+
async def get_receivers(
88+
self, request: web.Request, n_detections: int = -1
89+
) -> web.Response:
90+
async with aiofiles.open(self._search._catalog.receivers_file, "r") as f:
91+
lines = await f.readlines()
92+
data = "{" + lines + "}"
93+
await web.Response(text=data, content_type="application/json")
94+
95+
async def get_search(self, request: web.Request) -> web.Response:
96+
return web.Response(
97+
self._search.model_dump_json(),
98+
content_type="application/json",
12499
)
125100

126-
async def get_detection(self, request: web.Request) -> web.Response:
127-
req = await parse_pydantic_request(request, model=DetectionRequest)
128-
try:
129-
detection = self.search._catalog.get(req.uid)
130-
except KeyError:
131-
return web.HTTPNotFound()
132-
return pydantic_response(detection)
101+
async def websocket_handler(self, request: web.Request) -> web.WebSocketResponse:
102+
ws = web.WebSocketResponse()
103+
await ws.prepare(request)
104+
self._open_websockets.add(ws)
105+
logger.info("New WebSocket connection established: %s", request.remote)
106+
107+
for msg in ws:
108+
if msg.type == web.WSMsgType.TEXT:
109+
# Handle incoming messages from the client if needed
110+
pass
111+
elif msg.type == web.WSMsgType.ERROR:
112+
logger.error(
113+
"WebSocket connection closed with exception %s", ws.exception()
114+
)
133115

134-
async def get_search(self, request: web.Request) -> web.Response:
135-
return pydantic_response(self.search)
116+
async def new_detections(self, detections: list[EventDetection]) -> None:
117+
if not self._open_websockets:
118+
return
119+
120+
data = {
121+
"type": "new_detections",
122+
"detections": [d.model_dump(exclude={"receivers"}) for d in detections],
123+
"receivers": [d.receivers.model_dump() for d in detections],
124+
}
125+
126+
for ws in self._open_websockets:
127+
await ws.send_json(data)
136128

137129
async def start(self) -> None:
138-
runner = web.AppRunner(self.app)
130+
runner = web.AppRunner(self._app)
139131
await runner.setup()
140132

141-
port = 3445
133+
port = 19000 if self.port == "auto" else self.port
142134

143135
for _ in range(MAX_TRIES):
144136
try:
145-
site = web.TCPSite(runner, "0.0.0.0", port)
137+
site = web.TCPSite(runner, str(self.host), port)
146138
await site.start()
147-
logger.info("started on http://0.0.0.0:%d", port)
139+
logger.info("started on http://%s:%d", self.host, port)
140+
self._site = site
148141
break
149142
except OSError:
143+
if self.port != "auto":
144+
raise
145+
logger.warning("port %d is in use, trying next port...", port)
150146
port += 1
147+
else:
148+
raise OSError(f"Could not bind to a port after {MAX_TRIES} tries")
149+
150+
async def stop(self) -> None:
151+
logger.info("shutting down HTTP server...")
152+
if self._site:
153+
await self._site.stop()
154+
await self._app.shutdown()
155+
await self._app.cleanup()

0 commit comments

Comments
 (0)