stx-python 0.6.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.
stx/_base.py ADDED
@@ -0,0 +1,64 @@
1
+ """Base class for every response model the SDK returns.
2
+
3
+ Two rules shape it:
4
+
5
+ 1. Every field is optional and unknown fields are kept. The server adds
6
+ fields over time, and a response missing a field the SDK knows about
7
+ should not stop a trading loop. Extra fields are reachable as
8
+ attributes and through ``model_dump()``.
9
+ 2. A field whose value does not match its declared type is kept as the
10
+ raw value rather than failing the whole response. The specification
11
+ and the server can disagree on a display field, and that should cost
12
+ the caller one field, not the whole list of markets. Each such field
13
+ is logged once at WARNING so the mismatch is visible.
14
+
15
+ Money and quantity fields are declared ``str`` and the API sends strings,
16
+ so they pass through untouched: ``"0.5600"`` stays ``"0.5600"``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any, Dict, Set, Tuple
23
+
24
+ from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
25
+
26
+ logger = logging.getLogger("stx")
27
+
28
+ _warned: Set[Tuple[str, str]] = set()
29
+
30
+
31
+ class STXModel(BaseModel):
32
+ """Pydantic v2 model with lenient parsing. See the module docstring."""
33
+
34
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
35
+
36
+ @model_validator(mode="wrap")
37
+ @classmethod
38
+ def _lenient(cls, data: Any, handler: Any) -> Any:
39
+ # Normal path: the payload matches the declared types.
40
+ try:
41
+ return handler(data)
42
+ except ValidationError as exc:
43
+ if not isinstance(data, dict):
44
+ raise
45
+ # Collect the top-level fields that failed, validate without
46
+ # them, then put their raw values back.
47
+ bad = {err["loc"][0] for err in exc.errors() if err.get("loc")}
48
+ bad = {name for name in bad if isinstance(name, str) and name in data}
49
+ if not bad:
50
+ raise
51
+ clean: Dict[str, Any] = {k: v for k, v in data.items() if k not in bad}
52
+ model = handler(clean)
53
+ for name in sorted(bad):
54
+ key = (cls.__name__, name)
55
+ if key not in _warned:
56
+ _warned.add(key)
57
+ logger.warning(
58
+ "%s.%s did not match its declared type; keeping the raw value %r",
59
+ cls.__name__,
60
+ name,
61
+ data[name],
62
+ )
63
+ object.__setattr__(model, name, data[name])
64
+ return model
stx/_client.py ADDED
@@ -0,0 +1,502 @@
1
+ """``STX``: the blocking client, a thin wrapper over :class:`stx.AsyncSTX`.
2
+
3
+ Each method runs the matching ``AsyncSTX`` coroutine on a private event
4
+ loop in a background thread and returns its result, so the sync and async
5
+ clients cannot drift apart: there is one implementation. Because the loop
6
+ is private, ``STX`` also works inside Jupyter or any program that already
7
+ runs an event loop.
8
+
9
+ ``iter_*`` methods return ordinary iterators that fetch the next page when
10
+ the current one runs out.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import threading
17
+ from typing import (
18
+ Any,
19
+ AsyncIterator,
20
+ Coroutine,
21
+ Iterator,
22
+ List,
23
+ Mapping,
24
+ Optional,
25
+ Sequence,
26
+ TypeVar,
27
+ )
28
+
29
+ from stx import models
30
+ from stx._async_client import Amount, AsyncSTX, StrList
31
+ from stx._paging import Page
32
+ from stx._results import BatchOrderResult
33
+
34
+ T = TypeVar("T")
35
+
36
+
37
+ class _LoopThread:
38
+ """An event loop running forever in a daemon thread."""
39
+
40
+ def __init__(self) -> None:
41
+ self.loop = asyncio.new_event_loop()
42
+ self.thread = threading.Thread(target=self.loop.run_forever, name="stx-sync", daemon=True)
43
+ self.thread.start()
44
+
45
+ def run(self, coro: Coroutine[Any, Any, T]) -> T:
46
+ return asyncio.run_coroutine_threadsafe(coro, self.loop).result()
47
+
48
+ def stop(self) -> None:
49
+ self.loop.call_soon_threadsafe(self.loop.stop)
50
+ self.thread.join(timeout=5)
51
+ self.loop.close()
52
+
53
+
54
+ class STX:
55
+ """Blocking client for the STX exchange.
56
+
57
+ Takes the same arguments as :class:`stx.AsyncSTX` and has the same
58
+ methods, without ``await``::
59
+
60
+ with STX(profile="us-demo") as client:
61
+ print(client.me().user_id)
62
+ for market in client.markets(status="open", limit=5):
63
+ print(market.symbol, market.last_traded_price)
64
+
65
+ For WebSocket channels use :class:`stx.STXWebSocket`, which is async.
66
+ """
67
+
68
+ def __init__(self, **kwargs: Any) -> None:
69
+ self._loop = _LoopThread()
70
+ try:
71
+ self._async = self._loop.run(self._make(kwargs))
72
+ except BaseException:
73
+ self._loop.stop()
74
+ raise
75
+ self.base_url = self._async.base_url
76
+ self.socket_url = self._async.socket_url
77
+ self.credentials = self._async.credentials
78
+ self.profile = self._async.profile
79
+
80
+ @staticmethod
81
+ async def _make(kwargs: Mapping[str, Any]) -> AsyncSTX:
82
+ return AsyncSTX(**kwargs)
83
+
84
+ def __repr__(self) -> str:
85
+ key = self.credentials.key_id if self.credentials else None
86
+ return f"STX(base_url={self.base_url!r}, key_id={key!r})"
87
+
88
+ @property
89
+ def async_client(self) -> AsyncSTX:
90
+ """The underlying ``AsyncSTX`` (bound to this client's private loop)."""
91
+ return self._async
92
+
93
+ def __enter__(self) -> STX:
94
+ return self
95
+
96
+ def __exit__(self, *exc: Any) -> None:
97
+ self.close()
98
+
99
+ def close(self) -> None:
100
+ """Close connections and stop the background loop. Safe to call twice."""
101
+ if self._loop is None:
102
+ return
103
+ try:
104
+ self._loop.run(self._async.close())
105
+ finally:
106
+ self._loop.stop()
107
+ self._loop = None # type: ignore[assignment]
108
+
109
+ def _run(self, coro: Coroutine[Any, Any, T]) -> T:
110
+ if self._loop is None:
111
+ coro.close()
112
+ raise RuntimeError("This STX client is closed.")
113
+ return self._loop.run(coro)
114
+
115
+ def _iterate(self, agen: AsyncIterator[T]) -> Iterator[T]:
116
+ async def step() -> Any:
117
+ return await agen.__anext__()
118
+
119
+ try:
120
+ while True:
121
+ try:
122
+ yield self._run(step())
123
+ except StopAsyncIteration:
124
+ return
125
+ finally:
126
+ closer = getattr(agen, "aclose", None)
127
+ if closer is not None and self._loop is not None:
128
+ self._run(closer())
129
+
130
+ # Identity --------------------------------------------------------
131
+
132
+ def me(self) -> models.Me:
133
+ """``GET /api/v1/me``. See :meth:`AsyncSTX.me`."""
134
+ return self._run(self._async.me())
135
+
136
+ def user_id(self) -> str:
137
+ """Your user id, fetched once. See :meth:`AsyncSTX.user_id`."""
138
+ return self._run(self._async.user_id())
139
+
140
+ # Markets and events ---------------------------------------------
141
+
142
+ def markets(
143
+ self,
144
+ *,
145
+ market_ids: StrList = None,
146
+ event_ids: StrList = None,
147
+ status: StrList = None,
148
+ trading: Optional[bool] = None,
149
+ sports: StrList = None,
150
+ competitions: StrList = None,
151
+ sort_by: Optional[str] = None,
152
+ sort_direction: Optional[str] = None,
153
+ limit: Optional[int] = None,
154
+ cursor: Optional[str] = None,
155
+ ) -> Page[models.Market]:
156
+ """``GET /api/v1/markets``. See :meth:`AsyncSTX.markets`."""
157
+ return self._run(
158
+ self._async.markets(
159
+ market_ids=market_ids, event_ids=event_ids, status=status, trading=trading,
160
+ sports=sports, competitions=competitions, sort_by=sort_by,
161
+ sort_direction=sort_direction, limit=limit, cursor=cursor,
162
+ )
163
+ ) # fmt: skip
164
+
165
+ def iter_markets(
166
+ self,
167
+ *,
168
+ market_ids: StrList = None,
169
+ event_ids: StrList = None,
170
+ status: StrList = None,
171
+ trading: Optional[bool] = None,
172
+ sports: StrList = None,
173
+ competitions: StrList = None,
174
+ sort_by: Optional[str] = None,
175
+ sort_direction: Optional[str] = None,
176
+ limit: Optional[int] = None,
177
+ ) -> Iterator[models.Market]:
178
+ """Every matching market, page by page."""
179
+ return self._iterate(
180
+ self._async.iter_markets(
181
+ market_ids=market_ids, event_ids=event_ids, status=status, trading=trading,
182
+ sports=sports, competitions=competitions, sort_by=sort_by,
183
+ sort_direction=sort_direction, limit=limit,
184
+ )
185
+ ) # fmt: skip
186
+
187
+ def market(self, market_id: str) -> models.Market:
188
+ """One market by id. See :meth:`AsyncSTX.market`."""
189
+ return self._run(self._async.market(market_id))
190
+
191
+ def events(
192
+ self,
193
+ *,
194
+ event_ids: StrList = None,
195
+ sports: StrList = None,
196
+ competitions: StrList = None,
197
+ event_types: StrList = None,
198
+ title: Optional[str] = None,
199
+ status: Optional[str] = None,
200
+ promoted: Optional[bool] = None,
201
+ sort_by: Optional[str] = None,
202
+ sort_direction: Optional[str] = None,
203
+ limit: Optional[int] = None,
204
+ cursor: Optional[str] = None,
205
+ ) -> Page[models.Event]:
206
+ """``GET /api/v1/events``. See :meth:`AsyncSTX.events`."""
207
+ return self._run(
208
+ self._async.events(
209
+ event_ids=event_ids, sports=sports, competitions=competitions,
210
+ event_types=event_types, title=title, status=status, promoted=promoted,
211
+ sort_by=sort_by, sort_direction=sort_direction, limit=limit, cursor=cursor,
212
+ )
213
+ ) # fmt: skip
214
+
215
+ def iter_events(
216
+ self,
217
+ *,
218
+ event_ids: StrList = None,
219
+ sports: StrList = None,
220
+ competitions: StrList = None,
221
+ event_types: StrList = None,
222
+ title: Optional[str] = None,
223
+ status: Optional[str] = None,
224
+ promoted: Optional[bool] = None,
225
+ sort_by: Optional[str] = None,
226
+ sort_direction: Optional[str] = None,
227
+ limit: Optional[int] = None,
228
+ ) -> Iterator[models.Event]:
229
+ """Every matching event, page by page."""
230
+ return self._iterate(
231
+ self._async.iter_events(
232
+ event_ids=event_ids, sports=sports, competitions=competitions,
233
+ event_types=event_types, title=title, status=status, promoted=promoted,
234
+ sort_by=sort_by, sort_direction=sort_direction, limit=limit,
235
+ )
236
+ ) # fmt: skip
237
+
238
+ # Orders ------------------------------------------------------------
239
+
240
+ def orders(
241
+ self,
242
+ *,
243
+ order_ids: StrList = None,
244
+ client_order_ids: StrList = None,
245
+ market_ids: StrList = None,
246
+ status: StrList = None,
247
+ limit: Optional[int] = None,
248
+ cursor: Optional[str] = None,
249
+ ) -> Page[models.Order]:
250
+ """``GET /api/v1/orders``. See :meth:`AsyncSTX.orders`."""
251
+ return self._run(
252
+ self._async.orders(
253
+ order_ids=order_ids, client_order_ids=client_order_ids, market_ids=market_ids,
254
+ status=status, limit=limit, cursor=cursor,
255
+ )
256
+ ) # fmt: skip
257
+
258
+ def iter_orders(
259
+ self,
260
+ *,
261
+ order_ids: StrList = None,
262
+ client_order_ids: StrList = None,
263
+ market_ids: StrList = None,
264
+ status: StrList = None,
265
+ limit: Optional[int] = None,
266
+ ) -> Iterator[models.Order]:
267
+ """Every matching order, page by page."""
268
+ return self._iterate(
269
+ self._async.iter_orders(
270
+ order_ids=order_ids, client_order_ids=client_order_ids, market_ids=market_ids,
271
+ status=status, limit=limit,
272
+ )
273
+ ) # fmt: skip
274
+
275
+ def order(self, order_id: str) -> models.Order:
276
+ """``GET /api/v1/orders/{order_id}``."""
277
+ return self._run(self._async.order(order_id))
278
+
279
+ def place_order(
280
+ self,
281
+ market_id: str,
282
+ action: str,
283
+ order_type: str,
284
+ *,
285
+ price: Optional[Amount] = None,
286
+ quantity: Amount,
287
+ client_order_id: Optional[str] = None,
288
+ expiration: Optional[str] = None,
289
+ expiration_time: Optional[int] = None,
290
+ cancel_on_disconnect: Optional[bool] = None,
291
+ device_id: Optional[str] = None,
292
+ ) -> models.Order:
293
+ """``POST /api/v1/orders``. See :meth:`AsyncSTX.place_order`."""
294
+ return self._run(
295
+ self._async.place_order(
296
+ market_id, action, order_type, price=price, quantity=quantity,
297
+ client_order_id=client_order_id, expiration=expiration,
298
+ expiration_time=expiration_time, cancel_on_disconnect=cancel_on_disconnect,
299
+ device_id=device_id,
300
+ )
301
+ ) # fmt: skip
302
+
303
+ def place_orders(
304
+ self, orders: Sequence[Mapping[str, Any]], *, geo_location: Optional[str] = None
305
+ ) -> List[BatchOrderResult]:
306
+ """``POST /api/v1/orders/batched``. See :meth:`AsyncSTX.place_orders`."""
307
+ return self._run(self._async.place_orders(orders, geo_location=geo_location))
308
+
309
+ def cancel_order(self, order_id: str) -> models.Cancellation:
310
+ """``DELETE /api/v1/orders/{order_id}``."""
311
+ return self._run(self._async.cancel_order(order_id))
312
+
313
+ def cancel_orders(self, order_ids: Sequence[str]) -> List[models.Cancellation]:
314
+ """``DELETE /api/v1/orders/batched``."""
315
+ return self._run(self._async.cancel_orders(order_ids))
316
+
317
+ def cancel_all_orders(self) -> List[models.Cancellation]:
318
+ """``DELETE /api/v1/orders/all``."""
319
+ return self._run(self._async.cancel_all_orders())
320
+
321
+ # Fills, positions, balance ----------------------------------------
322
+
323
+ def fills(
324
+ self,
325
+ *,
326
+ market_ids: StrList = None,
327
+ order_ids: StrList = None,
328
+ status: Optional[str] = None,
329
+ limit: Optional[int] = None,
330
+ cursor: Optional[str] = None,
331
+ ) -> Page[models.Fill]:
332
+ """``GET /api/v1/fills``. See :meth:`AsyncSTX.fills`."""
333
+ return self._run(
334
+ self._async.fills(
335
+ market_ids=market_ids, order_ids=order_ids, status=status, limit=limit,
336
+ cursor=cursor,
337
+ )
338
+ ) # fmt: skip
339
+
340
+ def iter_fills(
341
+ self,
342
+ *,
343
+ market_ids: StrList = None,
344
+ order_ids: StrList = None,
345
+ status: Optional[str] = None,
346
+ limit: Optional[int] = None,
347
+ ) -> Iterator[models.Fill]:
348
+ """Every matching fill, page by page."""
349
+ return self._iterate(
350
+ self._async.iter_fills(
351
+ market_ids=market_ids, order_ids=order_ids, status=status, limit=limit
352
+ )
353
+ )
354
+
355
+ def positions(self, *, market_ids: StrList = None) -> List[models.Position]:
356
+ """``GET /api/v1/positions``. See :meth:`AsyncSTX.positions`."""
357
+ return self._run(self._async.positions(market_ids=market_ids))
358
+
359
+ def balance(self) -> models.Balance:
360
+ """``GET /api/v1/account/balance``."""
361
+ return self._run(self._async.balance())
362
+
363
+ def account_market_stats(
364
+ self,
365
+ *,
366
+ market_ids: StrList = None,
367
+ event_ids: StrList = None,
368
+ exclude_zero_settlements: Optional[bool] = None,
369
+ from_time: Optional[int] = None,
370
+ to_time: Optional[int] = None,
371
+ sports: StrList = None,
372
+ competitions: StrList = None,
373
+ limit: Optional[int] = None,
374
+ cursor: Optional[str] = None,
375
+ ) -> Page[models.MarketStat]:
376
+ """``GET /api/v1/account/market_stats``."""
377
+ return self._run(
378
+ self._async.account_market_stats(
379
+ market_ids=market_ids, event_ids=event_ids,
380
+ exclude_zero_settlements=exclude_zero_settlements, from_time=from_time,
381
+ to_time=to_time, sports=sports, competitions=competitions, limit=limit,
382
+ cursor=cursor,
383
+ )
384
+ ) # fmt: skip
385
+
386
+ def iter_account_market_stats(
387
+ self,
388
+ *,
389
+ market_ids: StrList = None,
390
+ event_ids: StrList = None,
391
+ exclude_zero_settlements: Optional[bool] = None,
392
+ from_time: Optional[int] = None,
393
+ to_time: Optional[int] = None,
394
+ sports: StrList = None,
395
+ competitions: StrList = None,
396
+ limit: Optional[int] = None,
397
+ ) -> Iterator[models.MarketStat]:
398
+ """Every per-market stat row, page by page."""
399
+ return self._iterate(
400
+ self._async.iter_account_market_stats(
401
+ market_ids=market_ids, event_ids=event_ids,
402
+ exclude_zero_settlements=exclude_zero_settlements, from_time=from_time,
403
+ to_time=to_time, sports=sports, competitions=competitions, limit=limit,
404
+ )
405
+ ) # fmt: skip
406
+
407
+ # Portfolio history --------------------------------------------------
408
+
409
+ def settlements(
410
+ self,
411
+ *,
412
+ market_ids: StrList = None,
413
+ type: Optional[str] = None,
414
+ limit: Optional[int] = None,
415
+ cursor: Optional[str] = None,
416
+ ) -> Page[models.Settlement]:
417
+ """``GET /api/v1/portfolio/settlements``."""
418
+ return self._run(
419
+ self._async.settlements(market_ids=market_ids, type=type, limit=limit, cursor=cursor)
420
+ )
421
+
422
+ def iter_settlements(
423
+ self, *, market_ids: StrList = None, type: Optional[str] = None, limit: Optional[int] = None
424
+ ) -> Iterator[models.Settlement]:
425
+ """Every settlement, page by page."""
426
+ return self._iterate(
427
+ self._async.iter_settlements(market_ids=market_ids, type=type, limit=limit)
428
+ )
429
+
430
+ def deposits(
431
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
432
+ ) -> Page[models.PaymentTransaction]:
433
+ """``GET /api/v1/portfolio/deposits``."""
434
+ return self._run(self._async.deposits(limit=limit, cursor=cursor))
435
+
436
+ def iter_deposits(self, *, limit: Optional[int] = None) -> Iterator[models.PaymentTransaction]:
437
+ """Every deposit, page by page."""
438
+ return self._iterate(self._async.iter_deposits(limit=limit))
439
+
440
+ def withdrawals(
441
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
442
+ ) -> Page[models.PaymentTransaction]:
443
+ """``GET /api/v1/portfolio/withdrawals``."""
444
+ return self._run(self._async.withdrawals(limit=limit, cursor=cursor))
445
+
446
+ def iter_withdrawals(
447
+ self, *, limit: Optional[int] = None
448
+ ) -> Iterator[models.PaymentTransaction]:
449
+ """Every withdrawal, page by page."""
450
+ return self._iterate(self._async.iter_withdrawals(limit=limit))
451
+
452
+ def adjustments(
453
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
454
+ ) -> Page[models.PaymentTransaction]:
455
+ """``GET /api/v1/portfolio/adjustments``."""
456
+ return self._run(self._async.adjustments(limit=limit, cursor=cursor))
457
+
458
+ def iter_adjustments(
459
+ self, *, limit: Optional[int] = None
460
+ ) -> Iterator[models.PaymentTransaction]:
461
+ """Every adjustment, page by page."""
462
+ return self._iterate(self._async.iter_adjustments(limit=limit))
463
+
464
+ def fees(
465
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
466
+ ) -> Page[models.FeeTransaction]:
467
+ """``GET /api/v1/portfolio/fees``."""
468
+ return self._run(self._async.fees(limit=limit, cursor=cursor))
469
+
470
+ def iter_fees(self, *, limit: Optional[int] = None) -> Iterator[models.FeeTransaction]:
471
+ """Every fee entry, page by page."""
472
+ return self._iterate(self._async.iter_fees(limit=limit))
473
+
474
+ def loyalty(
475
+ self, *, limit: Optional[int] = None, cursor: Optional[str] = None
476
+ ) -> Page[models.Transaction]:
477
+ """``GET /api/v1/portfolio/loyalty``."""
478
+ return self._run(self._async.loyalty(limit=limit, cursor=cursor))
479
+
480
+ def iter_loyalty(self, *, limit: Optional[int] = None) -> Iterator[models.Transaction]:
481
+ """Every loyalty entry, page by page."""
482
+ return self._iterate(self._async.iter_loyalty(limit=limit))
483
+
484
+ # Terms ----------------------------------------------------------------
485
+
486
+ def accept_terms(
487
+ self,
488
+ device_id: str,
489
+ *,
490
+ accept_terms: bool = True,
491
+ accept_privacy: bool = True,
492
+ accept_house_rules: Optional[bool] = None,
493
+ ) -> str:
494
+ """``POST /api/v1/tnc/accept``."""
495
+ return self._run(
496
+ self._async.accept_terms(
497
+ device_id,
498
+ accept_terms=accept_terms,
499
+ accept_privacy=accept_privacy,
500
+ accept_house_rules=accept_house_rules,
501
+ )
502
+ )
stx/_config.py ADDED
@@ -0,0 +1,107 @@
1
+ """Where the SDK connects: (region, env) to a host, and host to URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Optional, Tuple, Union
6
+ from urllib.parse import urlparse
7
+
8
+ from stx.enums import Environment, Region
9
+ from stx.exceptions import STXConfigException
10
+
11
+ # (Region, Environment) -> hostname, for the environments published at
12
+ # https://docs.stxapp.io/environments/. United States production is absent
13
+ # until that host serves the API. Any other host, such as a local server, is
14
+ # reachable through ``host=``.
15
+ #
16
+ # API keys do not carry across exchanges: an Ontario key will not
17
+ # authenticate against the United States exchange.
18
+ HOSTS: Dict[Tuple[Region, Environment], str] = {
19
+ (Region.US, Environment.DEMO): "demo.stxapp.io",
20
+ (Region.ONTARIO, Environment.DEMO): "demo.stxapp.ca",
21
+ (Region.ONTARIO, Environment.PRODUCTION): "api.on.stxapp.ca",
22
+ }
23
+
24
+ # Spellings accepted for ``env`` on top of the Environment values.
25
+ _ENV_ALIASES: Dict[str, Environment] = {
26
+ "prod": Environment.PRODUCTION,
27
+ "live": Environment.PRODUCTION,
28
+ }
29
+
30
+ SOCKET_PATH = "/socket/websocket"
31
+
32
+
33
+ def _coerce_region(value: Union[Region, str, None]) -> Optional[Region]:
34
+ if value is None or isinstance(value, Region):
35
+ return value
36
+ try:
37
+ return Region(value.lower())
38
+ except ValueError:
39
+ raise STXConfigException(
40
+ f"Unknown region: {value!r}. Known: {[r.value for r in Region]}."
41
+ ) from None
42
+
43
+
44
+ def _coerce_env(value: Union[Environment, str, None]) -> Optional[Environment]:
45
+ if value is None or isinstance(value, Environment):
46
+ return value
47
+ lowered = value.lower()
48
+ if lowered in _ENV_ALIASES:
49
+ return _ENV_ALIASES[lowered]
50
+ try:
51
+ return Environment(lowered)
52
+ except ValueError:
53
+ raise STXConfigException(
54
+ f"Unknown env: {value!r}. Known: {[e.value for e in Environment]}."
55
+ ) from None
56
+
57
+
58
+ def resolve_host(
59
+ region: Union[Region, str, None],
60
+ env: Union[Environment, str, None],
61
+ ) -> str:
62
+ """Hostname for a (region, env) pair, or ``STXConfigException``."""
63
+ region_enum = _coerce_region(region)
64
+ env_enum = _coerce_env(env)
65
+ if region_enum is None or env_enum is None:
66
+ raise STXConfigException(
67
+ "No host configured. Pass region= and env= (for example "
68
+ "region='us', env='demo'), or host=, or set them in a profile "
69
+ "in ~/.stx/credentials."
70
+ )
71
+ host = HOSTS.get((region_enum, env_enum))
72
+ if host is None:
73
+ raise STXConfigException(
74
+ f"No host configured for region={region_enum.value}, env={env_enum.value}. "
75
+ "Pass host= explicitly."
76
+ )
77
+ return host
78
+
79
+
80
+ def base_url_for(
81
+ region: Union[Region, str, None],
82
+ env: Union[Environment, str, None],
83
+ host: Optional[str],
84
+ ) -> str:
85
+ """The API base URL (scheme and authority, no path).
86
+
87
+ ``host`` may be a bare hostname (``demo.stxapp.io``, ``localhost:4000``),
88
+ which gets ``https://``, or a URL (``http://localhost:4000``) whose
89
+ scheme is kept. Any path on the URL is ignored: the API lives at
90
+ ``/api/v1`` on every host.
91
+ """
92
+ if host:
93
+ host = host.strip()
94
+ if host.startswith(("http://", "https://")):
95
+ parsed = urlparse(host)
96
+ if not parsed.netloc:
97
+ raise STXConfigException(f"Cannot parse host {host!r}.")
98
+ return f"{parsed.scheme}://{parsed.netloc}"
99
+ return f"https://{host.rstrip('/')}"
100
+ return f"https://{resolve_host(region, env)}"
101
+
102
+
103
+ def socket_url_for(base_url: str) -> str:
104
+ """The WebSocket URL for an API base URL: https maps to wss, http to ws."""
105
+ parsed = urlparse(base_url)
106
+ scheme = "ws" if parsed.scheme == "http" else "wss"
107
+ return f"{scheme}://{parsed.netloc}{SOCKET_PATH}?vsn=2.0.0"