pmxt 1.0.0__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.
- pmxt/__init__.py +58 -0
- pmxt/client.py +713 -0
- pmxt/models.py +296 -0
- pmxt/server_manager.py +242 -0
- pmxt-1.0.0.dist-info/METADATA +250 -0
- pmxt-1.0.0.dist-info/RECORD +56 -0
- pmxt-1.0.0.dist-info/WHEEL +5 -0
- pmxt-1.0.0.dist-info/top_level.txt +2 -0
- pmxt_internal/__init__.py +124 -0
- pmxt_internal/api/__init__.py +5 -0
- pmxt_internal/api/default_api.py +3722 -0
- pmxt_internal/api_client.py +804 -0
- pmxt_internal/api_response.py +21 -0
- pmxt_internal/configuration.py +578 -0
- pmxt_internal/exceptions.py +219 -0
- pmxt_internal/models/__init__.py +54 -0
- pmxt_internal/models/balance.py +93 -0
- pmxt_internal/models/base_request.py +91 -0
- pmxt_internal/models/base_response.py +93 -0
- pmxt_internal/models/cancel_order_request.py +94 -0
- pmxt_internal/models/create_order200_response.py +99 -0
- pmxt_internal/models/create_order_params.py +111 -0
- pmxt_internal/models/create_order_request.py +102 -0
- pmxt_internal/models/error_detail.py +87 -0
- pmxt_internal/models/error_response.py +93 -0
- pmxt_internal/models/exchange_credentials.py +93 -0
- pmxt_internal/models/fetch_balance200_response.py +103 -0
- pmxt_internal/models/fetch_markets200_response.py +103 -0
- pmxt_internal/models/fetch_markets_request.py +102 -0
- pmxt_internal/models/fetch_ohlcv200_response.py +103 -0
- pmxt_internal/models/fetch_ohlcv_request.py +102 -0
- pmxt_internal/models/fetch_ohlcv_request_args_inner.py +140 -0
- pmxt_internal/models/fetch_open_orders200_response.py +103 -0
- pmxt_internal/models/fetch_open_orders_request.py +94 -0
- pmxt_internal/models/fetch_order_book200_response.py +99 -0
- pmxt_internal/models/fetch_order_book_request.py +94 -0
- pmxt_internal/models/fetch_positions200_response.py +103 -0
- pmxt_internal/models/fetch_positions_request.py +94 -0
- pmxt_internal/models/fetch_trades200_response.py +103 -0
- pmxt_internal/models/fetch_trades_request.py +102 -0
- pmxt_internal/models/get_markets_by_slug_request.py +94 -0
- pmxt_internal/models/health_check200_response.py +89 -0
- pmxt_internal/models/history_filter_params.py +101 -0
- pmxt_internal/models/market_filter_params.py +113 -0
- pmxt_internal/models/market_outcome.py +95 -0
- pmxt_internal/models/order.py +139 -0
- pmxt_internal/models/order_book.py +106 -0
- pmxt_internal/models/order_level.py +89 -0
- pmxt_internal/models/position.py +101 -0
- pmxt_internal/models/price_candle.py +97 -0
- pmxt_internal/models/search_markets_request.py +102 -0
- pmxt_internal/models/search_markets_request_args_inner.py +140 -0
- pmxt_internal/models/trade.py +105 -0
- pmxt_internal/models/unified_market.py +120 -0
- pmxt_internal/py.typed +0 -0
- pmxt_internal/rest.py +263 -0
pmxt/models.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for PMXT.
|
|
3
|
+
|
|
4
|
+
These are clean Pythonic wrappers around the auto-generated OpenAPI models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional, Dict, Any, Literal
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MarketOutcome:
|
|
14
|
+
"""A single tradeable outcome within a market."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
"""Outcome ID. Use this for fetchOHLCV/fetchOrderBook/fetchTrades.
|
|
18
|
+
- Polymarket: CLOB Token ID
|
|
19
|
+
- Kalshi: Market Ticker
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
label: str
|
|
23
|
+
"""Human-readable label (e.g., "Trump", "Yes")"""
|
|
24
|
+
|
|
25
|
+
price: float
|
|
26
|
+
"""Current price (0.0 to 1.0, representing probability)"""
|
|
27
|
+
|
|
28
|
+
price_change_24h: Optional[float] = None
|
|
29
|
+
"""24-hour price change"""
|
|
30
|
+
|
|
31
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
32
|
+
"""Exchange-specific metadata"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class UnifiedMarket:
|
|
37
|
+
"""A unified market representation across exchanges."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
"""Market ID"""
|
|
41
|
+
|
|
42
|
+
title: str
|
|
43
|
+
"""Market title"""
|
|
44
|
+
|
|
45
|
+
outcomes: List[MarketOutcome]
|
|
46
|
+
"""All tradeable outcomes"""
|
|
47
|
+
|
|
48
|
+
volume_24h: float
|
|
49
|
+
"""24-hour trading volume (USD)"""
|
|
50
|
+
|
|
51
|
+
liquidity: float
|
|
52
|
+
"""Current liquidity (USD)"""
|
|
53
|
+
|
|
54
|
+
url: str
|
|
55
|
+
"""Direct URL to the market"""
|
|
56
|
+
|
|
57
|
+
description: Optional[str] = None
|
|
58
|
+
"""Market description"""
|
|
59
|
+
|
|
60
|
+
resolution_date: Optional[datetime] = None
|
|
61
|
+
"""Expected resolution date"""
|
|
62
|
+
|
|
63
|
+
volume: Optional[float] = None
|
|
64
|
+
"""Total volume (USD)"""
|
|
65
|
+
|
|
66
|
+
open_interest: Optional[float] = None
|
|
67
|
+
"""Open interest (USD)"""
|
|
68
|
+
|
|
69
|
+
image: Optional[str] = None
|
|
70
|
+
"""Market image URL"""
|
|
71
|
+
|
|
72
|
+
category: Optional[str] = None
|
|
73
|
+
"""Market category"""
|
|
74
|
+
|
|
75
|
+
tags: Optional[List[str]] = None
|
|
76
|
+
"""Market tags"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PriceCandle:
|
|
81
|
+
"""OHLCV price candle."""
|
|
82
|
+
|
|
83
|
+
timestamp: int
|
|
84
|
+
"""Unix timestamp (milliseconds)"""
|
|
85
|
+
|
|
86
|
+
open: float
|
|
87
|
+
"""Opening price (0.0 to 1.0)"""
|
|
88
|
+
|
|
89
|
+
high: float
|
|
90
|
+
"""Highest price (0.0 to 1.0)"""
|
|
91
|
+
|
|
92
|
+
low: float
|
|
93
|
+
"""Lowest price (0.0 to 1.0)"""
|
|
94
|
+
|
|
95
|
+
close: float
|
|
96
|
+
"""Closing price (0.0 to 1.0)"""
|
|
97
|
+
|
|
98
|
+
volume: Optional[float] = None
|
|
99
|
+
"""Trading volume"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class OrderLevel:
|
|
104
|
+
"""A single price level in the order book."""
|
|
105
|
+
|
|
106
|
+
price: float
|
|
107
|
+
"""Price (0.0 to 1.0)"""
|
|
108
|
+
|
|
109
|
+
size: float
|
|
110
|
+
"""Number of contracts"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class OrderBook:
|
|
115
|
+
"""Current order book for an outcome."""
|
|
116
|
+
|
|
117
|
+
bids: List[OrderLevel]
|
|
118
|
+
"""Bid orders (sorted high to low)"""
|
|
119
|
+
|
|
120
|
+
asks: List[OrderLevel]
|
|
121
|
+
"""Ask orders (sorted low to high)"""
|
|
122
|
+
|
|
123
|
+
timestamp: Optional[int] = None
|
|
124
|
+
"""Unix timestamp (milliseconds)"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Trade:
|
|
129
|
+
"""A historical trade."""
|
|
130
|
+
|
|
131
|
+
id: str
|
|
132
|
+
"""Trade ID"""
|
|
133
|
+
|
|
134
|
+
timestamp: int
|
|
135
|
+
"""Unix timestamp (milliseconds)"""
|
|
136
|
+
|
|
137
|
+
price: float
|
|
138
|
+
"""Trade price (0.0 to 1.0)"""
|
|
139
|
+
|
|
140
|
+
amount: float
|
|
141
|
+
"""Trade amount (contracts)"""
|
|
142
|
+
|
|
143
|
+
side: Literal["buy", "sell", "unknown"]
|
|
144
|
+
"""Trade side"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Order:
|
|
149
|
+
"""An order (open, filled, or cancelled)."""
|
|
150
|
+
|
|
151
|
+
id: str
|
|
152
|
+
"""Order ID"""
|
|
153
|
+
|
|
154
|
+
market_id: str
|
|
155
|
+
"""Market ID"""
|
|
156
|
+
|
|
157
|
+
outcome_id: str
|
|
158
|
+
"""Outcome ID"""
|
|
159
|
+
|
|
160
|
+
side: Literal["buy", "sell"]
|
|
161
|
+
"""Order side"""
|
|
162
|
+
|
|
163
|
+
type: Literal["market", "limit"]
|
|
164
|
+
"""Order type"""
|
|
165
|
+
|
|
166
|
+
amount: float
|
|
167
|
+
"""Order amount (contracts)"""
|
|
168
|
+
|
|
169
|
+
status: str
|
|
170
|
+
"""Order status (pending, open, filled, cancelled, rejected)"""
|
|
171
|
+
|
|
172
|
+
filled: float
|
|
173
|
+
"""Amount filled"""
|
|
174
|
+
|
|
175
|
+
remaining: float
|
|
176
|
+
"""Amount remaining"""
|
|
177
|
+
|
|
178
|
+
timestamp: int
|
|
179
|
+
"""Unix timestamp (milliseconds)"""
|
|
180
|
+
|
|
181
|
+
price: Optional[float] = None
|
|
182
|
+
"""Limit price (for limit orders)"""
|
|
183
|
+
|
|
184
|
+
fee: Optional[float] = None
|
|
185
|
+
"""Trading fee"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class Position:
|
|
190
|
+
"""A current position in a market."""
|
|
191
|
+
|
|
192
|
+
market_id: str
|
|
193
|
+
"""Market ID"""
|
|
194
|
+
|
|
195
|
+
outcome_id: str
|
|
196
|
+
"""Outcome ID"""
|
|
197
|
+
|
|
198
|
+
outcome_label: str
|
|
199
|
+
"""Outcome label"""
|
|
200
|
+
|
|
201
|
+
size: float
|
|
202
|
+
"""Position size (positive for long, negative for short)"""
|
|
203
|
+
|
|
204
|
+
entry_price: float
|
|
205
|
+
"""Average entry price"""
|
|
206
|
+
|
|
207
|
+
current_price: float
|
|
208
|
+
"""Current market price"""
|
|
209
|
+
|
|
210
|
+
unrealized_pnl: float
|
|
211
|
+
"""Unrealized profit/loss"""
|
|
212
|
+
|
|
213
|
+
realized_pnl: Optional[float] = None
|
|
214
|
+
"""Realized profit/loss"""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Balance:
|
|
219
|
+
"""Account balance."""
|
|
220
|
+
|
|
221
|
+
currency: str
|
|
222
|
+
"""Currency (e.g., "USDC")"""
|
|
223
|
+
|
|
224
|
+
total: float
|
|
225
|
+
"""Total balance"""
|
|
226
|
+
|
|
227
|
+
available: float
|
|
228
|
+
"""Available for trading"""
|
|
229
|
+
|
|
230
|
+
locked: float
|
|
231
|
+
"""Locked in open orders"""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Parameter types
|
|
235
|
+
CandleInterval = Literal["1m", "5m", "15m", "1h", "6h", "1d"]
|
|
236
|
+
SortOption = Literal["volume", "liquidity", "newest"]
|
|
237
|
+
SearchIn = Literal["title", "description", "both"]
|
|
238
|
+
OrderSide = Literal["buy", "sell"]
|
|
239
|
+
OrderType = Literal["market", "limit"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class MarketFilterParams:
|
|
244
|
+
"""Parameters for filtering markets."""
|
|
245
|
+
|
|
246
|
+
limit: Optional[int] = None
|
|
247
|
+
"""Maximum number of results"""
|
|
248
|
+
|
|
249
|
+
offset: Optional[int] = None
|
|
250
|
+
"""Pagination offset"""
|
|
251
|
+
|
|
252
|
+
sort: Optional[SortOption] = None
|
|
253
|
+
"""Sort order"""
|
|
254
|
+
|
|
255
|
+
search_in: Optional[SearchIn] = None
|
|
256
|
+
"""Where to search (for searchMarkets)"""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass
|
|
260
|
+
class HistoryFilterParams:
|
|
261
|
+
"""Parameters for fetching historical data."""
|
|
262
|
+
|
|
263
|
+
resolution: CandleInterval
|
|
264
|
+
"""Candle resolution"""
|
|
265
|
+
|
|
266
|
+
start: Optional[datetime] = None
|
|
267
|
+
"""Start time"""
|
|
268
|
+
|
|
269
|
+
end: Optional[datetime] = None
|
|
270
|
+
"""End time"""
|
|
271
|
+
|
|
272
|
+
limit: Optional[int] = None
|
|
273
|
+
"""Maximum number of results"""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class CreateOrderParams:
|
|
278
|
+
"""Parameters for creating an order."""
|
|
279
|
+
|
|
280
|
+
market_id: str
|
|
281
|
+
"""Market ID"""
|
|
282
|
+
|
|
283
|
+
outcome_id: str
|
|
284
|
+
"""Outcome ID"""
|
|
285
|
+
|
|
286
|
+
side: OrderSide
|
|
287
|
+
"""Order side (buy/sell)"""
|
|
288
|
+
|
|
289
|
+
type: OrderType
|
|
290
|
+
"""Order type (market/limit)"""
|
|
291
|
+
|
|
292
|
+
amount: float
|
|
293
|
+
"""Number of contracts"""
|
|
294
|
+
|
|
295
|
+
price: Optional[float] = None
|
|
296
|
+
"""Limit price (required for limit orders, 0.0-1.0)"""
|
pmxt/server_manager.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server Manager for PMXT Python SDK
|
|
3
|
+
|
|
4
|
+
This module handles automatic server lifecycle management.
|
|
5
|
+
The pattern implemented here is universal and can be replicated in any language SDK.
|
|
6
|
+
|
|
7
|
+
Universal Pattern:
|
|
8
|
+
1. Check if server is running (via lock file + process check)
|
|
9
|
+
2. If not running, call pmxt-ensure-server launcher
|
|
10
|
+
3. Wait for health check to confirm server is ready
|
|
11
|
+
4. Proceed with API calls
|
|
12
|
+
|
|
13
|
+
This ensures zero-configuration usage across all SDKs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import subprocess
|
|
20
|
+
import shutil
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional, Dict, Any
|
|
23
|
+
import urllib.request
|
|
24
|
+
import urllib.error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ServerManager:
|
|
28
|
+
"""
|
|
29
|
+
Manages the PMXT sidecar server lifecycle.
|
|
30
|
+
|
|
31
|
+
This class implements the universal server management pattern that
|
|
32
|
+
should be replicated in all language SDKs (Java, C#, Go, etc.)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
DEFAULT_PORT = 3847
|
|
36
|
+
HEALTH_CHECK_TIMEOUT = 10 # seconds
|
|
37
|
+
HEALTH_CHECK_INTERVAL = 0.1 # seconds
|
|
38
|
+
|
|
39
|
+
def __init__(self, base_url: str = "http://localhost:3847"):
|
|
40
|
+
"""
|
|
41
|
+
Initialize the server manager.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
base_url: Base URL where server should be running
|
|
45
|
+
"""
|
|
46
|
+
self.base_url = base_url
|
|
47
|
+
self.lock_path = Path.home() / '.pmxt' / 'server.lock'
|
|
48
|
+
self._port = self._extract_port_from_url(base_url)
|
|
49
|
+
|
|
50
|
+
def _extract_port_from_url(self, url: str) -> int:
|
|
51
|
+
"""Extract port number from URL."""
|
|
52
|
+
try:
|
|
53
|
+
from urllib.parse import urlparse
|
|
54
|
+
parsed = urlparse(url)
|
|
55
|
+
return parsed.port or self.DEFAULT_PORT
|
|
56
|
+
except:
|
|
57
|
+
return self.DEFAULT_PORT
|
|
58
|
+
|
|
59
|
+
def ensure_server_running(self) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Ensure the PMXT server is running.
|
|
62
|
+
|
|
63
|
+
This is the main entry point that SDKs should call.
|
|
64
|
+
It implements the universal pattern:
|
|
65
|
+
1. Check if server is alive
|
|
66
|
+
2. If not, start it via launcher
|
|
67
|
+
3. Wait for health check
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
Exception: If server fails to start or become healthy
|
|
71
|
+
"""
|
|
72
|
+
# Step 1: Check if server is already running
|
|
73
|
+
if self.is_server_alive():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Step 2: Start server via launcher
|
|
77
|
+
self._start_server_via_launcher()
|
|
78
|
+
|
|
79
|
+
# Step 3: Wait for health check
|
|
80
|
+
self._wait_for_health()
|
|
81
|
+
|
|
82
|
+
def is_server_alive(self) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Check if the server is currently running and healthy.
|
|
85
|
+
|
|
86
|
+
This implements the universal alive check:
|
|
87
|
+
1. Read lock file
|
|
88
|
+
2. Check if process exists
|
|
89
|
+
3. Optionally verify health endpoint
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if server is running and healthy, False otherwise
|
|
93
|
+
"""
|
|
94
|
+
# Check lock file exists
|
|
95
|
+
if not self.lock_path.exists():
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# Read lock file
|
|
100
|
+
lock_data = json.loads(self.lock_path.read_text())
|
|
101
|
+
pid = lock_data.get('pid')
|
|
102
|
+
port = lock_data.get('port', self.DEFAULT_PORT)
|
|
103
|
+
|
|
104
|
+
if not pid:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Check if process exists (cross-platform)
|
|
108
|
+
if not self._is_process_running(pid):
|
|
109
|
+
# Process doesn't exist, remove stale lock file
|
|
110
|
+
self._remove_stale_lock()
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
# Quick health check to verify server is responsive
|
|
114
|
+
try:
|
|
115
|
+
return self._check_health(port, timeout=1)
|
|
116
|
+
except:
|
|
117
|
+
# Process exists but not responding
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
except (json.JSONDecodeError, OSError):
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def _is_process_running(self, pid: int) -> bool:
|
|
124
|
+
"""
|
|
125
|
+
Check if a process with given PID is running.
|
|
126
|
+
|
|
127
|
+
Cross-platform implementation.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
# Signal 0 doesn't kill the process, just checks if it exists
|
|
131
|
+
os.kill(pid, 0)
|
|
132
|
+
return True
|
|
133
|
+
except (OSError, ProcessLookupError):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _remove_stale_lock(self) -> None:
|
|
137
|
+
"""Remove stale lock file."""
|
|
138
|
+
try:
|
|
139
|
+
self.lock_path.unlink()
|
|
140
|
+
except:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def _start_server_via_launcher(self) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Start the server using the pmxt-ensure-server launcher.
|
|
146
|
+
"""
|
|
147
|
+
# 1. Check for local development paths first
|
|
148
|
+
current_file = Path(__file__).resolve()
|
|
149
|
+
# Look for ../../bin/pmxt-ensure-server (monorepo structure)
|
|
150
|
+
local_launcher = current_file.parent.parent.parent / 'bin' / 'pmxt-ensure-server'
|
|
151
|
+
|
|
152
|
+
launcher = str(local_launcher) if local_launcher.exists() else shutil.which('pmxt-ensure-server')
|
|
153
|
+
|
|
154
|
+
if not launcher:
|
|
155
|
+
raise Exception(
|
|
156
|
+
"pmxt-ensure-server not found.\n"
|
|
157
|
+
"Local search failed and not in PATH.\n"
|
|
158
|
+
"Please install the server: npm install -g pmxtjs"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Call the launcher
|
|
162
|
+
try:
|
|
163
|
+
# If it's a JS file and we are calling it directly, might need node
|
|
164
|
+
cmd = [launcher]
|
|
165
|
+
if launcher.endswith('.js') or not os.access(launcher, os.X_OK):
|
|
166
|
+
cmd = ['node', launcher]
|
|
167
|
+
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
cmd,
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
timeout=self.HEALTH_CHECK_TIMEOUT
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
raise Exception(
|
|
177
|
+
f"Failed to start server: {result.stderr or result.stdout}"
|
|
178
|
+
)
|
|
179
|
+
except subprocess.TimeoutExpired:
|
|
180
|
+
raise Exception("Server startup timeout")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise Exception(f"Failed to start server: {e}")
|
|
183
|
+
|
|
184
|
+
def _wait_for_health(self) -> None:
|
|
185
|
+
"""
|
|
186
|
+
Wait for the server to respond to health checks.
|
|
187
|
+
|
|
188
|
+
Universal pattern: Poll /health endpoint until it responds or timeout.
|
|
189
|
+
"""
|
|
190
|
+
start_time = time.time()
|
|
191
|
+
|
|
192
|
+
while time.time() - start_time < self.HEALTH_CHECK_TIMEOUT:
|
|
193
|
+
try:
|
|
194
|
+
if self._check_health(self._port):
|
|
195
|
+
return
|
|
196
|
+
except:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
time.sleep(self.HEALTH_CHECK_INTERVAL)
|
|
200
|
+
|
|
201
|
+
raise Exception(
|
|
202
|
+
f"Server failed to become healthy within {self.HEALTH_CHECK_TIMEOUT}s"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _check_health(self, port: int, timeout: int = 2) -> bool:
|
|
206
|
+
"""
|
|
207
|
+
Check if server is healthy by calling /health endpoint.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
port: Port to check
|
|
211
|
+
timeout: Request timeout in seconds
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if server responds with 200 OK
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
url = f"http://localhost:{port}/health"
|
|
218
|
+
req = urllib.request.Request(url)
|
|
219
|
+
|
|
220
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
221
|
+
if response.status == 200:
|
|
222
|
+
data = json.loads(response.read().decode())
|
|
223
|
+
return data.get('status') == 'ok'
|
|
224
|
+
|
|
225
|
+
return False
|
|
226
|
+
except (urllib.error.URLError, urllib.error.HTTPError, Exception):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def get_server_info(self) -> Optional[Dict[str, Any]]:
|
|
230
|
+
"""
|
|
231
|
+
Get information about the running server from lock file.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Dictionary with server info (port, pid, timestamp) or None
|
|
235
|
+
"""
|
|
236
|
+
if not self.lock_path.exists():
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
return json.loads(self.lock_path.read_text())
|
|
241
|
+
except:
|
|
242
|
+
return None
|