synpath 0.1.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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/server/api.py ADDED
@@ -0,0 +1,439 @@
1
+ """The HTTP surface over the library. No authentication, on purpose.
2
+
3
+ This app is the *inner* app. It knows how to turn a request into a library
4
+ call and a library exception into a status code, and nothing else — no API
5
+ keys, no quotas, no metering, no cross-venue logic. A hosted deployment mounts
6
+ it under its own middleware and owns all of that:
7
+
8
+ ```python
9
+ from fastapi import FastAPI, Depends
10
+ from synpath.server import create_app, VenueRegistry
11
+
12
+ outer = FastAPI()
13
+ outer.state.registry = VenueRegistry() # optional; a default is used otherwise
14
+ outer.include_router(create_app(docs=False).router, dependencies=[Depends(my_auth)])
15
+ ```
16
+
17
+ Note the registry on the host app. A mounted route is served by the host, so
18
+ `request.app` is the host's app and that is where the registry is looked for.
19
+
20
+ Routes are written out one by one rather than dispatched dynamically from a
21
+ method name. Dynamic dispatch is less code and produces an OpenAPI document
22
+ that says every response is `object`, which makes generated clients untyped —
23
+ and a typed TypeScript client generated from this schema is the whole reason
24
+ the server layer exists.
25
+
26
+ Handlers are `def`, not `async def`. The library is synchronous, so Starlette
27
+ runs them in a worker thread; writing them `async` would block the event loop
28
+ on every venue call.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import threading
34
+ from contextlib import asynccontextmanager
35
+ from typing import Literal, Annotated, Any, Iterator
36
+
37
+ from fastapi import Depends, FastAPI, HTTPException, Path, Query, Request
38
+ from fastapi.responses import JSONResponse
39
+
40
+ import synpath
41
+ from synpath import (
42
+ BadRequest,
43
+ Candle,
44
+ Event,
45
+ Exchange,
46
+ ExchangeError,
47
+ ExchangeNotAvailable,
48
+ FeeSchedule,
49
+ Market,
50
+ MarketNotFound,
51
+ NetworkError,
52
+ NotSupported,
53
+ OrderBook,
54
+ RateLimitExceeded,
55
+ RequestTimeout,
56
+ Series,
57
+ SynpathError,
58
+ Trade,
59
+ )
60
+
61
+ from ..base import timeframe_seconds
62
+ from .errors import http_error, install_error_handlers
63
+ from .models import ErrorBody, PageResponse, VenueInfo
64
+
65
+ log = logging.getLogger("synpath.server")
66
+
67
+ STATUS_FOR: dict[type[Exception], int] = {
68
+ MarketNotFound: 404,
69
+ BadRequest: 400,
70
+ NotSupported: 501,
71
+ RateLimitExceeded: 429,
72
+ RequestTimeout: 504,
73
+ ExchangeNotAvailable: 502,
74
+ NetworkError: 502,
75
+ ExchangeError: 502,
76
+ }
77
+ """Library exception to status code.
78
+
79
+ The two that are worth explaining: `NotSupported` is 501 rather than 404,
80
+ because the venue has no such capability at all and no id would have worked;
81
+ and a venue failing us is 502, not 500, because this service is fine and is
82
+ reporting on an upstream that is not.
83
+ """
84
+
85
+
86
+ def _status_for(exc: Exception) -> int:
87
+ for kind in type(exc).__mro__:
88
+ if kind in STATUS_FOR:
89
+ return STATUS_FOR[kind]
90
+ return 500
91
+
92
+
93
+ def _venue_of(exc: Exception) -> str | None:
94
+ text = str(exc)
95
+ head = text.split(":", 1)[0].strip().lower()
96
+ return head if head in synpath.exchanges else None
97
+
98
+
99
+ class VenueRegistry:
100
+ """One adapter instance per venue, reused across requests.
101
+
102
+ Reused rather than constructed per request because the rate limiter and
103
+ the HTTP connection pool both have to be shared: a venue counts requests
104
+ per account and IP, so a fresh client per request would hand every request
105
+ a full token bucket and sail straight past the limit.
106
+
107
+ Nothing request-scoped is kept on an adapter — paging cursors ride on the
108
+ returned `Page` — so sharing one across threads is safe.
109
+ """
110
+
111
+ def __init__(self, exchanges: dict[str, Exchange] | None = None):
112
+ self._exchanges: dict[str, Exchange] = exchanges or {}
113
+ # Handlers are sync `def`, so Starlette runs them in a thread pool and
114
+ # two requests really do race here. Without this, both would build an
115
+ # adapter, one would be dropped still holding an open connection pool,
116
+ # and for that moment two rate limiters would each think they owned the
117
+ # venue's whole budget.
118
+ self._lock = threading.Lock()
119
+
120
+ def get(self, venue: str) -> Exchange:
121
+ if venue not in synpath.exchanges:
122
+ raise http_error(
123
+ 404, "unknown_venue",
124
+ f"unknown venue {venue!r}; available: {', '.join(sorted(synpath.exchanges))}",
125
+ )
126
+ existing = self._exchanges.get(venue)
127
+ if existing is not None:
128
+ return existing
129
+ with self._lock:
130
+ # Checked again under the lock: another thread may have built it
131
+ # while this one waited.
132
+ if venue not in self._exchanges:
133
+ self._exchanges[venue] = synpath.exchange(venue)
134
+ return self._exchanges[venue]
135
+
136
+ def close(self) -> None:
137
+ with self._lock:
138
+ for exchange in self._exchanges.values():
139
+ exchange.close()
140
+ self._exchanges.clear()
141
+
142
+
143
+ DEFAULT_REGISTRY = VenueRegistry()
144
+ """Used when a request arrives on an app that carries no registry of its own —
145
+ which is what happens when the router is mounted on somebody else's app."""
146
+
147
+
148
+ def get_venue(
149
+ request: Request,
150
+ venue: Annotated[str, Path(description="Venue id, e.g. `kalshi`.")],
151
+ ) -> Exchange:
152
+ """Resolve the adapter for this request.
153
+
154
+ The registry is looked up on the app handling the request, so a host that
155
+ mounts this router and wants its own registry sets it on its own app:
156
+
157
+ ```python
158
+ outer.state.registry = VenueRegistry()
159
+ outer.include_router(create_app().router)
160
+ ```
161
+
162
+ Without that, requests fall back to the process-wide default, which is the
163
+ right answer for the standalone case and harmless for the mounted one.
164
+ """
165
+ registry = getattr(request.app.state, "registry", None) or DEFAULT_REGISTRY
166
+ return registry.get(venue)
167
+
168
+
169
+ Venue = Annotated[Exchange, Depends(get_venue)]
170
+ """Module level on purpose: `from __future__ import annotations` turns every
171
+ annotation into a string, and FastAPI resolves those against module globals.
172
+ Defined inside the factory it would be invisible and the app would fail to
173
+ build its schema."""
174
+
175
+ RESPONSES: dict[int | str, dict[str, Any]] = {
176
+ 400: {"model": ErrorBody, "description": "Invalid parameters."},
177
+ 404: {"model": ErrorBody, "description": "No such venue or market."},
178
+ 429: {"model": ErrorBody, "description": "Venue rate limit; retry after backing off."},
179
+ 501: {"model": ErrorBody, "description": "This venue does not offer the capability."},
180
+ 502: {"model": ErrorBody, "description": "The venue failed or rejected the request."},
181
+ 504: {"model": ErrorBody, "description": "The venue did not answer in time."},
182
+ }
183
+
184
+
185
+ def create_app(
186
+ *,
187
+ registry: VenueRegistry | None = None,
188
+ title: str = "synpath",
189
+ docs: bool = True,
190
+ ) -> FastAPI:
191
+ """Build the app. Pass `registry` to inject stub adapters in tests."""
192
+ registry = registry or DEFAULT_REGISTRY
193
+
194
+ @asynccontextmanager
195
+ async def lifespan(_: FastAPI):
196
+ yield
197
+ # Closes the registry this app was built with, not the one belonging to
198
+ # whichever app runs the lifespan: `include_router` merges lifespan
199
+ # contexts, so a host mounting this router runs this function with its
200
+ # own app object, which has no registry of ours on it.
201
+ registry.close()
202
+
203
+ app = FastAPI(
204
+ lifespan=lifespan,
205
+ title=title,
206
+ version=synpath.__version__,
207
+ summary="One API for prediction markets.",
208
+ description=(
209
+ "Read-only market data for Kalshi and Polymarket behind one "
210
+ "interface.\n\n"
211
+ "Prices are labelled by source: `bid`, `ask`, `mid` and `last` are "
212
+ "separate fields, `mid` is null unless both sides are quoted, and a "
213
+ "null price means the venue published none rather than zero. "
214
+ "Candles carry `price_source`; volume figures carry `volume_unit`, "
215
+ "because Kalshi counts contracts and Polymarket counts collateral.\n\n"
216
+ "Call `GET /venues` first: `has` says which capabilities a venue "
217
+ "offers, and an unsupported one answers 501."
218
+ ),
219
+ docs_url="/docs" if docs else None,
220
+ redoc_url=None,
221
+ openapi_url="/openapi.json" if docs else None,
222
+ )
223
+ app.state.registry = registry
224
+
225
+ # -- errors -------------------------------------------------------------
226
+
227
+ install_error_handlers(app, _status_for)
228
+
229
+ # -- discovery ----------------------------------------------------------
230
+
231
+ @app.get("/", tags=["meta"], summary="What this server is")
232
+ def index(request: Request) -> dict[str, Any]:
233
+ """A landing answer for someone who opens the address in a browser."""
234
+ links = {"docs": "/docs", "openapi": "/openapi.json", "health": "/health", "venues": "/venues"}
235
+ trading = getattr(request.app.state, "trading_path", None)
236
+ if trading:
237
+ links["trading"] = trading
238
+ return {"name": "synpath", "version": synpath.__version__, "links": links}
239
+
240
+ @app.get("/health", tags=["meta"], summary="Liveness")
241
+ def health() -> dict[str, str]:
242
+ """Answers without touching a venue, so it stays useful when one is down."""
243
+ return {"status": "ok", "version": synpath.__version__}
244
+
245
+ @app.get("/venues", tags=["meta"], summary="List venues and capabilities")
246
+ def list_venues() -> list[VenueInfo]:
247
+ """Read this before anything else. `has` tells you which calls a venue
248
+ answers and which it will refuse with a 501."""
249
+ return [
250
+ VenueInfo(
251
+ id=cls.id, name=cls.name, book_model=cls.book_model, has=dict(cls.has),
252
+ )
253
+ for cls in synpath.exchanges.values()
254
+ ]
255
+
256
+ @app.get("/venues/{venue}", tags=["meta"], summary="One venue", responses=RESPONSES)
257
+ def get_venue_info(exchange: Venue) -> VenueInfo:
258
+ return VenueInfo(
259
+ id=exchange.id, name=exchange.name,
260
+ book_model=exchange.book_model, has=dict(exchange.has),
261
+ )
262
+
263
+ # -- catalog ------------------------------------------------------------
264
+
265
+ @app.get(
266
+ "/venues/{venue}/markets", tags=["catalog"],
267
+ summary="List markets", responses=RESPONSES,
268
+ )
269
+ def list_markets(
270
+ exchange: Venue,
271
+ query: Annotated[str | None, Query(description=(
272
+ "Text filter. Server-side on Polymarket; on Kalshi the page is "
273
+ "filtered locally, so a page may come back short while "
274
+ "`next_cursor` still points at more."
275
+ ))] = None,
276
+ limit: Annotated[int, Query(ge=1, le=100)] = 100,
277
+ cursor: Annotated[str | None, Query(description=(
278
+ "Opaque cursor from a previous `next_cursor`. There is no `offset`: "
279
+ "the catalog changes between calls and offset paging silently drops "
280
+ "or repeats rows when it does."
281
+ ))] = None,
282
+ status: Annotated[str, Query(pattern="^(open|closed|settled|all)$")] = "open",
283
+ sort: Annotated[str | None, Query(description=(
284
+ "`volume`, `liquidity` or `newest`. Any other value is refused with 400, and a venue that cannot "
285
+ "sort answers 501 rather than returning unsorted rows."
286
+ ))] = None,
287
+ ) -> PageResponse[Market]:
288
+ page = exchange.fetch_markets(
289
+ query=query, limit=limit, cursor=cursor, status=status, sort=sort,
290
+ )
291
+ return PageResponse[Market](
292
+ data=list(page), next_cursor=page.next_cursor, count=len(page),
293
+ )
294
+
295
+ @app.get(
296
+ "/venues/{venue}/markets/{market_id}", tags=["catalog"],
297
+ summary="One market", responses=RESPONSES,
298
+ )
299
+ def get_market(
300
+ exchange: Venue,
301
+ market_id: Annotated[str, Path(description=(
302
+ "Venue market id: a Kalshi ticker, or a Polymarket numeric id. "
303
+ "No path converter here on purpose — a greedy one would swallow "
304
+ "the `/trades` and `/fee` suffixes below."
305
+ ))],
306
+ ) -> Market:
307
+ return exchange.fetch_market(market_id)
308
+
309
+ @app.get(
310
+ "/venues/{venue}/events", tags=["catalog"],
311
+ summary="List events with markets nested", responses=RESPONSES,
312
+ )
313
+ def list_events(
314
+ exchange: Venue,
315
+ query: Annotated[str | None, Query()] = None,
316
+ limit: Annotated[int, Query(ge=1, le=100)] = 100,
317
+ cursor: Annotated[str | None, Query()] = None,
318
+ status: Annotated[str, Query(pattern="^(open|closed|settled|all)$")] = "open",
319
+ ) -> PageResponse[Event]:
320
+ page = exchange.fetch_events(
321
+ query=query, limit=limit, cursor=cursor, status=status,
322
+ )
323
+ return PageResponse[Event](
324
+ data=list(page), next_cursor=page.next_cursor, count=len(page),
325
+ )
326
+
327
+ # -- market data --------------------------------------------------------
328
+
329
+ @app.get(
330
+ "/venues/{venue}/markets/{market_id}/book", tags=["market data"],
331
+ summary="Order book for one side of a market", responses=RESPONSES,
332
+ )
333
+ def get_order_book(
334
+ exchange: Venue,
335
+ market_id: Annotated[str, Path(description=(
336
+ "A Synpath id (`kalshi:KXFOO-25`, `polymarket:2252244`) or the "
337
+ "venue's own id for this venue."
338
+ ))],
339
+ side: Annotated[Literal["yes", "no"], Query(description=(
340
+ "Which side the book is priced for. `no` is what NO costs."
341
+ ))] = "yes",
342
+ depth: Annotated[int | None, Query(ge=1, le=1000)] = None,
343
+ ) -> OrderBook:
344
+ """Bids descend, asks ascend, best first on both sides.
345
+
346
+ On Kalshi and Polymarket US one book serves both sides, so the NO
347
+ view is the YES book reflected through the market's face value and
348
+ the response says so with `derived: true`. On Polymarket the NO book
349
+ is a real book of its own.
350
+ """
351
+ return exchange.fetch_order_book(market_id, side=side, depth=depth)
352
+
353
+ @app.get(
354
+ "/venues/{venue}/markets/{market_id}/trades", tags=["market data"],
355
+ summary="Recent trades", responses=RESPONSES,
356
+ )
357
+ def list_trades(
358
+ exchange: Venue,
359
+ market_id: str,
360
+ since: Annotated[int | None, Query(description="Milliseconds since epoch, inclusive.")] = None,
361
+ limit: Annotated[int, Query(ge=1, le=100)] = 100,
362
+ cursor: Annotated[str | None, Query()] = None,
363
+ ) -> PageResponse[Trade]:
364
+ """Oldest first, in the YES price. `side` is what the taker did on the
365
+ YES leg: `buy` took YES, `sell` took NO."""
366
+ page = exchange.fetch_trades(market_id, since=since, limit=limit, cursor=cursor)
367
+ return PageResponse[Trade](
368
+ data=list(page), next_cursor=page.next_cursor, count=len(page),
369
+ )
370
+
371
+ @app.get(
372
+ "/venues/{venue}/markets/{market_id}/candles", tags=["market data"],
373
+ summary="OHLCV candles, in the YES price", responses=RESPONSES,
374
+ )
375
+ def list_candles(
376
+ exchange: Venue,
377
+ market_id: str,
378
+ timeframe: Annotated[str, Query(description=(
379
+ "Kalshi accepts `1m`, `1h`, `1d` only and refuses anything else "
380
+ "rather than rounding to a period you did not ask for."
381
+ ))] = "1h",
382
+ since: Annotated[int | None, Query(description=(
383
+ "Milliseconds since epoch. With it, bars are read forward from here and the "
384
+ "first `limit` returned; without it, the newest `limit` bars."
385
+ ))] = None,
386
+ until: Annotated[int | None, Query(description="Milliseconds since epoch.")] = None,
387
+ limit: Annotated[int, Query(ge=1, le=1000)] = 100,
388
+ cursor: Annotated[str | None, Query(description=(
389
+ "`next_cursor` from the previous page of a read with `since`; continues after its last bar."
390
+ ))] = None,
391
+ ) -> PageResponse[Candle]:
392
+ """Read `price_source` on every bar before using it.
393
+
394
+ `trade` means executions. `bid_ask_mid` means nothing traded in that
395
+ period and the bar is the book's midpoint. `sampled_mid` means the
396
+ venue publishes no candles at all and these were bucketed from price
397
+ samples — those carry `volume: null`, because null is not zero.
398
+
399
+ A long history is paged forward: pass `since`, then each
400
+ `next_cursor` until it is null. Every page costs the venue only the
401
+ requests that page needs.
402
+ """
403
+ if cursor is not None:
404
+ if not cursor.isdigit():
405
+ raise http_error(400, "validation_error", "cursor: not a cursor from this endpoint", field="cursor")
406
+ since = int(cursor)
407
+ seconds = timeframe_seconds(timeframe)
408
+ candles = exchange.fetch_ohlcv(
409
+ market_id, timeframe=timeframe, since=since, until=until, limit=limit,
410
+ )
411
+ more = since is not None and len(candles) == limit
412
+ next_cursor = str(candles[-1].timestamp + seconds * 1000) if more else None
413
+ return PageResponse[Candle](data=list(candles), next_cursor=next_cursor, count=len(candles))
414
+
415
+ # -- reference ----------------------------------------------------------
416
+
417
+ @app.get(
418
+ "/venues/{venue}/markets/{market_id}/fee", tags=["reference"],
419
+ summary="Fee schedule, before trading", responses=RESPONSES,
420
+ )
421
+ def get_fee_schedule(exchange: Venue, market_id: str) -> FeeSchedule:
422
+ """What trading this market costs. Kalshi keys fees on the series above
423
+ the market, so this resolves that for you."""
424
+ return exchange.fetch_fee_schedule(market_id)
425
+
426
+ @app.get(
427
+ "/venues/{venue}/series/{series_id}", tags=["reference"],
428
+ summary="One series", responses=RESPONSES,
429
+ )
430
+ def get_series(exchange: Venue, series_id: str) -> Series:
431
+ """Kalshi only. Polymarket has no series tier and answers 501."""
432
+ return exchange.fetch_series(series_id)
433
+
434
+ return app
435
+
436
+
437
+ def venues_dependency(app: FastAPI) -> Iterator[VenueRegistry]:
438
+ """The registry an app is using, for a host that wants to reach into it."""
439
+ yield app.state.registry
@@ -0,0 +1,87 @@
1
+ """One error shape for every route: `{"error": {"code", "message", "details"}}`.
2
+
3
+ Both apps install the same handlers, so a client writes one error path. The
4
+ `code` is derived from the library's exception (`InsufficientFunds` becomes
5
+ `insufficient_funds`), so an HTTP caller branches on the same vocabulary an
6
+ in-process caller catches; errors the HTTP layer raises itself (no token, no
7
+ permission, a malformed body) get codes from their status.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from typing import Any, Callable
14
+
15
+ from fastapi import FastAPI, HTTPException, Request
16
+ from fastapi.exceptions import RequestValidationError
17
+ from fastapi.responses import JSONResponse
18
+ from starlette.exceptions import HTTPException as StarletteHTTPException
19
+
20
+ from ..errors import NetworkError, SynpathError
21
+ from .models import ErrorBody, ErrorDetail
22
+
23
+ log = logging.getLogger("synpath.server")
24
+
25
+ STATUS_CODES: dict[int, str] = {
26
+ 400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found", 405: "method_not_allowed",
27
+ 409: "conflict", 422: "validation_error", 429: "rate_limited", 500: "internal_error", 501: "not_supported",
28
+ 502: "venue_error", 503: "unavailable", 504: "venue_timeout",
29
+ }
30
+
31
+
32
+ def code_of(exc: BaseException) -> str:
33
+ """`InsufficientFunds` -> `insufficient_funds`."""
34
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", type(exc).__name__).lower()
35
+
36
+
37
+ def error_body(code: str, message: str, **details: Any) -> dict[str, Any]:
38
+ details.setdefault("venue", None)
39
+ details.setdefault("retryable", False)
40
+ return ErrorBody(error=ErrorDetail(code=code, message=message, details=details)).model_dump()
41
+
42
+
43
+ def http_error(status: int, code: str, message: str, **details: Any) -> HTTPException:
44
+ """An HTTPException that carries its own code, for routes that know better
45
+ than the status alone (`unknown_venue` rather than `not_found`)."""
46
+ return HTTPException(status_code=status, detail={"code": code, "message": message, "details": details})
47
+
48
+
49
+ def install_error_handlers(app: FastAPI, status_for: Callable[[Exception], int]) -> None:
50
+ @app.exception_handler(SynpathError)
51
+ async def _on_synpath_error(request: Request, exc: SynpathError) -> JSONResponse:
52
+ status = status_for(exc)
53
+ if status >= 500 and status != 501:
54
+ log.warning("%s %s -> %s: %s", request.method, request.url.path, status, exc)
55
+ from .api import _venue_of
56
+
57
+ details: dict[str, Any] = {
58
+ "venue": getattr(exc, "synpath_venue", None) or _venue_of(exc),
59
+ "retryable": isinstance(exc, NetworkError),
60
+ }
61
+ for name in ("rule", "reason"):
62
+ value = getattr(exc, name, None)
63
+ if value:
64
+ details[name] = value
65
+ headers = {}
66
+ retry_after = getattr(exc, "retry_after", None)
67
+ if retry_after:
68
+ details["retry_after"] = retry_after
69
+ headers["Retry-After"] = str(int(retry_after))
70
+ return JSONResponse(status_code=status, content=error_body(code_of(exc), str(exc), **details),
71
+ headers=headers)
72
+
73
+ @app.exception_handler(StarletteHTTPException)
74
+ async def _on_http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
75
+ detail = exc.detail
76
+ if isinstance(detail, dict) and "code" in detail:
77
+ body = error_body(detail["code"], str(detail.get("message", "")), **(detail.get("details") or {}))
78
+ else:
79
+ body = error_body(STATUS_CODES.get(exc.status_code, "error"), str(detail))
80
+ return JSONResponse(status_code=exc.status_code, content=body, headers=getattr(exc, "headers", None))
81
+
82
+ @app.exception_handler(RequestValidationError)
83
+ async def _on_invalid(request: Request, exc: RequestValidationError) -> JSONResponse:
84
+ errors = [{"field": ".".join(str(part) for part in e.get("loc", ()) if part != "body"),
85
+ "message": e.get("msg", "")} for e in exc.errors()]
86
+ summary = "; ".join(f"{e['field']}: {e['message']}" for e in errors) or "the request is not valid"
87
+ return JSONResponse(status_code=422, content=error_body("validation_error", summary, errors=errors))
@@ -0,0 +1,96 @@
1
+ """Where a self-hosted server leaves its access token for clients on the same machine.
2
+
3
+ A `synpath serve` on your own machine should need no token from you: the
4
+ server makes one on first start and the library finds it. So the server
5
+ records `{host:port -> access token, control database, pid}` in a file only your user
6
+ can read (`~/.synpath/servers.json`, mode 0600), and `synpath.Client(server=
7
+ "http://127.0.0.1:8000")` reads the token from there when the address is
8
+ loopback. A server reached over the network is a different matter: nothing
9
+ is looked up, and the caller passes the token (`access_token=`, or
10
+ `SYNPATH_ACCESS_TOKEN`), which the server printed once when it made it.
11
+
12
+ The secret is written only when it is created, because the control database
13
+ keeps a hash and cannot give it back. Deleting the file loses the local
14
+ copy; `synpath bootstrap` makes a new owner access token.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import stat
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ LOOPBACK = {"127.0.0.1", "localhost", "::1", "[::1]"}
27
+ ENV_KEY = "SYNPATH_ACCESS_TOKEN"
28
+ ENV_HOME = "SYNPATH_HOME"
29
+
30
+
31
+ def home(override: str | None = None) -> Path:
32
+ return Path(override or os.environ.get(ENV_HOME) or (Path.home() / ".synpath"))
33
+
34
+
35
+ def registry_path(override: str | None = None) -> Path:
36
+ return home(override) / "servers.json"
37
+
38
+
39
+ def is_loopback(url: str) -> bool:
40
+ host = urlsplit(url if "://" in url else f"http://{url}").hostname
41
+ return host in LOOPBACK
42
+
43
+
44
+ def address(host: str, port: int) -> str:
45
+ """One name for every way of saying this machine, so a server started on
46
+ `localhost` is found by a client that says `127.0.0.1`."""
47
+ host = host.strip("[]")
48
+ if host in ("0.0.0.0", "", "::") or host in LOOPBACK:
49
+ host = "127.0.0.1"
50
+ return f"{host}:{port}"
51
+
52
+
53
+ def _read(path: Path) -> dict[str, Any]:
54
+ try:
55
+ return json.loads(path.read_text())
56
+ except (FileNotFoundError, json.JSONDecodeError):
57
+ return {}
58
+
59
+
60
+ def _write(path: Path, data: dict[str, Any]) -> None:
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ os.chmod(path.parent, stat.S_IRWXU)
63
+ tmp = path.with_suffix(".tmp")
64
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
65
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
66
+ tmp.replace(path)
67
+
68
+
69
+ def remember(host: str, port: int, *, control: str, journal: str, key: str | None, home_dir: str | None = None) -> str | None:
70
+ """Record this server. `key` is the secret just made, or `None` on a
71
+ later start, when the earlier record's token is kept if it was for the
72
+ same control database. Returns the token on record, if any."""
73
+ path = registry_path(home_dir)
74
+ data = _read(path)
75
+ name = address(host, port)
76
+ previous = data.get(name) or {}
77
+ kept = key or (previous.get("access_token") if previous.get("control") == str(control) else None)
78
+ data[name] = {"access_token": kept, "control": str(control), "journal": str(journal), "pid": os.getpid(),
79
+ "started_ms": int(time.time() * 1000)}
80
+ _write(path, data)
81
+ return kept
82
+
83
+
84
+ def lookup(server: str, *, home_dir: str | None = None) -> str | None:
85
+ """The stored access token for a loopback server address, or `None`."""
86
+ if not is_loopback(server):
87
+ return None
88
+ parts = urlsplit(server if "://" in server else f"http://{server}")
89
+ name = address(parts.hostname or "127.0.0.1", parts.port or (443 if parts.scheme == "https" else 80))
90
+ entry = _read(registry_path(home_dir)).get(name) or {}
91
+ return entry.get("access_token") or None
92
+
93
+
94
+ def resolve_key(server: str, explicit: str | None = None, *, home_dir: str | None = None) -> str | None:
95
+ """Explicit, then the environment, then the local registry for loopback."""
96
+ return explicit or os.environ.get(ENV_KEY) or lookup(server, home_dir=home_dir)