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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/remote.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Order entry through your own `synpath serve`, with the `Client` method names.
|
|
2
|
+
|
|
3
|
+
`synpath.Client(server="http://127.0.0.1:8000")` sends every trading call to
|
|
4
|
+
that server's `/trading` routes instead of to the venues, which is what
|
|
5
|
+
makes the engine's order types reachable from Python: a stop, an iceberg,
|
|
6
|
+
an order on a bucket. Market data stays direct. The server holds the venue
|
|
7
|
+
credentials; this side holds only the server's access token, found in the local
|
|
8
|
+
registry for a loopback address (`synpath.server.local`) or given.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import AuthenticationError, BadRequest, ExchangeError, NetworkError, NotSupported
|
|
18
|
+
from .server.local import resolve_key
|
|
19
|
+
from .trading.errors import InsufficientFunds, OrderNotFound, RiskRejected
|
|
20
|
+
from .bucket import BUCKET_PREFIX, Bucket, BucketMember, BucketOrderReport, BucketPosition
|
|
21
|
+
from .trading.types import Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement, Side
|
|
22
|
+
from .types import Page
|
|
23
|
+
|
|
24
|
+
TRADING_PATH = "/trading"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RemoteTrading:
|
|
28
|
+
"""The trading half of `Client`, over HTTP to a self-hosted server."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, server: str, key: str | None = None, *, http_client: Any = None,
|
|
31
|
+
home_dir: str | None = None, timeout: float = 30.0):
|
|
32
|
+
base = server.rstrip("/")
|
|
33
|
+
self.base = base if base.endswith(TRADING_PATH) else base + TRADING_PATH
|
|
34
|
+
self.server = server
|
|
35
|
+
self.key = resolve_key(server, key, home_dir=home_dir)
|
|
36
|
+
self._http = http_client
|
|
37
|
+
self._owned = http_client is None
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
|
|
40
|
+
# -- transport ------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _headers(self) -> dict[str, str]:
|
|
43
|
+
if not self.key:
|
|
44
|
+
raise AuthenticationError(
|
|
45
|
+
f"no access token for {self.server}: pass access_token=, set SYNPATH_ACCESS_TOKEN, or, for a server on this "
|
|
46
|
+
"machine, start it with `synpath serve` so its token is in ~/.synpath/servers.json"
|
|
47
|
+
)
|
|
48
|
+
return {"Authorization": f"Bearer {self.key}"}
|
|
49
|
+
|
|
50
|
+
async def _call(self, method: str, path: str, *, params: dict[str, Any] | None = None,
|
|
51
|
+
json: Any = None) -> Any:
|
|
52
|
+
if self._http is None:
|
|
53
|
+
self._http = httpx.AsyncClient(timeout=self.timeout)
|
|
54
|
+
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
|
55
|
+
try:
|
|
56
|
+
response = await self._http.request(method, self.base + path, params=clean, json=json, headers=self._headers())
|
|
57
|
+
except httpx.HTTPError as exc:
|
|
58
|
+
raise NetworkError(f"{method} {path}: {exc}") from exc
|
|
59
|
+
if response.status_code >= 400:
|
|
60
|
+
raise self._error(response, method, path)
|
|
61
|
+
return response.json() if response.content else None
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _error(response: httpx.Response, method: str, path: str) -> Exception:
|
|
65
|
+
"""The server's `{"error": {"code", "message", "details"}}` as the
|
|
66
|
+
exception an in-process caller would have caught."""
|
|
67
|
+
try:
|
|
68
|
+
error = (response.json() or {}).get("error") or {}
|
|
69
|
+
except ValueError:
|
|
70
|
+
error = {}
|
|
71
|
+
code = str(error.get("code") or "")
|
|
72
|
+
message = str(error.get("message") or response.text or response.reason_phrase)
|
|
73
|
+
details = error.get("details") or {}
|
|
74
|
+
status = response.status_code
|
|
75
|
+
if status in (401, 403):
|
|
76
|
+
return AuthenticationError(message)
|
|
77
|
+
if status == 404:
|
|
78
|
+
return OrderNotFound(message)
|
|
79
|
+
if code == "risk_rejected" or status == 409:
|
|
80
|
+
return RiskRejected(message, rule=str(details.get("rule") or code or "rejected"))
|
|
81
|
+
if code == "insufficient_funds":
|
|
82
|
+
return InsufficientFunds(message)
|
|
83
|
+
if status in (400, 422):
|
|
84
|
+
return BadRequest(message)
|
|
85
|
+
if details.get("retryable"):
|
|
86
|
+
return NetworkError(f"{method} {path} -> {status}: {message}")
|
|
87
|
+
return ExchangeError(f"{method} {path} -> {status}: {message}")
|
|
88
|
+
|
|
89
|
+
# -- orders ---------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def create_order(self, request: OrderRequest) -> Order:
|
|
92
|
+
return Order.model_validate(await self._call("POST", "/orders", json=request.model_dump(mode="json")))
|
|
93
|
+
|
|
94
|
+
async def create_bucket(self, *, book: str, name: str, members: list[BucketMember]) -> Bucket:
|
|
95
|
+
body = {"book": book, "name": name, "members": [m.model_dump(mode="json") for m in members]}
|
|
96
|
+
return Bucket.model_validate(await self._call("POST", "/buckets", json=body))
|
|
97
|
+
|
|
98
|
+
async def fetch_buckets(self, *, book: str | None = None, status: str = "active") -> list[Bucket]:
|
|
99
|
+
page = await self._call("GET", "/buckets", params={"book": book, "status": status})
|
|
100
|
+
return [Bucket.model_validate(row) for row in page.get("data", [])]
|
|
101
|
+
|
|
102
|
+
async def fetch_bucket(self, bucket_id: str) -> Bucket:
|
|
103
|
+
return Bucket.model_validate(await self._call("GET", f"/buckets/{_bare(bucket_id)}"))
|
|
104
|
+
|
|
105
|
+
async def archive_bucket(self, bucket_id: str) -> Bucket:
|
|
106
|
+
return Bucket.model_validate(await self._call("DELETE", f"/buckets/{_bare(bucket_id)}"))
|
|
107
|
+
|
|
108
|
+
async def fetch_bucket_position(self, bucket_id: str, *, book: str | None = None) -> BucketPosition:
|
|
109
|
+
return BucketPosition.model_validate(
|
|
110
|
+
await self._call("GET", f"/buckets/{_bare(bucket_id)}/position", params={"book": book}))
|
|
111
|
+
|
|
112
|
+
async def fetch_bucket_orders(self, bucket_id: str) -> list[BucketOrderReport]:
|
|
113
|
+
page = await self._call("GET", f"/buckets/{_bare(bucket_id)}/orders")
|
|
114
|
+
return [BucketOrderReport.model_validate(row) for row in page.get("data", [])]
|
|
115
|
+
|
|
116
|
+
async def fetch_bucket_order(self, bucket_id: str, order_id: str) -> BucketOrderReport:
|
|
117
|
+
return BucketOrderReport.model_validate(
|
|
118
|
+
await self._call("GET", f"/buckets/{_bare(bucket_id)}/orders/{order_id}"))
|
|
119
|
+
|
|
120
|
+
async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
|
|
121
|
+
out: list[Order | Exception] = []
|
|
122
|
+
for request in requests:
|
|
123
|
+
try:
|
|
124
|
+
out.append(await self.create_order(request))
|
|
125
|
+
except Exception as exc: # one refusal does not stop the rest, as the venue adapters behave
|
|
126
|
+
out.append(exc)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
async def cancel_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
|
|
130
|
+
return Order.model_validate(await self._call("DELETE", f"/orders/{order_id}"))
|
|
131
|
+
|
|
132
|
+
async def cancel_all_orders(self, *, market_id: str | None = None, venue: str | None = None) -> int | None:
|
|
133
|
+
count = 0
|
|
134
|
+
for order in await self.fetch_open_orders(market_id=market_id, venue=venue):
|
|
135
|
+
await self.cancel_order(order.id)
|
|
136
|
+
count += 1
|
|
137
|
+
return count
|
|
138
|
+
|
|
139
|
+
async def edit_order(self, request: EditRequest, *, venue: str | None = None, current: Order | None = None) -> Order:
|
|
140
|
+
body = request.model_dump(mode="json", exclude_none=True)
|
|
141
|
+
return Order.model_validate(await self._call("PATCH", f"/orders/{request.order_id}", json=body))
|
|
142
|
+
|
|
143
|
+
async def fetch_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
|
|
144
|
+
return Order.model_validate(await self._call("GET", f"/orders/{order_id}"))
|
|
145
|
+
|
|
146
|
+
async def fetch_open_orders(self, *, market_id: str | None = None, venue: str | None = None) -> list[Order]:
|
|
147
|
+
page = await self._call("GET", "/orders", params={"venue": venue})
|
|
148
|
+
orders = [Order.model_validate(row) for row in page.get("data", [])]
|
|
149
|
+
return [o for o in orders if market_id is None or o.market_id == market_id]
|
|
150
|
+
|
|
151
|
+
# -- portfolio ------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
async def fetch_my_trades(self, *, market_id: str | None = None, venue: str | None = None,
|
|
154
|
+
since: int | None = None, limit: int | None = None, cursor: str | None = None) -> Page[Fill]:
|
|
155
|
+
page = await self._call("GET", "/fills", params={"since": since, "venue": venue})
|
|
156
|
+
fills = [Fill.model_validate(row) for row in page.get("data", [])]
|
|
157
|
+
if market_id is not None:
|
|
158
|
+
fills = [f for f in fills if f.market_id == market_id]
|
|
159
|
+
if limit is not None:
|
|
160
|
+
fills = fills[:limit]
|
|
161
|
+
return Page(fills, next_cursor=None)
|
|
162
|
+
|
|
163
|
+
async def fetch_positions(self, *, market_id: str | None = None, venue: str | None = None) -> list[Position]:
|
|
164
|
+
page = await self._call("GET", "/positions")
|
|
165
|
+
positions = [Position.model_validate(row) for row in page.get("data", [])]
|
|
166
|
+
return [p for p in positions
|
|
167
|
+
if (market_id is None or p.market_id == market_id) and (venue is None or p.venue == venue)]
|
|
168
|
+
|
|
169
|
+
async def fetch_balance(self, venue: str) -> Balance:
|
|
170
|
+
page = await self._call("GET", "/balances")
|
|
171
|
+
for row in page.get("data", []):
|
|
172
|
+
balance = Balance.model_validate(row)
|
|
173
|
+
if balance.venue == venue:
|
|
174
|
+
return balance
|
|
175
|
+
raise NotSupported(f"{self.server} reports no balance for {venue}: is that venue configured there?")
|
|
176
|
+
|
|
177
|
+
async def fetch_settlements(self, **_: Any) -> Page[Settlement]:
|
|
178
|
+
raise NotSupported("settlements are read from the venue, not through a synpath server; "
|
|
179
|
+
"use synpath.Client(load_credentials()) for them")
|
|
180
|
+
|
|
181
|
+
async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
|
|
182
|
+
raise NotSupported("fee estimates are read from the venue, not through a synpath server; "
|
|
183
|
+
"use synpath.Client(load_credentials()) or fetch_fee_schedule() for them")
|
|
184
|
+
|
|
185
|
+
# -- housekeeping ---------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def close(self) -> None:
|
|
188
|
+
if self._http is not None and self._owned:
|
|
189
|
+
await self._http.aclose()
|
|
190
|
+
self._http = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _bare(bucket_id: str) -> str:
|
|
194
|
+
"""A bucket's id, whether given as `<id>` or as its market id `bucket:<id>`."""
|
|
195
|
+
return bucket_id[len(BUCKET_PREFIX):] if bucket_id.startswith(BUCKET_PREFIX) else bucket_id
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""HTTP layer over the library. `create_app`, `create_trading_app`,
|
|
2
|
+
`VenueRegistry`, `ControlStore` and `Principal` are also exported from the
|
|
3
|
+
top-level package: `from synpath import create_app`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install synpath
|
|
7
|
+
python -m synpath.server # http://127.0.0.1:8000/docs
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Two ways to use it:
|
|
11
|
+
|
|
12
|
+
**Standalone**, as above, or with any ASGI server:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uvicorn synpath.server:app --port 8000
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Mounted**, when something else owns authentication, quotas and metering:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from fastapi import Depends, FastAPI
|
|
22
|
+
from synpath.server import create_app
|
|
23
|
+
|
|
24
|
+
outer = FastAPI()
|
|
25
|
+
outer.include_router(create_app(docs=False).router, dependencies=[Depends(my_auth)])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The read app has no auth of its own and never will — a service that ships
|
|
29
|
+
authentication inside the open-source core forces every host to work around
|
|
30
|
+
it. Everything cross-venue (matching the same question across exchanges,
|
|
31
|
+
routing an order) belongs outside too.
|
|
32
|
+
|
|
33
|
+
**Trading is a second app**, because placing an order does need to know who
|
|
34
|
+
is asking:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from synpath.server import create_trading_app, ControlStore
|
|
38
|
+
|
|
39
|
+
store = await ControlStore("control.db").open()
|
|
40
|
+
user, key = await store.bootstrap("owner") # once; the key is shown once
|
|
41
|
+
app = create_trading_app(engine, store)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
It carries per-key grants scoped to a subaccount (`view`, `trade`,
|
|
45
|
+
`manage_credentials`, `manage_members`), an append-only audit log the
|
|
46
|
+
database itself enforces, and `/ws/events`, which replays the engine's event
|
|
47
|
+
stream from a cursor before following it live. See
|
|
48
|
+
[docs/server.md](../docs/server.md).
|
|
49
|
+
|
|
50
|
+
`GET /openapi.json` is the contract. Generate a typed client from it rather
|
|
51
|
+
than hand-writing one:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @hey-api/openapi-ts -i http://127.0.0.1:8000/openapi.json -o ./src/synpath
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import importlib
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
__all__ = ["create_app", "VenueRegistry", "app", "main", "create_trading_app", "ControlStore", "Principal"]
|
|
63
|
+
|
|
64
|
+
_app = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def __getattr__(name: str) -> Any:
|
|
68
|
+
"""Import FastAPI lazily.
|
|
69
|
+
|
|
70
|
+
`synpath` itself must not require it: someone using the library in a
|
|
71
|
+
notebook should not be made to install a web framework. Resolving the
|
|
72
|
+
import here also means a missing dependency fails with a sentence saying
|
|
73
|
+
what to install, rather than a bare ImportError three frames down.
|
|
74
|
+
"""
|
|
75
|
+
if name not in __all__:
|
|
76
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
77
|
+
try:
|
|
78
|
+
# importlib rather than `from . import api`, which would look the name
|
|
79
|
+
# up on this package and re-enter this function forever.
|
|
80
|
+
api = importlib.import_module(f"{__name__}.api")
|
|
81
|
+
except ImportError as exc: # pragma: no cover - depends on env
|
|
82
|
+
raise ImportError(
|
|
83
|
+
"synpath.server needs FastAPI, which the base install carries. Reinstall with:\n"
|
|
84
|
+
" pip install synpath"
|
|
85
|
+
) from exc
|
|
86
|
+
if name == "app":
|
|
87
|
+
# One module-level instance, so `uvicorn synpath.server:app` works.
|
|
88
|
+
global _app
|
|
89
|
+
if _app is None:
|
|
90
|
+
_app = api.create_app()
|
|
91
|
+
return _app
|
|
92
|
+
if name == "main":
|
|
93
|
+
return importlib.import_module(f"{__name__}.__main__").main
|
|
94
|
+
if name == "create_trading_app":
|
|
95
|
+
return importlib.import_module(f"{__name__}.trading").create_trading_app
|
|
96
|
+
if name in ("ControlStore", "Principal"):
|
|
97
|
+
return getattr(importlib.import_module(f"{__name__}.store"), name)
|
|
98
|
+
return getattr(api, name)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""`python -m synpath.server` — run the API locally, or write its schema out.
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
python -m synpath.server # the read API on 127.0.0.1:8000
|
|
5
|
+
python -m synpath.server schema --out openapi.json # the read contract
|
|
6
|
+
python -m synpath.server schema --trading --out openapi.json # the trading one
|
|
7
|
+
python -m synpath.server bootstrap --control control.db # the first API key
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`schema` exists so a typed client can be generated in CI without starting a
|
|
11
|
+
server or holding credentials:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
python -m synpath.server schema --trading --out openapi.json
|
|
15
|
+
npx @hey-api/openapi-ts -i openapi.json -o ./src/synpath
|
|
16
|
+
```
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def schema(args: argparse.Namespace) -> None:
|
|
26
|
+
"""Write the OpenAPI document without running anything."""
|
|
27
|
+
if args.trading:
|
|
28
|
+
from ..engine.engine import Engine, EngineConfig
|
|
29
|
+
from .store import ControlStore
|
|
30
|
+
from .trading import create_trading_app
|
|
31
|
+
|
|
32
|
+
# The document depends on the routes, not on the engine's state, so an
|
|
33
|
+
# engine that was never started describes the same contract.
|
|
34
|
+
engine = Engine({}, EngineConfig(journal_path=":memory:", require_lease=False))
|
|
35
|
+
app = create_trading_app(engine, ControlStore(":memory:"))
|
|
36
|
+
else:
|
|
37
|
+
from .api import create_app
|
|
38
|
+
|
|
39
|
+
app = create_app()
|
|
40
|
+
document = json.dumps(app.openapi(), indent=2, sort_keys=True)
|
|
41
|
+
if args.out:
|
|
42
|
+
with open(args.out, "w") as handle:
|
|
43
|
+
handle.write(document + "\n")
|
|
44
|
+
print(f"wrote {args.out}")
|
|
45
|
+
else:
|
|
46
|
+
print(document)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bootstrap(args: argparse.Namespace) -> None:
|
|
50
|
+
"""Create the first user and key for a control database."""
|
|
51
|
+
from .store import ControlStore
|
|
52
|
+
|
|
53
|
+
async def run() -> None:
|
|
54
|
+
store = await ControlStore(args.control).open()
|
|
55
|
+
try:
|
|
56
|
+
user, key = await store.bootstrap(args.name)
|
|
57
|
+
print(f"user {user.id} ({user.name})")
|
|
58
|
+
print(f"key {key.secret}")
|
|
59
|
+
print("This key is shown once. It has every permission on every account.")
|
|
60
|
+
finally:
|
|
61
|
+
await store.close()
|
|
62
|
+
|
|
63
|
+
asyncio.run(run())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> None:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="python -m synpath.server",
|
|
69
|
+
description="Serve the synpath API. The read app has no authentication: bind to localhost, "
|
|
70
|
+
"or mount it behind your own middleware for anything public.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--host", default="127.0.0.1", help="default: 127.0.0.1")
|
|
73
|
+
parser.add_argument("--port", type=int, default=8000, help="default: 8000")
|
|
74
|
+
parser.add_argument("--reload", action="store_true", help="reload on code changes")
|
|
75
|
+
parser.add_argument("--log-level", default="info")
|
|
76
|
+
sub = parser.add_subparsers(dest="command")
|
|
77
|
+
|
|
78
|
+
schema_parser = sub.add_parser("schema", help="write the OpenAPI document and exit")
|
|
79
|
+
schema_parser.add_argument("--trading", action="store_true", help="the trading contract instead of the read one")
|
|
80
|
+
schema_parser.add_argument("--out", default=None, help="a file to write, or stdout")
|
|
81
|
+
|
|
82
|
+
boot = sub.add_parser("bootstrap", help="create the first user and API key")
|
|
83
|
+
boot.add_argument("--control", default="synpath-control.db", help="the control database")
|
|
84
|
+
boot.add_argument("--name", default="owner")
|
|
85
|
+
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
if args.command == "schema":
|
|
88
|
+
return schema(args)
|
|
89
|
+
if args.command == "bootstrap":
|
|
90
|
+
return bootstrap(args)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
import uvicorn
|
|
94
|
+
except ImportError: # pragma: no cover - depends on env
|
|
95
|
+
raise SystemExit(
|
|
96
|
+
'synpath.server needs an ASGI server, which the base install carries. Reinstall with:\n'
|
|
97
|
+
' pip install synpath'
|
|
98
|
+
) from None
|
|
99
|
+
|
|
100
|
+
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
101
|
+
print(
|
|
102
|
+
f"warning: binding to {args.host} exposes an API with no "
|
|
103
|
+
"authentication. Mount create_app() behind your own middleware "
|
|
104
|
+
"instead of serving this directly."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
uvicorn.run(
|
|
108
|
+
"synpath.server:app",
|
|
109
|
+
host=args.host,
|
|
110
|
+
port=args.port,
|
|
111
|
+
reload=args.reload,
|
|
112
|
+
log_level=args.log_level,
|
|
113
|
+
factory=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|