mobs-ws 0.16.1__tar.gz
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.
- mobs_ws-0.16.1/LICENSE +21 -0
- mobs_ws-0.16.1/PKG-INFO +65 -0
- mobs_ws-0.16.1/README.md +43 -0
- mobs_ws-0.16.1/pyproject.toml +36 -0
- mobs_ws-0.16.1/setup.cfg +4 -0
- mobs_ws-0.16.1/src/mobs_ws/__init__.py +41 -0
- mobs_ws-0.16.1/src/mobs_ws/__main__.py +27 -0
- mobs_ws-0.16.1/src/mobs_ws/client.py +368 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/PKG-INFO +65 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/SOURCES.txt +13 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/dependency_links.txt +1 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/entry_points.txt +2 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/requires.txt +1 -0
- mobs_ws-0.16.1/src/mobs_ws.egg-info/top_level.txt +1 -0
- mobs_ws-0.16.1/tests/test_client.py +173 -0
mobs_ws-0.16.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MOBS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mobs_ws-0.16.1/PKG-INFO
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobs-ws
|
|
3
|
+
Version: 0.16.1
|
|
4
|
+
Summary: Official client for the MOBS /v1/ws/metar WebSocket (protocol 4).
|
|
5
|
+
Author-email: MOBS <admin@mobs.ws>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://mobs.ws
|
|
8
|
+
Project-URL: Documentation, https://docs.mobs.ws/websocket
|
|
9
|
+
Project-URL: Repository, https://github.com/mobs-ws/mobs-sdks
|
|
10
|
+
Project-URL: Issues, https://github.com/mobs-ws/mobs-sdks/issues
|
|
11
|
+
Keywords: mobs,metar,websocket
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: websockets>=17.3
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# mobs-ws
|
|
24
|
+
|
|
25
|
+
Official Python client for the MOBS `/v1/ws/metar` WebSocket (protocol 4).
|
|
26
|
+
|
|
27
|
+
The only runtime dependency is `websockets`. HTTP backfill uses urllib from
|
|
28
|
+
the standard library. Snapshot and resume are opt-in. The client applies
|
|
29
|
+
`awc_confirmed` frames last-wins per (`icao`, `versions`), handles
|
|
30
|
+
`station_late`, `station_recovered`, and `usage` (use `request_usage()`),
|
|
31
|
+
and ignores unknown frame types. Late alerts are kept as the latest frame per `outage_id` and cleared on `station_recovered`. More than one `station_late` can arrive in the same outage. A change-only `filter` and parsed
|
|
32
|
+
`delta` are opt-in on a mid-session `subscribe`, not on the handshake.
|
|
33
|
+
|
|
34
|
+
The API key is never embedded. Pass it to the constructor, or set
|
|
35
|
+
`MOBS_TOKEN`.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install mobs-ws
|
|
39
|
+
export MOBS_TOKEN=$MOBS_TOKEN
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
import os
|
|
45
|
+
|
|
46
|
+
from mobs_ws import MetarClient
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = MetarClient(
|
|
50
|
+
airports=["KNYC", "KLGA"],
|
|
51
|
+
token=os.environ["MOBS_TOKEN"], # or token="YOUR_API_KEY"
|
|
52
|
+
snapshot=True,
|
|
53
|
+
resume=True,
|
|
54
|
+
on_record=lambda rec: print(rec["icao"], rec["raw_metar"], rec.get("awc_confirmed")),
|
|
55
|
+
)
|
|
56
|
+
await client.run_forever()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m mobs_ws --airports KNYC,KLGA
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
MIT licensed. See [LICENSE](https://github.com/mobs-ws/mobs-sdks/blob/main/LICENSE).
|
mobs_ws-0.16.1/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# mobs-ws
|
|
2
|
+
|
|
3
|
+
Official Python client for the MOBS `/v1/ws/metar` WebSocket (protocol 4).
|
|
4
|
+
|
|
5
|
+
The only runtime dependency is `websockets`. HTTP backfill uses urllib from
|
|
6
|
+
the standard library. Snapshot and resume are opt-in. The client applies
|
|
7
|
+
`awc_confirmed` frames last-wins per (`icao`, `versions`), handles
|
|
8
|
+
`station_late`, `station_recovered`, and `usage` (use `request_usage()`),
|
|
9
|
+
and ignores unknown frame types. Late alerts are kept as the latest frame per `outage_id` and cleared on `station_recovered`. More than one `station_late` can arrive in the same outage. A change-only `filter` and parsed
|
|
10
|
+
`delta` are opt-in on a mid-session `subscribe`, not on the handshake.
|
|
11
|
+
|
|
12
|
+
The API key is never embedded. Pass it to the constructor, or set
|
|
13
|
+
`MOBS_TOKEN`.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install mobs-ws
|
|
17
|
+
export MOBS_TOKEN=$MOBS_TOKEN
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
from mobs_ws import MetarClient
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
client = MetarClient(
|
|
28
|
+
airports=["KNYC", "KLGA"],
|
|
29
|
+
token=os.environ["MOBS_TOKEN"], # or token="YOUR_API_KEY"
|
|
30
|
+
snapshot=True,
|
|
31
|
+
resume=True,
|
|
32
|
+
on_record=lambda rec: print(rec["icao"], rec["raw_metar"], rec.get("awc_confirmed")),
|
|
33
|
+
)
|
|
34
|
+
await client.run_forever()
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m mobs_ws --airports KNYC,KLGA
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
MIT licensed. See [LICENSE](https://github.com/mobs-ws/mobs-sdks/blob/main/LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mobs-ws"
|
|
3
|
+
version = "0.16.1"
|
|
4
|
+
description = "Official client for the MOBS /v1/ws/metar WebSocket (protocol 4)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [{name = "MOBS", email = "admin@mobs.ws"}]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"websockets>=17.3",
|
|
12
|
+
]
|
|
13
|
+
keywords = ["mobs", "metar", "websocket"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Internet",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://mobs.ws"
|
|
24
|
+
Documentation = "https://docs.mobs.ws/websocket"
|
|
25
|
+
Repository = "https://github.com/mobs-ws/mobs-sdks"
|
|
26
|
+
Issues = "https://github.com/mobs-ws/mobs-sdks/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
mobs-ws = "mobs_ws.__main__:main"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["setuptools>=77"]
|
|
33
|
+
build-backend = "setuptools.build_meta"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
mobs_ws-0.16.1/setup.cfg
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Official client for MOBS /v1/ws/metar (protocol 4)."""
|
|
2
|
+
|
|
3
|
+
from mobs_ws.client import (
|
|
4
|
+
BACKOFF_CAP_S,
|
|
5
|
+
BACKOFF_START_S,
|
|
6
|
+
DEFAULT_URL,
|
|
7
|
+
PING_EVERY_S,
|
|
8
|
+
PONG_TIMEOUT_S,
|
|
9
|
+
QUIET_OPEN_S,
|
|
10
|
+
MetarClient,
|
|
11
|
+
MetarExample,
|
|
12
|
+
apply_awc_confirmed,
|
|
13
|
+
fetch_latest,
|
|
14
|
+
handshake_url,
|
|
15
|
+
latest_url,
|
|
16
|
+
merge_record,
|
|
17
|
+
next_backoff,
|
|
18
|
+
resolve_token,
|
|
19
|
+
should_retry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BACKOFF_CAP_S",
|
|
24
|
+
"BACKOFF_START_S",
|
|
25
|
+
"DEFAULT_URL",
|
|
26
|
+
"PING_EVERY_S",
|
|
27
|
+
"PONG_TIMEOUT_S",
|
|
28
|
+
"QUIET_OPEN_S",
|
|
29
|
+
"MetarClient",
|
|
30
|
+
"MetarExample",
|
|
31
|
+
"apply_awc_confirmed",
|
|
32
|
+
"fetch_latest",
|
|
33
|
+
"handshake_url",
|
|
34
|
+
"latest_url",
|
|
35
|
+
"merge_record",
|
|
36
|
+
"next_backoff",
|
|
37
|
+
"resolve_token",
|
|
38
|
+
"should_retry",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.16.1"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from mobs_ws.client import DEFAULT_URL, MetarClient, resolve_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(description="MOBS /v1/ws/metar official client")
|
|
13
|
+
parser.add_argument("--url", default=os.environ.get("MOBS_WS_URL", DEFAULT_URL))
|
|
14
|
+
parser.add_argument("--airports", default="KNYC,KLGA")
|
|
15
|
+
parser.add_argument("--token", default=None)
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
try:
|
|
18
|
+
token = resolve_token(args.token)
|
|
19
|
+
except ValueError:
|
|
20
|
+
sys.exit("set MOBS_TOKEN or pass --token; use the placeholder YOUR_API_KEY in examples only")
|
|
21
|
+
airports = [part.strip() for part in args.airports.split(",") if part.strip()]
|
|
22
|
+
client = MetarClient(airports, token=token, url=args.url)
|
|
23
|
+
asyncio.run(client.run_forever())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Official client for MOBS /v1/ws/metar (protocol 4).
|
|
2
|
+
|
|
3
|
+
Promoted from examples/ws_client.py. Uses the ``websockets`` package
|
|
4
|
+
only. HTTP backfill uses urllib from the standard library.
|
|
5
|
+
|
|
6
|
+
Features: JSON ping every 20 s, 15 s pong timeout, exponential backoff
|
|
7
|
+
with jitter (reset after the first message or about 10 s of an open
|
|
8
|
+
socket; the 10 s timer starts when the socket opens), opt-in snapshot
|
|
9
|
+
and resume with since_seq and boot_id, GET /v1/metar/latest on a resume
|
|
10
|
+
fallback (boot_id change, partial, or none), dedupe on icao+versions
|
|
11
|
+
plus icao+obs_time+raw_metar, awc_confirmed last-wins per
|
|
12
|
+
(icao, versions), station_late / station_recovered / usage
|
|
13
|
+
handled (seq advances; usage is a point-in-time read), and
|
|
14
|
+
unknown frame types ignored. Filter and delta are opt-in on a
|
|
15
|
+
mid-session subscribe, not on the handshake query.
|
|
16
|
+
|
|
17
|
+
The token comes only from the caller or the MOBS_TOKEN environment
|
|
18
|
+
variable. It is never embedded.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from websockets.asyncio.client import connect
|
|
34
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
35
|
+
|
|
36
|
+
DEFAULT_URL = "wss://mobs.ws/v1/ws/metar"
|
|
37
|
+
PING_EVERY_S = 20.0
|
|
38
|
+
PONG_TIMEOUT_S = 15.0
|
|
39
|
+
BACKOFF_START_S = 1.0
|
|
40
|
+
BACKOFF_CAP_S = 30.0
|
|
41
|
+
QUIET_OPEN_S = 10.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_token(token: str | None = None) -> str:
|
|
45
|
+
"""Return the caller-supplied token, or MOBS_TOKEN from the environment."""
|
|
46
|
+
value = token if token not in (None, "") else os.environ.get("MOBS_TOKEN") or ""
|
|
47
|
+
if not value:
|
|
48
|
+
raise ValueError("pass a token or set MOBS_TOKEN")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def next_backoff(delay: float, *, rand: Callable[[], float] = random.random) -> float:
|
|
53
|
+
"""Double, cap at about 30 s, then apply jitter (about 15-45 s at the cap)."""
|
|
54
|
+
grown = min(delay * 2.0, BACKOFF_CAP_S)
|
|
55
|
+
return grown * (0.5 + rand())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def should_retry(code: int | None) -> bool:
|
|
59
|
+
return code != 4403
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _invalid_status_code(exc: InvalidStatus) -> int | None:
|
|
63
|
+
response = getattr(exc, "response", None)
|
|
64
|
+
return getattr(response, "status_code", None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def connect_failure_code(exc: BaseException) -> int:
|
|
68
|
+
"""Map a failed connect to a close code: 401/403 -> 4403 (stop), else 1006."""
|
|
69
|
+
if isinstance(exc, InvalidStatus) and _invalid_status_code(exc) in {401, 403}:
|
|
70
|
+
return 4403
|
|
71
|
+
return 1006
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def newer_than(incoming: dict, held: dict) -> bool:
|
|
75
|
+
"""Live (or any newer versions / later body) wins over a cached or older record."""
|
|
76
|
+
if incoming.get("versions") != held.get("versions"):
|
|
77
|
+
return True
|
|
78
|
+
if (incoming.get("icao"), incoming.get("obs_time"), incoming.get("raw_metar")) != (
|
|
79
|
+
held.get("icao"),
|
|
80
|
+
held.get("obs_time"),
|
|
81
|
+
held.get("raw_metar"),
|
|
82
|
+
):
|
|
83
|
+
return True
|
|
84
|
+
return incoming.get("_live", False) and not held.get("_live", False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def merge_record(latest: dict[str, dict], record: dict, *, live: bool = False) -> dict:
|
|
88
|
+
"""Dedupe on icao+versions; icao+obs_time+raw_metar is the body identity."""
|
|
89
|
+
item = dict(record)
|
|
90
|
+
item["_live"] = live
|
|
91
|
+
icao = item.get("icao")
|
|
92
|
+
if not icao:
|
|
93
|
+
return latest
|
|
94
|
+
held = latest.get(icao)
|
|
95
|
+
if held is None or newer_than(item, held):
|
|
96
|
+
latest[icao] = item
|
|
97
|
+
return latest
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply_awc_confirmed(latest: dict[str, dict], message: dict) -> dict:
|
|
101
|
+
"""Last-wins per (icao, versions). Ignore a versions the client never saw."""
|
|
102
|
+
icao = message.get("icao")
|
|
103
|
+
versions = message.get("versions")
|
|
104
|
+
if not icao or versions is None:
|
|
105
|
+
return latest
|
|
106
|
+
held = latest.get(icao)
|
|
107
|
+
if held is None or held.get("versions") != versions:
|
|
108
|
+
return latest
|
|
109
|
+
held["awc_confirmed"] = True
|
|
110
|
+
held["awc_confirmed_at"] = message.get("awc_confirmed_at")
|
|
111
|
+
return latest
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def handshake_url(
|
|
115
|
+
base: str,
|
|
116
|
+
airports: list[str],
|
|
117
|
+
*,
|
|
118
|
+
snapshot: bool = True,
|
|
119
|
+
since_seq: int | None = None,
|
|
120
|
+
boot_id: str | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
parsed = urllib.parse.urlsplit(base)
|
|
123
|
+
params = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
|
|
124
|
+
params["airports"] = ",".join(airports)
|
|
125
|
+
if snapshot:
|
|
126
|
+
params["snapshot"] = "true"
|
|
127
|
+
if since_seq is not None and boot_id:
|
|
128
|
+
params["since_seq"] = str(since_seq)
|
|
129
|
+
params["boot_id"] = boot_id
|
|
130
|
+
query = urllib.parse.urlencode(params)
|
|
131
|
+
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/v1/ws/metar", query, ""))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def latest_url(base: str, airports: list[str]) -> str:
|
|
135
|
+
parsed = urllib.parse.urlsplit(base)
|
|
136
|
+
scheme = "https" if parsed.scheme in {"wss", "https"} else "http"
|
|
137
|
+
netloc = parsed.netloc
|
|
138
|
+
path = "/v1/metar/latest"
|
|
139
|
+
query = urllib.parse.urlencode({"airports": ",".join(airports)})
|
|
140
|
+
return urllib.parse.urlunsplit((scheme, netloc, path, query, ""))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fetch_latest(url: str, token: str, airports: list[str]) -> list[dict]:
|
|
144
|
+
target = latest_url(url, airports)
|
|
145
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
146
|
+
request = urllib.request.Request(target, headers=headers)
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
149
|
+
body = json.loads(response.read().decode())
|
|
150
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
151
|
+
return []
|
|
152
|
+
return list(body.get("data") or [])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _outage_key(message: dict) -> str:
|
|
157
|
+
return str(message.get("outage_id") or message.get("icao") or "")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MetarClient:
|
|
161
|
+
"""Reconnecting client for /v1/ws/metar."""
|
|
162
|
+
|
|
163
|
+
fetch_latest = staticmethod(fetch_latest)
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
airports: list[str],
|
|
168
|
+
*,
|
|
169
|
+
token: str | None = None,
|
|
170
|
+
url: str = DEFAULT_URL,
|
|
171
|
+
snapshot: bool = True,
|
|
172
|
+
resume: bool = True,
|
|
173
|
+
on_record: Callable[[dict], Any] | None = None,
|
|
174
|
+
on_frame: Callable[[dict], Any] | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.url = url
|
|
177
|
+
self.token = resolve_token(token)
|
|
178
|
+
self.airports = [name.upper() for name in airports]
|
|
179
|
+
self.snapshot = snapshot
|
|
180
|
+
self.resume = resume
|
|
181
|
+
self.on_record = on_record
|
|
182
|
+
self.on_frame = on_frame
|
|
183
|
+
self.latest: dict[str, dict] = {}
|
|
184
|
+
self.boot_id: str | None = None
|
|
185
|
+
self.seq: int | None = None
|
|
186
|
+
self._delay = BACKOFF_START_S
|
|
187
|
+
self._stop = asyncio.Event()
|
|
188
|
+
self._ws: Any = None
|
|
189
|
+
self.last_usage: dict | None = None
|
|
190
|
+
self.last_late: dict | None = None
|
|
191
|
+
self.last_recovered: dict | None = None
|
|
192
|
+
self.lates: dict[str, dict] = {} # latest station_late per outage_id
|
|
193
|
+
|
|
194
|
+
def handshake_url(self) -> str:
|
|
195
|
+
return handshake_url(
|
|
196
|
+
self.url,
|
|
197
|
+
self.airports,
|
|
198
|
+
snapshot=self.snapshot,
|
|
199
|
+
since_seq=self.seq if self.resume else None,
|
|
200
|
+
boot_id=self.boot_id if self.resume else None,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def handle_frame(self, message: dict) -> None:
|
|
204
|
+
kind = message.get("type")
|
|
205
|
+
if kind == "subscription_updated":
|
|
206
|
+
if message.get("boot_id"):
|
|
207
|
+
self.boot_id = message["boot_id"]
|
|
208
|
+
# Do not take seq from subscription_updated: it is the server's
|
|
209
|
+
# current seq, and would skip replay frames not yet received.
|
|
210
|
+
elif kind == "resume":
|
|
211
|
+
self._apply_resume(self.fetch_latest(self.url, self.token, self.airports))
|
|
212
|
+
elif kind == "new_metar_arrived":
|
|
213
|
+
live = message.get("cached") is False and message.get("replay") is False
|
|
214
|
+
replayed = message.get("replay") is True
|
|
215
|
+
for rec in message.get("data") or []:
|
|
216
|
+
before = self.latest.get(rec.get("icao") or "")
|
|
217
|
+
merge_record(self.latest, rec, live=live)
|
|
218
|
+
self._emit(rec.get("icao"), before)
|
|
219
|
+
if (live or replayed) and message.get("seq") is not None:
|
|
220
|
+
self._advance_seq(message["seq"])
|
|
221
|
+
elif kind == "awc_confirmed":
|
|
222
|
+
if message.get("seq") is not None:
|
|
223
|
+
self._advance_seq(message["seq"])
|
|
224
|
+
icao = message.get("icao")
|
|
225
|
+
before = dict(self.latest[icao]) if icao and icao in self.latest else None
|
|
226
|
+
apply_awc_confirmed(self.latest, message)
|
|
227
|
+
self._emit(icao, before)
|
|
228
|
+
elif kind in {"station_late", "station_recovered", "usage"}:
|
|
229
|
+
if message.get("seq") is not None:
|
|
230
|
+
self._advance_seq(message["seq"])
|
|
231
|
+
if kind == "usage":
|
|
232
|
+
self.last_usage = message
|
|
233
|
+
elif kind == "station_late":
|
|
234
|
+
self.last_late = message
|
|
235
|
+
self.lates[_outage_key(message)] = message
|
|
236
|
+
else:
|
|
237
|
+
self.last_recovered = message
|
|
238
|
+
key = _outage_key(message)
|
|
239
|
+
self.lates.pop(key, None)
|
|
240
|
+
held = self.last_late
|
|
241
|
+
if held is not None and _outage_key(held) == key:
|
|
242
|
+
self.last_late = None
|
|
243
|
+
# Unknown types (pong, server_notice, error, and any later type) are ignored.
|
|
244
|
+
if self.on_frame is not None:
|
|
245
|
+
self.on_frame(message)
|
|
246
|
+
|
|
247
|
+
def _advance_seq(self, seq: int) -> None:
|
|
248
|
+
if self.seq is None or seq > self.seq:
|
|
249
|
+
self.seq = seq
|
|
250
|
+
|
|
251
|
+
def _apply_resume(self, rows: list[dict]) -> None:
|
|
252
|
+
for rec in rows:
|
|
253
|
+
before = self.latest.get(rec.get("icao") or "")
|
|
254
|
+
merge_record(self.latest, rec, live=False)
|
|
255
|
+
self._emit(rec.get("icao"), before)
|
|
256
|
+
|
|
257
|
+
async def _handle_message(self, message: dict) -> None:
|
|
258
|
+
if message.get("type") == "resume":
|
|
259
|
+
rows = await asyncio.to_thread(self.fetch_latest, self.url, self.token, self.airports)
|
|
260
|
+
self._apply_resume(rows)
|
|
261
|
+
if self.on_frame is not None:
|
|
262
|
+
self.on_frame(message)
|
|
263
|
+
return
|
|
264
|
+
self.handle_frame(message)
|
|
265
|
+
|
|
266
|
+
def _emit(self, icao: str | None, before: dict | None) -> None:
|
|
267
|
+
if not icao or self.on_record is None:
|
|
268
|
+
return
|
|
269
|
+
held = self.latest.get(icao)
|
|
270
|
+
if held is not None and held is not before and held != before:
|
|
271
|
+
self.on_record(held)
|
|
272
|
+
|
|
273
|
+
async def request_usage(self) -> None:
|
|
274
|
+
"""Send {"action":"usage"} on the open socket, if any."""
|
|
275
|
+
ws = self._ws
|
|
276
|
+
if ws is not None:
|
|
277
|
+
await ws.send(json.dumps({"action": "usage"}))
|
|
278
|
+
|
|
279
|
+
async def subscribe(self, icaos: list[str] | None = None, **fields: Any) -> None:
|
|
280
|
+
"""Send a mid-session subscribe. Pass filter= or delta= to opt in."""
|
|
281
|
+
ws = self._ws
|
|
282
|
+
if ws is None:
|
|
283
|
+
return
|
|
284
|
+
body: dict[str, Any] = {"action": "subscribe", "icaos": list(icaos or [])}
|
|
285
|
+
body.update(fields)
|
|
286
|
+
await ws.send(json.dumps(body))
|
|
287
|
+
|
|
288
|
+
def stop(self) -> None:
|
|
289
|
+
self._stop.set()
|
|
290
|
+
ws = self._ws
|
|
291
|
+
if ws is not None:
|
|
292
|
+
try:
|
|
293
|
+
asyncio.get_running_loop().create_task(ws.close())
|
|
294
|
+
except RuntimeError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
async def _session(self) -> str | int | None:
|
|
298
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
299
|
+
opened: float | None = None
|
|
300
|
+
saw_message = False
|
|
301
|
+
ping_task: asyncio.Task | None = None
|
|
302
|
+
try:
|
|
303
|
+
async with connect(self.handshake_url(), additional_headers=headers) as ws:
|
|
304
|
+
opened = time.monotonic()
|
|
305
|
+
self._ws = ws
|
|
306
|
+
|
|
307
|
+
async def heartbeat() -> None:
|
|
308
|
+
while True:
|
|
309
|
+
await asyncio.sleep(PING_EVERY_S)
|
|
310
|
+
if self._stop.is_set():
|
|
311
|
+
return
|
|
312
|
+
await ws.send(json.dumps({"action": "ping"}))
|
|
313
|
+
try:
|
|
314
|
+
await asyncio.wait_for(pong.wait(), timeout=PONG_TIMEOUT_S)
|
|
315
|
+
except asyncio.TimeoutError:
|
|
316
|
+
await ws.close(code=1001, reason="pong timeout")
|
|
317
|
+
return
|
|
318
|
+
finally:
|
|
319
|
+
pong.clear()
|
|
320
|
+
|
|
321
|
+
pong = asyncio.Event()
|
|
322
|
+
ping_task = asyncio.create_task(heartbeat())
|
|
323
|
+
async for text in ws:
|
|
324
|
+
if self._stop.is_set():
|
|
325
|
+
break
|
|
326
|
+
if not saw_message:
|
|
327
|
+
saw_message = True
|
|
328
|
+
self._delay = BACKOFF_START_S
|
|
329
|
+
message = json.loads(text)
|
|
330
|
+
if message.get("type") == "pong":
|
|
331
|
+
pong.set()
|
|
332
|
+
await self._handle_message(message)
|
|
333
|
+
except ConnectionClosed as exc:
|
|
334
|
+
if saw_message or (opened is not None and time.monotonic() - opened >= QUIET_OPEN_S):
|
|
335
|
+
self._delay = BACKOFF_START_S
|
|
336
|
+
return exc.rcvd.code if exc.rcvd is not None else exc.sent.code if exc.sent is not None else 1006
|
|
337
|
+
except (InvalidStatus, OSError, TimeoutError, asyncio.TimeoutError) as exc:
|
|
338
|
+
return connect_failure_code(exc)
|
|
339
|
+
finally:
|
|
340
|
+
self._ws = None
|
|
341
|
+
if ping_task is not None:
|
|
342
|
+
ping_task.cancel()
|
|
343
|
+
if opened is not None and (saw_message or time.monotonic() - opened >= QUIET_OPEN_S):
|
|
344
|
+
self._delay = BACKOFF_START_S
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
async def run_forever(self) -> None:
|
|
348
|
+
self._stop = asyncio.Event()
|
|
349
|
+
while not self._stop.is_set():
|
|
350
|
+
code = await self._session()
|
|
351
|
+
if self._stop.is_set():
|
|
352
|
+
return
|
|
353
|
+
if not should_retry(code if isinstance(code, int) else None):
|
|
354
|
+
return
|
|
355
|
+
wait = self._delay * (0.5 + random.random())
|
|
356
|
+
self._delay = min(self._delay * 2.0, BACKOFF_CAP_S)
|
|
357
|
+
try:
|
|
358
|
+
await asyncio.wait_for(self._stop.wait(), timeout=wait)
|
|
359
|
+
return
|
|
360
|
+
except asyncio.TimeoutError:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class MetarExample(MetarClient):
|
|
365
|
+
"""Backward-compatible constructor: (url, token, airports)."""
|
|
366
|
+
|
|
367
|
+
def __init__(self, url: str, token: str, airports: list[str], **kwargs) -> None:
|
|
368
|
+
super().__init__(airports, token=token, url=url, **kwargs)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobs-ws
|
|
3
|
+
Version: 0.16.1
|
|
4
|
+
Summary: Official client for the MOBS /v1/ws/metar WebSocket (protocol 4).
|
|
5
|
+
Author-email: MOBS <admin@mobs.ws>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://mobs.ws
|
|
8
|
+
Project-URL: Documentation, https://docs.mobs.ws/websocket
|
|
9
|
+
Project-URL: Repository, https://github.com/mobs-ws/mobs-sdks
|
|
10
|
+
Project-URL: Issues, https://github.com/mobs-ws/mobs-sdks/issues
|
|
11
|
+
Keywords: mobs,metar,websocket
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: websockets>=17.3
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# mobs-ws
|
|
24
|
+
|
|
25
|
+
Official Python client for the MOBS `/v1/ws/metar` WebSocket (protocol 4).
|
|
26
|
+
|
|
27
|
+
The only runtime dependency is `websockets`. HTTP backfill uses urllib from
|
|
28
|
+
the standard library. Snapshot and resume are opt-in. The client applies
|
|
29
|
+
`awc_confirmed` frames last-wins per (`icao`, `versions`), handles
|
|
30
|
+
`station_late`, `station_recovered`, and `usage` (use `request_usage()`),
|
|
31
|
+
and ignores unknown frame types. Late alerts are kept as the latest frame per `outage_id` and cleared on `station_recovered`. More than one `station_late` can arrive in the same outage. A change-only `filter` and parsed
|
|
32
|
+
`delta` are opt-in on a mid-session `subscribe`, not on the handshake.
|
|
33
|
+
|
|
34
|
+
The API key is never embedded. Pass it to the constructor, or set
|
|
35
|
+
`MOBS_TOKEN`.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install mobs-ws
|
|
39
|
+
export MOBS_TOKEN=$MOBS_TOKEN
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
import os
|
|
45
|
+
|
|
46
|
+
from mobs_ws import MetarClient
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = MetarClient(
|
|
50
|
+
airports=["KNYC", "KLGA"],
|
|
51
|
+
token=os.environ["MOBS_TOKEN"], # or token="YOUR_API_KEY"
|
|
52
|
+
snapshot=True,
|
|
53
|
+
resume=True,
|
|
54
|
+
on_record=lambda rec: print(rec["icao"], rec["raw_metar"], rec.get("awc_confirmed")),
|
|
55
|
+
)
|
|
56
|
+
await client.run_forever()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python -m mobs_ws --airports KNYC,KLGA
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
MIT licensed. See [LICENSE](https://github.com/mobs-ws/mobs-sdks/blob/main/LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mobs_ws/__init__.py
|
|
5
|
+
src/mobs_ws/__main__.py
|
|
6
|
+
src/mobs_ws/client.py
|
|
7
|
+
src/mobs_ws.egg-info/PKG-INFO
|
|
8
|
+
src/mobs_ws.egg-info/SOURCES.txt
|
|
9
|
+
src/mobs_ws.egg-info/dependency_links.txt
|
|
10
|
+
src/mobs_ws.egg-info/entry_points.txt
|
|
11
|
+
src/mobs_ws.egg-info/requires.txt
|
|
12
|
+
src/mobs_ws.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets>=17.3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mobs_ws
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Self-contained unit tests for the mobs-ws Python client (no MOBS server needed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import socket
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import mobs_ws
|
|
12
|
+
from mobs_ws import (
|
|
13
|
+
MetarClient,
|
|
14
|
+
apply_awc_confirmed,
|
|
15
|
+
handshake_url,
|
|
16
|
+
latest_url,
|
|
17
|
+
merge_record,
|
|
18
|
+
resolve_token,
|
|
19
|
+
should_retry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
PKG = Path(__file__).resolve().parent.parent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_metadata_is_publish_ready():
|
|
26
|
+
data = tomllib.loads((PKG / "pyproject.toml").read_text())
|
|
27
|
+
project = data["project"]
|
|
28
|
+
assert project["name"] == "mobs-ws"
|
|
29
|
+
assert project["version"] == mobs_ws.__version__
|
|
30
|
+
assert project["dependencies"] == ["websockets>=17.3"]
|
|
31
|
+
assert project["license"] == "MIT"
|
|
32
|
+
assert project["urls"]["Repository"] == "https://github.com/mobs-ws/mobs-sdks"
|
|
33
|
+
assert (PKG / "LICENSE").is_file()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_token_comes_from_caller_or_env(monkeypatch):
|
|
37
|
+
monkeypatch.delenv("MOBS_TOKEN", raising=False)
|
|
38
|
+
with pytest.raises(ValueError, match="MOBS_TOKEN"):
|
|
39
|
+
resolve_token(None)
|
|
40
|
+
with pytest.raises(ValueError, match="MOBS_TOKEN"):
|
|
41
|
+
resolve_token("")
|
|
42
|
+
monkeypatch.setenv("MOBS_TOKEN", "from-env")
|
|
43
|
+
assert resolve_token(None) == "from-env"
|
|
44
|
+
assert resolve_token("from-caller") == "from-caller"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_merge_confirm_and_unknown_types():
|
|
48
|
+
latest = {}
|
|
49
|
+
merge_record(latest, {"icao": "KNYC", "versions": "v0", "obs_time": "t", "raw_metar": "A"}, live=False)
|
|
50
|
+
assert latest["KNYC"]["raw_metar"] == "A"
|
|
51
|
+
merge_record(latest, {"icao": "KNYC", "versions": "v1", "obs_time": "t", "raw_metar": "B"}, live=True)
|
|
52
|
+
assert latest["KNYC"]["raw_metar"] == "B"
|
|
53
|
+
apply_awc_confirmed(latest, {"icao": "KNYC", "versions": "v1", "awc_confirmed_at": "t1"})
|
|
54
|
+
assert latest["KNYC"]["awc_confirmed"] is True and latest["KNYC"]["awc_confirmed_at"] == "t1"
|
|
55
|
+
apply_awc_confirmed(latest, {"icao": "KNYC", "versions": "v1", "awc_confirmed_at": "t2"})
|
|
56
|
+
assert latest["KNYC"]["awc_confirmed_at"] == "t2"
|
|
57
|
+
apply_awc_confirmed(latest, {"icao": "KNYC", "versions": "v9", "awc_confirmed_at": "nope"})
|
|
58
|
+
assert latest["KNYC"]["awc_confirmed_at"] == "t2"
|
|
59
|
+
client = MetarClient(["KNYC"], token="YOUR_API_KEY", url="ws://127.0.0.1:9/v1/ws/metar")
|
|
60
|
+
client.latest = latest
|
|
61
|
+
client.handle_frame({"type": "not_a_real_type", "seq": 99})
|
|
62
|
+
assert latest["KNYC"]["awc_confirmed_at"] == "t2"
|
|
63
|
+
assert should_retry(4008) and not should_retry(4403)
|
|
64
|
+
url = handshake_url("wss://mobs.ws/v1/ws/metar", ["KNYC"], snapshot=True, since_seq=4, boot_id="abcd")
|
|
65
|
+
assert "snapshot=true" in url and "since_seq=4" in url and "boot_id=abcd" in url
|
|
66
|
+
assert latest_url("wss://mobs.ws/v1/ws/metar", ["KNYC"]).startswith("https://mobs.ws/v1/metar/latest")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_seq_advances_only_from_received_replay_or_live():
|
|
70
|
+
client = MetarClient(["KNYC"], token="YOUR_API_KEY", url="ws://127.0.0.1:9/v1/ws/metar")
|
|
71
|
+
client.seq = 2
|
|
72
|
+
client.handle_frame(
|
|
73
|
+
{"type": "subscription_updated", "icaos": ["KNYC"], "boot_id": "boot", "seq": 80, "resume": "full", "replayed": 80}
|
|
74
|
+
)
|
|
75
|
+
assert client.boot_id == "boot"
|
|
76
|
+
assert client.seq == 2
|
|
77
|
+
rec = {"icao": "KNYC", "versions": "v1", "obs_time": "t", "raw_metar": "A"}
|
|
78
|
+
client.handle_frame({"type": "new_metar_arrived", "cached": False, "replay": True, "seq": 5, "data": [rec]})
|
|
79
|
+
assert client.seq == 5
|
|
80
|
+
client.handle_frame({"type": "new_metar_arrived", "cached": True, "replay": False, "seq": 80, "data": [rec]})
|
|
81
|
+
assert client.seq == 5
|
|
82
|
+
client.handle_frame({"type": "new_metar_arrived", "cached": False, "replay": False, "seq": 6, "data": [rec]})
|
|
83
|
+
assert client.seq == 6
|
|
84
|
+
client.handle_frame({"type": "awc_confirmed", "icao": "KNYC", "versions": "v1", "awc_confirmed_at": "t", "seq": 7})
|
|
85
|
+
assert client.seq == 7
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _closed_port() -> int:
|
|
89
|
+
sock = socket.socket()
|
|
90
|
+
sock.bind(("127.0.0.1", 0))
|
|
91
|
+
port = sock.getsockname()[1]
|
|
92
|
+
sock.close()
|
|
93
|
+
return port
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _http_status_server(status: int):
|
|
97
|
+
async def handler(reader, writer):
|
|
98
|
+
try:
|
|
99
|
+
await asyncio.wait_for(reader.read(4096), timeout=1)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
try:
|
|
103
|
+
writer.write(f"HTTP/1.1 {status} test\r\nConnection: close\r\n\r\n".encode())
|
|
104
|
+
await writer.drain()
|
|
105
|
+
writer.close()
|
|
106
|
+
await writer.wait_closed()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
server = await asyncio.start_server(handler, "127.0.0.1", 0)
|
|
111
|
+
return server, server.sockets[0].getsockname()[1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _run_counted(client: MetarClient, n: int, timeout: float = 5) -> list[int | None]:
|
|
115
|
+
codes: list[int | None] = []
|
|
116
|
+
original = MetarClient._session
|
|
117
|
+
|
|
118
|
+
async def wrapped(_self=client):
|
|
119
|
+
code = await original(_self)
|
|
120
|
+
codes.append(code)
|
|
121
|
+
if len(codes) >= n:
|
|
122
|
+
_self.stop()
|
|
123
|
+
return code
|
|
124
|
+
|
|
125
|
+
client._session = wrapped # type: ignore[method-assign]
|
|
126
|
+
client._delay = 0.01
|
|
127
|
+
await asyncio.wait_for(client.run_forever(), timeout=timeout)
|
|
128
|
+
return codes
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _client(url: str) -> MetarClient:
|
|
132
|
+
return MetarClient(["KNYC"], token="local-test-token", url=url, snapshot=False, resume=False)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_connect_refused_502_and_401_do_not_escape_run_forever():
|
|
136
|
+
async def run():
|
|
137
|
+
codes = await _run_counted(_client(f"ws://127.0.0.1:{_closed_port()}/v1/ws/metar"), 2)
|
|
138
|
+
assert codes[0] == 1006 and len(codes) >= 2
|
|
139
|
+
|
|
140
|
+
server, port = await _http_status_server(502)
|
|
141
|
+
try:
|
|
142
|
+
codes = await _run_counted(_client(f"ws://127.0.0.1:{port}/v1/ws/metar"), 2)
|
|
143
|
+
assert codes[0] == 1006 and len(codes) >= 2
|
|
144
|
+
finally:
|
|
145
|
+
server.close()
|
|
146
|
+
await server.wait_closed()
|
|
147
|
+
|
|
148
|
+
server, port = await _http_status_server(401)
|
|
149
|
+
try:
|
|
150
|
+
codes = await _run_counted(_client(f"ws://127.0.0.1:{port}/v1/ws/metar"), 2)
|
|
151
|
+
assert codes == [4403]
|
|
152
|
+
finally:
|
|
153
|
+
server.close()
|
|
154
|
+
await server.wait_closed()
|
|
155
|
+
|
|
156
|
+
asyncio.run(run())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_connect_asyncio_timeout_backs_off(monkeypatch):
|
|
160
|
+
class _Boom:
|
|
161
|
+
async def __aenter__(self):
|
|
162
|
+
raise asyncio.TimeoutError()
|
|
163
|
+
|
|
164
|
+
async def __aexit__(self, *_exc):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr("mobs_ws.client.connect", lambda *_a, **_k: _Boom())
|
|
168
|
+
|
|
169
|
+
async def run():
|
|
170
|
+
codes = await _run_counted(_client("ws://127.0.0.1:9/v1/ws/metar"), 2)
|
|
171
|
+
assert codes[0] == 1006 and len(codes) >= 2
|
|
172
|
+
|
|
173
|
+
asyncio.run(run())
|