pmwallets 0.1.2__tar.gz → 0.3.0__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.
- {pmwallets-0.1.2 → pmwallets-0.3.0}/PKG-INFO +25 -3
- {pmwallets-0.1.2 → pmwallets-0.3.0}/README.md +24 -2
- {pmwallets-0.1.2 → pmwallets-0.3.0}/README.zh.md +14 -1
- {pmwallets-0.1.2 → pmwallets-0.3.0}/pyproject.toml +1 -1
- pmwallets-0.3.0/src/pmwallets/__init__.py +13 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/src/pmwallets/client.py +37 -5
- {pmwallets-0.1.2 → pmwallets-0.3.0}/src/pmwallets/stream.py +51 -9
- {pmwallets-0.1.2 → pmwallets-0.3.0}/tests/test_client.py +35 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/tests/test_stream.py +64 -6
- pmwallets-0.3.0/uv.lock +539 -0
- pmwallets-0.1.2/src/pmwallets/__init__.py +0 -13
- {pmwallets-0.1.2 → pmwallets-0.3.0}/.github/workflows/ci.yml +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/.github/workflows/publish.yml +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/.gitignore +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/LICENSE +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/src/pmwallets/types.py +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/src/pmwallets/webhook.py +0 -0
- {pmwallets-0.1.2 → pmwallets-0.3.0}/tests/test_real_socket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pmwallets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports.
|
|
5
5
|
Project-URL: Homepage, https://pmwallets.com/docs
|
|
6
6
|
Project-URL: Repository, https://github.com/polymarketwallets/pmwallets-python
|
|
@@ -53,6 +53,8 @@ async def main():
|
|
|
53
53
|
client=pmw,
|
|
54
54
|
store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
|
|
55
55
|
on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
|
|
56
|
+
on_event=lambda e: e["type"] == "fatal" and print(e["error"]), # e.g. another bot already holds the stream
|
|
57
|
+
# takeover=True, # replace whichever API client holds this account's stream (it stops with "replaced")
|
|
56
58
|
)
|
|
57
59
|
await stream.run()
|
|
58
60
|
|
|
@@ -67,9 +69,29 @@ asyncio.run(main())
|
|
|
67
69
|
| `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
|
|
68
70
|
| `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
|
|
69
71
|
|
|
70
|
-
One stream per account
|
|
72
|
+
One stream per account:
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
- An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it).
|
|
75
|
+
- Between two API connections **the first one keeps the stream**. A second one — another key of the same account, or the same bot started twice — is refused: `blocked`, then `fatal`, and the stream stops. Pass `takeover=True` to replace the holder instead; the holder gets `replaced`, then `fatal`, and stops (it does not fight back).
|
|
76
|
+
- A `FillStream` identifies itself with a random `client_id`, so its own reconnects after a network drop replace its own dead connection without needing `takeover`.
|
|
77
|
+
- When the server restarts it closes the socket with `1012`; the stream reconnects at once (event `restarting`) and replays anything it missed.
|
|
78
|
+
|
|
79
|
+
`HTTPS_PROXY` is honoured.
|
|
80
|
+
|
|
81
|
+
PMWallets' servers are in the United Kingdom. A consumer that also trades on Polymarket should run from Ireland (AWS eu-west-1): Polymarket does not accept API orders from the UK, the US and several EU countries.
|
|
82
|
+
|
|
83
|
+
## Trade-history exports
|
|
84
|
+
|
|
85
|
+
A purchase is access to files: one zstd-compressed CSV per wallet per UTC day, downloadable at once and again later.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
|
|
89
|
+
for f in client.export_files(order["id"])["files"]:
|
|
90
|
+
data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
|
|
91
|
+
# pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`export_file_url(wallet, day)` returns the short-lived link instead; the API key is never sent to the storage host.
|
|
73
95
|
|
|
74
96
|
## Resources
|
|
75
97
|
|
|
@@ -35,6 +35,8 @@ async def main():
|
|
|
35
35
|
client=pmw,
|
|
36
36
|
store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
|
|
37
37
|
on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
|
|
38
|
+
on_event=lambda e: e["type"] == "fatal" and print(e["error"]), # e.g. another bot already holds the stream
|
|
39
|
+
# takeover=True, # replace whichever API client holds this account's stream (it stops with "replaced")
|
|
38
40
|
)
|
|
39
41
|
await stream.run()
|
|
40
42
|
|
|
@@ -49,9 +51,29 @@ asyncio.run(main())
|
|
|
49
51
|
| `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
|
|
50
52
|
| `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
|
|
51
53
|
|
|
52
|
-
One stream per account
|
|
54
|
+
One stream per account:
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
- An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it).
|
|
57
|
+
- Between two API connections **the first one keeps the stream**. A second one — another key of the same account, or the same bot started twice — is refused: `blocked`, then `fatal`, and the stream stops. Pass `takeover=True` to replace the holder instead; the holder gets `replaced`, then `fatal`, and stops (it does not fight back).
|
|
58
|
+
- A `FillStream` identifies itself with a random `client_id`, so its own reconnects after a network drop replace its own dead connection without needing `takeover`.
|
|
59
|
+
- When the server restarts it closes the socket with `1012`; the stream reconnects at once (event `restarting`) and replays anything it missed.
|
|
60
|
+
|
|
61
|
+
`HTTPS_PROXY` is honoured.
|
|
62
|
+
|
|
63
|
+
PMWallets' servers are in the United Kingdom. A consumer that also trades on Polymarket should run from Ireland (AWS eu-west-1): Polymarket does not accept API orders from the UK, the US and several EU countries.
|
|
64
|
+
|
|
65
|
+
## Trade-history exports
|
|
66
|
+
|
|
67
|
+
A purchase is access to files: one zstd-compressed CSV per wallet per UTC day, downloadable at once and again later.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
|
|
71
|
+
for f in client.export_files(order["id"])["files"]:
|
|
72
|
+
data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
|
|
73
|
+
# pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`export_file_url(wallet, day)` returns the short-lived link instead; the API key is never sent to the storage host.
|
|
55
77
|
|
|
56
78
|
## Resources
|
|
57
79
|
|
|
@@ -26,7 +26,20 @@ pip install pmwallets # Python ≥ 3.10
|
|
|
26
26
|
|
|
27
27
|
每个账户只有一条推送流。API key 建立的连接优先于 pmwallets.com 的推送页;两条 API 连接之间仍是后连的顶掉先连的。支持 `HTTPS_PROXY`。
|
|
28
28
|
|
|
29
|
-
PMWallets
|
|
29
|
+
PMWallets 的服务器在英国。若还要在 Polymarket 下单,请部署在爱尔兰(AWS eu-west-1):Polymarket 不接受来自英国、美国及部分欧盟国家的 API 订单。
|
|
30
|
+
|
|
31
|
+
## 历史成交导出
|
|
32
|
+
|
|
33
|
+
购买得到的是文件的访问权:每个钱包每个 UTC 日一个 zstd 压缩的 CSV,付款后立即可下载,之后也能再下。
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
|
|
37
|
+
for f in client.export_files(order["id"])["files"]:
|
|
38
|
+
data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
|
|
39
|
+
# pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`export_file_url(wallet, day)` 只返回短时效链接;API key 不会发给存储服务。
|
|
30
43
|
|
|
31
44
|
## 相关链接
|
|
32
45
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pmwallets"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official PMWallets SDK — https://pmwallets.com/docs"""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_BASE_URL, AsyncClient, Client, PmwError
|
|
4
|
+
from .stream import CLOSE_API_SEAT_TAKEN, CLOSE_SERVICE_RESTART, FileStateStore, FillMeta, FillStream, MemoryStateStore, StreamState, UpgradeRefused, websockets_connector
|
|
5
|
+
from .types import Fill, FillCursor, FillsPage
|
|
6
|
+
from .webhook import verify_webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DEFAULT_BASE_URL", "AsyncClient", "Client", "PmwError",
|
|
10
|
+
"CLOSE_API_SEAT_TAKEN", "CLOSE_SERVICE_RESTART", "FileStateStore", "FillMeta", "FillStream", "MemoryStateStore", "StreamState", "UpgradeRefused", "websockets_connector",
|
|
11
|
+
"Fill", "FillCursor", "FillsPage", "verify_webhook",
|
|
12
|
+
]
|
|
13
|
+
__version__ = "0.2.0"
|
|
@@ -63,6 +63,15 @@ class _Base:
|
|
|
63
63
|
raise PmwError(res.status_code, body)
|
|
64
64
|
return body if text else None
|
|
65
65
|
|
|
66
|
+
@classmethod
|
|
67
|
+
def _location(cls, res: httpx.Response) -> str:
|
|
68
|
+
"""The redirect target of a purchased-file request; anything else is raised as PmwError."""
|
|
69
|
+
loc = res.headers.get("location")
|
|
70
|
+
if 300 <= res.status_code < 400 and loc:
|
|
71
|
+
return loc
|
|
72
|
+
cls._parse(res)
|
|
73
|
+
raise PmwError(res.status_code, res.text, "expected a redirect to the file")
|
|
74
|
+
|
|
66
75
|
@staticmethod
|
|
67
76
|
def _fills_query(since_block: int, since_log_index: int, limit: int) -> dict[str, Any]:
|
|
68
77
|
return {"sinceBlock": since_block, "sinceLogIndex": since_log_index, "limit": limit}
|
|
@@ -154,9 +163,22 @@ class Client(_Base):
|
|
|
154
163
|
def get_export(self, export_id: str) -> dict[str, Any]:
|
|
155
164
|
return self.request("GET", f"/v1/account/exports/{_q(export_id)}")
|
|
156
165
|
|
|
157
|
-
def
|
|
158
|
-
"""
|
|
159
|
-
return self.request("GET", f"/v1/account/exports/{_q(export_id)}/
|
|
166
|
+
def export_files(self, export_id: str) -> dict[str, Any]:
|
|
167
|
+
"""The daily files an export grants: one per wallet per UTC day."""
|
|
168
|
+
return self.request("GET", f"/v1/account/exports/{_q(export_id)}/files")
|
|
169
|
+
|
|
170
|
+
def export_file_url(self, wallet: str, day: str) -> str:
|
|
171
|
+
"""A short-lived (5 minute) link to one purchased daily file. The redirect is read, not followed,
|
|
172
|
+
so the API key is never sent to the storage host."""
|
|
173
|
+
res = self._http.request("GET", self.base_url + f"/v1/account/data/{_q(wallet)}/{_q(day)}", headers=self._headers, follow_redirects=False)
|
|
174
|
+
return self._location(res)
|
|
175
|
+
|
|
176
|
+
def download_export_file(self, wallet: str, day: str) -> bytes:
|
|
177
|
+
"""One purchased daily file: zstd-compressed CSV bytes (.csv.zst)."""
|
|
178
|
+
res = self._http.get(self.export_file_url(wallet, day), timeout=max(self.timeout, 60.0))
|
|
179
|
+
if res.status_code != 200:
|
|
180
|
+
raise PmwError(res.status_code, res.text)
|
|
181
|
+
return res.content
|
|
160
182
|
|
|
161
183
|
|
|
162
184
|
class AsyncClient(_Base):
|
|
@@ -233,5 +255,15 @@ class AsyncClient(_Base):
|
|
|
233
255
|
async def get_export(self, export_id: str) -> dict[str, Any]:
|
|
234
256
|
return await self.request("GET", f"/v1/account/exports/{_q(export_id)}")
|
|
235
257
|
|
|
236
|
-
async def
|
|
237
|
-
return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/
|
|
258
|
+
async def export_files(self, export_id: str) -> dict[str, Any]:
|
|
259
|
+
return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/files")
|
|
260
|
+
|
|
261
|
+
async def export_file_url(self, wallet: str, day: str) -> str:
|
|
262
|
+
res = await self._http.request("GET", self.base_url + f"/v1/account/data/{_q(wallet)}/{_q(day)}", headers=self._headers, follow_redirects=False)
|
|
263
|
+
return self._location(res)
|
|
264
|
+
|
|
265
|
+
async def download_export_file(self, wallet: str, day: str) -> bytes:
|
|
266
|
+
res = await self._http.get(await self.export_file_url(wallet, day), timeout=max(self.timeout, 60.0))
|
|
267
|
+
if res.status_code != 200:
|
|
268
|
+
raise PmwError(res.status_code, res.text)
|
|
269
|
+
return res.content
|
|
@@ -5,14 +5,22 @@ import contextlib
|
|
|
5
5
|
import json
|
|
6
6
|
import math
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
8
10
|
from collections import OrderedDict
|
|
9
11
|
from dataclasses import asdict, dataclass
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
from typing import Any, AsyncContextManager, Awaitable, Callable, Optional, Protocol, Union
|
|
14
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
12
15
|
|
|
13
16
|
from .client import AsyncClient
|
|
14
17
|
from .types import Fill
|
|
15
18
|
|
|
19
|
+
# close codes the server uses (see the WebSocket section of the API docs)
|
|
20
|
+
CLOSE_SERVICE_RESTART = 1012 # the server is restarting: reconnect at once
|
|
21
|
+
CLOSE_API_SEAT_TAKEN = 4411 # another API client of this account holds the stream
|
|
22
|
+
_CLIENT_ID = re.compile(r"[A-Za-z0-9_-]{8,64}")
|
|
23
|
+
|
|
16
24
|
|
|
17
25
|
@dataclass
|
|
18
26
|
class StreamState:
|
|
@@ -135,6 +143,8 @@ class FillStream:
|
|
|
135
143
|
ws_options: Optional[dict[str, Any]] = None,
|
|
136
144
|
replay_without_cursor: bool = False,
|
|
137
145
|
anchor_lag_blocks: int = 200,
|
|
146
|
+
takeover: bool = False,
|
|
147
|
+
client_id: Optional[str] = None,
|
|
138
148
|
) -> None:
|
|
139
149
|
self.client = client
|
|
140
150
|
self.on_fill = on_fill
|
|
@@ -152,6 +162,16 @@ class FillStream:
|
|
|
152
162
|
# your account; anchoring exactly at it could exclude a fill mined earlier but not yet pushed. The extra
|
|
153
163
|
# blocks are replayed at most once more and dropped by eventId.
|
|
154
164
|
self.anchor_lag_blocks = anchor_lag_blocks
|
|
165
|
+
# One stream per account, and the API client that connected FIRST keeps it: a second one is refused
|
|
166
|
+
# ("blocked", then "fatal"). takeover=True replaces whichever client holds it — that client is told
|
|
167
|
+
# "replaced" and stops. Only the first connection asks; our own reconnects are recognised by client_id.
|
|
168
|
+
self.takeover = takeover
|
|
169
|
+
self._ask_takeover = takeover
|
|
170
|
+
# Identifies this stream so its own reconnects replace its own dead connection instead of being refused
|
|
171
|
+
# by it. Default: random per FillStream.
|
|
172
|
+
self.client_id = client_id if client_id is not None else secrets.token_urlsafe(16)
|
|
173
|
+
if not _CLIENT_ID.fullmatch(self.client_id):
|
|
174
|
+
raise ValueError("client_id must be 8-64 characters of [A-Za-z0-9_-]")
|
|
155
175
|
self.connector = connector or websockets_connector(ping_interval, **(ws_options or {}))
|
|
156
176
|
self.state = StreamState()
|
|
157
177
|
self._seen: "OrderedDict[str, bool]" = OrderedDict()
|
|
@@ -203,17 +223,29 @@ class FillStream:
|
|
|
203
223
|
async def _loop(self) -> None:
|
|
204
224
|
backoff = self.min_backoff
|
|
205
225
|
while self._running:
|
|
206
|
-
healthy = await self._connect_once()
|
|
226
|
+
healthy, code = await self._connect_once()
|
|
207
227
|
if not self._running:
|
|
208
228
|
break
|
|
229
|
+
if code == CLOSE_SERVICE_RESTART: # the server's replacement is taking over now: reconnect at once
|
|
230
|
+
backoff = self.min_backoff
|
|
231
|
+
continue
|
|
209
232
|
backoff = self.min_backoff if healthy else min(backoff * 2, self.max_backoff)
|
|
210
233
|
self._wake.clear()
|
|
211
234
|
with contextlib.suppress(asyncio.TimeoutError):
|
|
212
235
|
await asyncio.wait_for(self._wake.wait(), backoff)
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
"""
|
|
216
|
-
|
|
237
|
+
def _url(self) -> str:
|
|
238
|
+
"""The stream URL with this client's identity (and, before the first hello, the takeover request)."""
|
|
239
|
+
u = urlsplit(self.client.ws_url)
|
|
240
|
+
q = [(k, v) for k, v in parse_qsl(u.query) if k not in ("clientId", "takeover")]
|
|
241
|
+
q.append(("clientId", self.client_id))
|
|
242
|
+
if self._ask_takeover:
|
|
243
|
+
q.append(("takeover", "1"))
|
|
244
|
+
return urlunsplit(u._replace(query=urlencode(q)))
|
|
245
|
+
|
|
246
|
+
async def _connect_once(self) -> tuple[bool, int]:
|
|
247
|
+
"""One connection's life: (got as far as a hello and did not fail, the close code)."""
|
|
248
|
+
url = self._url()
|
|
217
249
|
self._emit({"type": "connecting", "url": url})
|
|
218
250
|
greeted = False
|
|
219
251
|
failed = False
|
|
@@ -248,16 +280,25 @@ class FillStream:
|
|
|
248
280
|
self._emit({"type": "fatal", "error": RuntimeError(f"WebSocket upgrade refused with {e.status_code}: check the API key")})
|
|
249
281
|
else:
|
|
250
282
|
self._emit({"type": "error", "error": e})
|
|
251
|
-
return False
|
|
283
|
+
return False, 0
|
|
252
284
|
except Exception as e:
|
|
253
285
|
self._emit({"type": "error", "error": e})
|
|
254
|
-
return False
|
|
286
|
+
return False, 0
|
|
255
287
|
finally:
|
|
256
288
|
self._socket = None
|
|
257
|
-
if code == 1000 and "replaced" in reason.lower():
|
|
258
|
-
self._emit({"type": "replaced"})
|
|
259
289
|
self._emit({"type": "disconnected", "code": code, "reason": reason})
|
|
260
|
-
|
|
290
|
+
if code == 1000 and "replaced" in reason.lower() and self._running:
|
|
291
|
+
# another client took over: reconnecting would take the stream back and the two would fight
|
|
292
|
+
self._running = False
|
|
293
|
+
self._emit({"type": "replaced"})
|
|
294
|
+
self._emit({"type": "fatal", "error": RuntimeError("another API client of this account took the stream over (takeover); this stream has stopped")})
|
|
295
|
+
elif code == CLOSE_API_SEAT_TAKEN and self._running:
|
|
296
|
+
self._running = False
|
|
297
|
+
self._emit({"type": "blocked"})
|
|
298
|
+
self._emit({"type": "fatal", "error": RuntimeError("another API client of this account already holds the stream (one per account; the first one keeps it). Stop it, or pass takeover=True to replace it")})
|
|
299
|
+
elif code == CLOSE_SERVICE_RESTART:
|
|
300
|
+
self._emit({"type": "restarting"})
|
|
301
|
+
return greeted and not failed, code
|
|
261
302
|
|
|
262
303
|
async def _handle_frame(self, m: dict[str, Any]) -> None:
|
|
263
304
|
if m.get("type") == "hello" and isinstance(m.get("session"), str):
|
|
@@ -270,6 +311,7 @@ class FillStream:
|
|
|
270
311
|
self.state.session = m["session"]
|
|
271
312
|
self.state.seq = int(m.get("seq") or 0)
|
|
272
313
|
await self.store.save(self.state)
|
|
314
|
+
self._ask_takeover = False # seated: from here on our own client_id is what gets us back in
|
|
273
315
|
self._emit({"type": "hello", "session": m["session"], "seq": self.state.seq})
|
|
274
316
|
return
|
|
275
317
|
if m.get("type") != "fill" or not m.get("data") or not isinstance(m.get("seq"), int):
|
|
@@ -56,3 +56,38 @@ def test_verify_webhook():
|
|
|
56
56
|
assert not verify_webhook(body, sig, "other")
|
|
57
57
|
assert not verify_webhook(body, "zz", "sec")
|
|
58
58
|
assert not verify_webhook(body, None, "sec")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
W = "0x" + "ab" * 20
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _file_transport(calls):
|
|
65
|
+
def handler(req: httpx.Request):
|
|
66
|
+
calls.append(req)
|
|
67
|
+
if req.url.host == "api.example.com":
|
|
68
|
+
return httpx.Response(302, headers={"location": "https://r2.example/signed"})
|
|
69
|
+
return httpx.Response(200, content=b"\x01\x02\x03")
|
|
70
|
+
return httpx.MockTransport(handler)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_download_reads_the_redirect_and_never_sends_the_key_to_storage():
|
|
74
|
+
calls = []
|
|
75
|
+
c = Client("pmw_a_b", "https://api.example.com", http=httpx.Client(transport=_file_transport(calls)))
|
|
76
|
+
assert c.download_export_file(W, "2026-09-25") == b"\x01\x02\x03"
|
|
77
|
+
assert str(calls[0].url) == f"https://api.example.com/v1/account/data/{W}/2026-09-25"
|
|
78
|
+
assert str(calls[1].url) == "https://r2.example/signed"
|
|
79
|
+
assert "x-api-key" not in calls[1].headers
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_async_download_reads_the_redirect():
|
|
83
|
+
calls = []
|
|
84
|
+
c = AsyncClient("pmw_a_b", "https://api.example.com", http=httpx.AsyncClient(transport=_file_transport(calls)))
|
|
85
|
+
assert await c.download_export_file(W, "2026-09-25") == b"\x01\x02\x03"
|
|
86
|
+
assert "x-api-key" not in calls[1].headers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_no_purchased_file_is_pmw_error():
|
|
90
|
+
c = Client("k", http=httpx.Client(transport=transport([{"statusCode": 404, "message": "no purchased file for this wallet and day"}], 404)))
|
|
91
|
+
with pytest.raises(PmwError) as e:
|
|
92
|
+
c.export_file_url(W, "2026-09-25")
|
|
93
|
+
assert e.value.status == 404
|
|
@@ -2,6 +2,9 @@ import asyncio
|
|
|
2
2
|
import contextlib
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
|
+
import pytest
|
|
6
|
+
from urllib.parse import parse_qs, urlsplit
|
|
7
|
+
|
|
5
8
|
from pmwallets.stream import FileStateStore, FillStream, MemoryStateStore, UpgradeRefused
|
|
6
9
|
|
|
7
10
|
|
|
@@ -68,13 +71,14 @@ class FakeClient:
|
|
|
68
71
|
yield f
|
|
69
72
|
|
|
70
73
|
|
|
71
|
-
def harness(ledger, fail_on=None, refuse=None, head=209):
|
|
72
|
-
sockets, delivered, events = [], [], []
|
|
74
|
+
def harness(ledger, fail_on=None, refuse=None, head=209, takeover=False, min_backoff=0.001):
|
|
75
|
+
sockets, delivered, events, urls = [], [], [], []
|
|
73
76
|
client = FakeClient(ledger, head)
|
|
74
77
|
state = {"fail": fail_on}
|
|
75
78
|
|
|
76
79
|
@contextlib.asynccontextmanager
|
|
77
80
|
async def connector(url, headers):
|
|
81
|
+
urls.append(parse_qs(urlsplit(url).query))
|
|
78
82
|
if refuse:
|
|
79
83
|
raise UpgradeRefused(refuse)
|
|
80
84
|
s = FakeSocket()
|
|
@@ -90,8 +94,9 @@ def harness(ledger, fail_on=None, refuse=None, head=209):
|
|
|
90
94
|
raise RuntimeError("handler failed")
|
|
91
95
|
delivered.append((f["eventId"], meta.source))
|
|
92
96
|
|
|
93
|
-
stream = FillStream(client, on_fill, on_event=events.append, store=MemoryStateStore(), min_backoff=
|
|
94
|
-
max_backoff=0.002, connector=connector)
|
|
97
|
+
stream = FillStream(client, on_fill, on_event=events.append, store=MemoryStateStore(), min_backoff=min_backoff,
|
|
98
|
+
max_backoff=max(min_backoff, 0.002), connector=connector, takeover=takeover)
|
|
99
|
+
stream.urls = urls
|
|
95
100
|
return stream, sockets, client, delivered, events
|
|
96
101
|
|
|
97
102
|
|
|
@@ -171,15 +176,68 @@ async def test_401_is_fatal():
|
|
|
171
176
|
assert [e["type"] for e in events].count("connecting") == 1
|
|
172
177
|
|
|
173
178
|
|
|
174
|
-
async def
|
|
179
|
+
async def test_replaced_by_takeover_stops_for_good():
|
|
175
180
|
stream, sockets, _, _, events = harness([])
|
|
176
181
|
await stream.start(); await tick()
|
|
177
182
|
sockets[0].remote_close(1000, "replaced by a newer connection")
|
|
183
|
+
await asyncio.wait_for(stream.wait(), 1)
|
|
184
|
+
types = [e["type"] for e in events]
|
|
185
|
+
assert "replaced" in types and "fatal" in types
|
|
186
|
+
assert len(sockets) == 1 # reconnecting would take the stream back and the two would fight
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def test_refused_4411_stops_and_says_how_to_take_over():
|
|
190
|
+
stream, sockets, _, _, events = harness([])
|
|
191
|
+
await stream.start(); await tick()
|
|
192
|
+
sockets[0].send({"type": "blocked", "reason": "api_seat_taken"})
|
|
193
|
+
sockets[0].remote_close(4411, "another API client is connected")
|
|
194
|
+
await asyncio.wait_for(stream.wait(), 1)
|
|
195
|
+
assert any(e["type"] == "blocked" for e in events)
|
|
196
|
+
fatal = [e for e in events if e["type"] == "fatal"]
|
|
197
|
+
assert fatal and "takeover" in str(fatal[0]["error"])
|
|
198
|
+
assert len(sockets) == 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def test_server_restart_1012_reconnects_at_once():
|
|
202
|
+
stream, sockets, _, _, events = harness([], min_backoff=60) # a backoff the test would time out waiting for
|
|
203
|
+
await stream.start(); await tick()
|
|
204
|
+
sockets[0].send({"type": "hello", "session": "A", "seq": 0})
|
|
178
205
|
await tick()
|
|
179
|
-
|
|
206
|
+
sockets[0].remote_close(1012, "service restart; reconnect now")
|
|
207
|
+
await tick(); await tick()
|
|
208
|
+
assert any(e["type"] == "restarting" for e in events)
|
|
209
|
+
assert len(sockets) == 2
|
|
180
210
|
await stream.stop()
|
|
181
211
|
|
|
182
212
|
|
|
213
|
+
async def test_client_id_on_every_connection_takeover_only_until_seated():
|
|
214
|
+
stream, sockets, _, _, _ = harness([], takeover=True)
|
|
215
|
+
await stream.start(); await tick()
|
|
216
|
+
sockets[0].send({"type": "hello", "session": "A", "seq": 0})
|
|
217
|
+
await tick()
|
|
218
|
+
sockets[0].remote_close(1006, "")
|
|
219
|
+
await tick(); await tick()
|
|
220
|
+
assert len(stream.urls) == 2
|
|
221
|
+
cid = stream.urls[0]["clientId"][0]
|
|
222
|
+
assert 8 <= len(cid) <= 64
|
|
223
|
+
assert stream.urls[1]["clientId"] == [cid] # the same id, so the server knows it is us coming back
|
|
224
|
+
assert stream.urls[0]["takeover"] == ["1"]
|
|
225
|
+
assert "takeover" not in stream.urls[1] # a reconnect must not snatch the stream from a client that took it meanwhile
|
|
226
|
+
await stream.stop()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def test_never_asks_to_take_over_unless_told():
|
|
230
|
+
stream, sockets, _, _, _ = harness([])
|
|
231
|
+
await stream.start(); await tick()
|
|
232
|
+
assert "takeover" not in stream.urls[0]
|
|
233
|
+
await stream.stop()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_malformed_client_id_is_refused():
|
|
237
|
+
with pytest.raises(ValueError, match="client_id"):
|
|
238
|
+
FillStream(FakeClient([]), lambda f, m: None, client_id="short")
|
|
239
|
+
|
|
240
|
+
|
|
183
241
|
async def test_anchors_behind_the_chain_head_on_first_hello():
|
|
184
242
|
# head 300, lag 200 → replay covers blocks ≥ 100. Block 99 is history; block 150 was mined before we connected
|
|
185
243
|
# but had not been pushed yet (the head ran ahead of the fill index).
|