Metadata-Version: 2.5
Name: owls-insight
Version: 0.28.1
Summary: Official Python SDK for the Owls Insight real-time sports betting odds API
Project-URL: Homepage, https://owlsinsight.com
Project-URL: Documentation, https://owlsinsight.com/docs
Project-URL: Repository, https://github.com/Davidgsilva/owls-insight-sdk-python
Author-email: Owls Insight <david@wisesportsai.com>
License: MIT
License-File: LICENSE
Keywords: api,betting,draftkings,fanduel,mlb,nba,nfl,nhl,odds,pinnacle,real-time,soccer,sports,tennis,websocket
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: aiohttp>=3.9
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.5
Requires-Dist: python-socketio>=5.12
Requires-Dist: websocket-client>=1.7
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# Owls Insight Python SDK

Official Python SDK for the [Owls Insight](https://owlsinsight.com) real-time sports betting odds API. Sync **and** async clients, fully typed with Pydantic v2, plus a Socket.io WebSocket client for live streams.

```bash
pip install owls-insight
```

Requires Python 3.9+. Get an API key at [owlsinsight.com](https://owlsinsight.com).

## Quick start (sync)

```python
from owls_insight import OwlsInsight

client = OwlsInsight(api_key="owlsinsight_...")

# REST: current NBA odds. `data` is keyed by sportsbook -> list of events.
# `market="h2h"` narrows to one market; `event.home_team_id` is a stable id
# for the team, the same across every book, so you can join on it.
odds = client.rest.get_odds("nba", books=["pinnacle", "fanduel"], market="h2h")
for book, events in odds.data.items():
    for event in events:
        print(book, event.home_team_id, "vs", event.away_team_id)

# A filter the endpoint did not honour is reported, never an error.
if odds.meta and odds.meta.ignored_params:
    print("ignored:", odds.meta.ignored_params, odds.meta.ignored_reasons)

# WebSocket: stream live updates. The subscription is sent again on every
# reconnect, so it holds for the life of the client.
client.ws.on("odds-update", lambda data: print("live:", data))
client.ws.connect(subscription={"sports": ["nba"], "books": ["pinnacle"]})

# or block for the next event
update = client.ws.wait_for("odds-update", timeout=15)

client.destroy()
```

## Quick start (async)

```python
import asyncio
from owls_insight import AsyncOwlsInsight

async def main():
    async with AsyncOwlsInsight(api_key="owlsinsight_...") as client:
        odds = await client.rest.get_odds("nba", books=["pinnacle"])
        print(sum(len(events) for events in odds.data.values()), "games")

        await client.ws.connect(subscription={"sports": ["nba"], "books": ["pinnacle"]})
        update = await client.ws.wait_for("pinnacle-realtime", timeout=30)
        print("realtime:", update)

asyncio.run(main())
```

## Authentication

Pass your API key to the constructor. REST uses the `Authorization: Bearer` header; the WebSocket uses the `?apiKey=` query string. Both are handled for you. Keep the key on your server: do not ship it inside an app or web page (Terms of Service, section 5).

Every REST request and the WebSocket connection send `User-Agent: owls-insight-python/<version>`, and the WebSocket handshake sends the same label as `auth.sdk`.

```python
import os
client = OwlsInsight(api_key=os.environ["OWLS_INSIGHT_API_KEY"])
```

## REST

Method names are `snake_case` (the JS SDK's `getOdds` is `get_odds` here). Every method returns a typed Pydantic model; unknown fields from the evolving API are preserved (`model_extra`), never dropped.

Full parity with the TypeScript SDK — every endpoint is available on both `client.rest` (sync) and the async client.

| Area | Methods |
|------|---------|
| Odds | `get_odds`, `get_moneyline`, `get_spreads`, `get_totals`, `get_realtime`, `get_ps3838_realtime`, `get_esports_realtime`, `get_ev`, `list_events`, `get_one_x_bet_soccer`, `get_prophetx_odds` |
| Props | `get_props`, `get_book_props`, `get_props_history`, `get_props_stats`, `get_book_props_stats` |
| Prop results & trends | `get_prop_results` (one game, or `date=` to list a day's graded games), `get_prop_trends` (hit rate / average / streak vs a line, or `line="closing"` + `book`) |
| Same-Game Parlay | `get_sgp_events`, `build_sgp` (FanDuel bet-slip price; Rookie/MVP/HoF) |
| Scores / schedule | `get_scores`, `get_schedule`, `get_results`, `get_splits`, `normalize`, `normalize_batch` |
| Stats | `get_stats`, `get_match_stats`, `get_h2h`, `get_player_averages` |
| Line history | `get_odds_history`, `get_moneyline_history`, `get_spread_history`, `get_totals_history` |
| Historical | `get_history_coverage` (which dates each dataset holds, per sport), `get_history_games`, `get_history_odds`, `get_history_props`, `iter_history_odds` / `iter_history_props` (page a whole game, retries 429/503), `get_history_stats`, `get_history_tennis_stats`, `get_game_stats_detail`, `get_closing_odds`, `get_historical_player_props`, `get_public_betting`, `get_cs2_matches`, `get_cs2_match`, `get_cs2_players` |
| v2 Source API | `get_hard_rock_events`, `get_hard_rock_leagues`, `get_ballybet_v2`/`_leagues`, `get_bet365_v2`/`_leagues`, `get_betonline_v2`/`_leagues`, `get_betrivers_v2`/`_leagues`, `get_betus_v2`/`_leagues`, `get_bookmaker_v2`/`_leagues`, `get_bovada_v2`/`_leagues`, `get_draftkings_v2`/`_leagues`, `get_fanaticsmarkets_v2`/`_leagues`, `get_fanduel_v2`/`_leagues`, `get_kalshi_v2`/`_leagues`, `get_lowvig_v2`/`_leagues`, `get_mybookie_v2`, `get_novig_v2`/`_leagues`, `get_pinnacle_v2`/`_leagues`, `get_polymarket_v2`/`_leagues`, `get_stake_v2`, `get_thescore_v2`/`_leagues`, `get_thunderpick_v2`, `get_tippmixpro_v2`/`_leagues`, `get_underdog_v2`, `get_versus_v2`/`_leagues`, plus `get_hard_rock_ladder` for Hard Rock's `rootIdx` decode |

> Methods whose response shape isn't individually modelled yet return a permissive `ApiResponse` envelope (`success`/`data`/`meta`, all fields preserved). Deep per-endpoint Pydantic typing is the remaining follow-on; the method surface is complete.

### ProphetX order book

Each ProphetX `markets` entry (keyed `"{market_id}:{line}"`) parses as `models.ProphetXMarket`.
The book is a list of sides in `outcomes` order, each side its price levels best-first or
`None` when nothing is resting. On spread and total deltas the book sits under
`marketLines[0]` rather than the top level, so use the helpers:

```python
from owls_insight import OwlsInsight, prophetx_book, prophetx_outcomes, prophetx_best

px = OwlsInsight(api_key="...").rest.get_prophetx_odds("baseball")
for event in px.data.sports["Baseball"]:
    for key, market in event.markets.items():
        outcomes = prophetx_outcomes(market)        # labels, one per side
        for i, side in enumerate(prophetx_book(market)):
            best = prophetx_best(market, i)
            if best is None:                        # None side = nothing resting
                continue
            # best.value = amount you can bet now; best.odds = American; payout from those two, not from stake
            label = outcomes[i].name if i < len(outcomes) else None
            print(key, label, best.odds, best.value)
```

### Hard Rock odds decoding

Hard Rock v2 selections carry a `rootIdx`, not inline odds. Decode it client-side:

```python
from owls_insight import root_idx_to_american_odds
root_idx_to_american_odds(42)  # -325
root_idx_to_american_odds(72)  #  100  (even)
```

## WebSocket

Connecting needs WebSocket access: Hall of Fame, or any paid plan with the WebSocket add-on.

### Subscriptions

A subscription belongs to one connection, and the server forgets it when the connection ends. The client remembers the last one you sent, plus every props subscription, and sends them again on every connect and reconnect: with the connection request, and as `subscribe` / `subscribe-*-props` events right after it, before your own `connect` listeners run. You do not need to re-subscribe after a reconnect.

```python
client.ws.connect(subscription={"sports": ["nba", "nhl"], "books": ["pinnacle", "fanduel"]})
client.ws.subscribe(sports=["nba"], event_ids=["<event id>"])   # REPLACE the whole subscription
client.ws.update_subscription(prophetx=True)                   # MERGE: change one field, keep the rest
client.ws.subscribe_props(book="fanduel")                      # props stream, also re-sent on reconnect
client.ws.on("props-update", handle_props)
print(client.ws.subscription)                                  # what is sent on every connect
```

- `subscribe()` replaces the subscription: a field you leave out is cleared. `update_subscription()` changes only the fields you pass; each one is replaced whole, so to change `v2`, send the whole `v2` object.
- `event_ids` limits `odds-update` to the events with those `id` values (sent as `eventIds`). `exclude_exchanges=True` drops Kalshi, Polymarket and Novig from the books.
- `connect(subscription=...)` takes the same fields as a dict. Snake_case names `esports_realtime`, `one_x_bet_soccer` and `event_ids` are sent under their server names; every other key is sent exactly as given (`exclude_exchanges` is already the server's name).
- A connection that never subscribes receives the default: all sports, all books.
- `subscribed` can arrive more than once after a connect. Each one carries the subscription in force.
- `disconnect()` closes the connection, stops a reconnect in progress, and forgets the subscription and your listeners. You can call it from any listener, `disconnect` and `connect_error` included. A later `connect()` starts over, and the client again reconnects on its own after a drop.

Events: `odds-update`, `props-update` (and per-book `{book}-props-update`), `pinnacle-realtime`, `pinnacle-realtime-sync`, `ps3838-realtime`, `esports-update`, `prophetx-update`, the v2 `{book}-v2-update` deltas, and on every connection `server-heartbeat` and `server-notice`.

`odds-update`: after a subscribe the first frames are a snapshot of your subscription; later frames carry only the games that changed, so merge them into your board by event `id`.

### Realtime: `pinnacle-realtime`

Sent to eligible connections (WebSocket access plus MVP or above) for the sports in your subscription, or every sport if you never subscribe. The `books` filter does not apply to realtime.

Each frame carries **one sport** and at least one event:

```
{"timestamp": ..., "<sport>": [events], "delta": true, "resync": true?, "stream": {...}?, "removed": {"<sport>": [ids]}?}
```

- `delta` is always `true`: a frame holds only some events. Merge each event into your board by `id`, replacing the whole event, then delete the ids in `removed[<sport>]`.
- `resync: true` marks a frame from the periodic resend, which also carries events that did not change.
- `stream` is `{"sport", "epoch", "seq", "checkpoint"?}`. Within one `epoch`, `seq` goes up by 1 per frame for that sport. `stream` and `removed` are optional: handle frames with and without them.
- `pinnacle-realtime-sync` is its companion, on the same connections and for the same sports: `{"timestamp", "sport", "stream"?, "removed"?}`, a stream position or removals for a sport with no events to send. Track `stream` and apply `removed` exactly as on `pinnacle-realtime`; it never carries events.

Each event has the same fields as `get_realtime` data (typed there on `models.OddsEvent`): `status`, `isLive`, `phase` when known (`scheduled`, `live`, `suspended`, `closed`; new values can appear, so treat an unknown one like a missing field, and read `phase` first whenever it is present), `lastUpdate` and `seenAt` (epoch ms) and `freshness`. Markets carry `last_update`, and where present `last_seen` (when the feed last confirmed the price) and `suspended: true`.

**Loading a board without gaps.** Do this for each sport on every connect and reconnect, and whenever the frame checks below say so:

1. Keep your current board. Buffer that sport's frames from both `pinnacle-realtime` and `pinnacle-realtime-sync`.
2. Wait up to 25 seconds for a frame with `stream.checkpoint` set. If the sport's frames carry no `stream`, load at once. After a reconnect, also wait a random 0 to 10 seconds, so that clients reconnecting together (for example after `SERVER_RESTART`) do not all load at the same moment.
3. Call `resp = client.rest.get_realtime(sport)` and read `stream = getattr(resp.meta, "stream", None)`. To hold the board as the same kind of dicts the frames carry, use `{e.id: e.model_dump(exclude_unset=True) for e in resp.data}`: `exclude_unset=True` keeps only the fields the response carried, as a frame does. Prices come back as floats (`-120.0` where a frame has `-120`).
   - `None`: replace your board with `resp.data`, merge every frame with no `seq` check, and load again after 10, 20, 40 seconds and so on, up to every 5 minutes. Replacing the board is also what drops ended events while frames carry no `removed`.
   - its `epoch` differs from the checkpoint's, or its `seq` is lower than the checkpoint's: load again at the next checkpoint.
   - otherwise: replace your board with `resp.data`, discard buffered frames with a `seq` at or below `stream["seq"]`, and apply the rest in order.

Each load counts against your request quota. Then apply frames like this:

```python
board = {}        # event id -> event, for one sport
position = None   # (epoch, seq) once a load aligned with the stream, else None

def frame_sport(frame):
    """The one sport a pinnacle-realtime or pinnacle-realtime-sync frame is about."""
    if frame.get("stream"):
        return frame["stream"]["sport"]
    if isinstance(frame.get("sport"), str):
        return frame["sport"]
    return next(key for key, value in frame.items() if isinstance(value, list))

def apply(frame, sport):
    for event in frame.get(sport) or []:
        board[event["id"]] = event                    # replace the whole event
    for event_id in (frame.get("removed") or {}).get(sport, []):
        board.pop(event_id, None)

def on_frame(frame, sport):
    """Apply one frame. False means: load the board again (keep this one meanwhile)."""
    global position
    if position is None:                              # not aligned: merge everything
        apply(frame, sport)
        return True
    stream = frame.get("stream")
    if stream is None or stream["epoch"] != position[0]:
        return False                                  # stream stopped, or a new epoch
    if stream["seq"] <= position[1]:
        return True                                   # a duplicate: ignore it
    if stream["seq"] != position[1] + 1:
        return False                                  # a frame was missed
    position = (stream["epoch"], stream["seq"])
    apply(frame, sport)
    return True
```

**Leagues.** Frames carry every league of the sport. If you load with `get_realtime(sport, league=...)`, apply the same filter to frames before merging them: the REST filter keeps the events whose `league` contains your text, ignoring case.

**Stalls.** Once a sport's frames carry `stream`, more than 30 seconds without a frame for that sport while you are connected means its stream has stopped or the sport has no events left. Load it again as above, and while it stays silent, keep loading after 10, 20, 40 seconds and so on, up to every 5 minutes. `resp.meta.feed` says how long ago data last arrived for the sport.

On `get_realtime`, `meta.stream` is `{"epoch", "seq"}` (the stream position the response matches) or `None`, and `meta.feed` carries `lastDataAgeSeconds`, `upstreamAgeSeconds` (seconds since Pinnacle last sent anything for the sport) and `resubscribing`. The last two are `None` when unknown. These are extra fields on `resp.meta`: read them as `getattr(resp.meta, "stream", None)` and `resp.meta.feed["upstreamAgeSeconds"]`.

### Realtime: `ps3838-realtime`

Same eligibility and sport filter as `pinnacle-realtime`. Each frame carries one sport and can be several MB.

- `delta: true`: changed or added events. Merge them into that sport's board by `id`. Deltas carry no removals.
- `snapshot: true`: the sport's full board. Replace your board for that sport. Sent about every 20 seconds, and early when events are removed.
- neither marker: a full board for the sport. Replace.

Load `client.rest.get_ps3838_realtime(sport)` on every connect and reconnect, then apply frames. No frame is sent for a sport with no events, so when a sport's board empties, its last events stay on your board: load a sport again when it has sent no frame for a minute or more. Each load counts against your request quota.

### Connection health

- `server-heartbeat`: `{"seq", "ts"}` every 10 seconds on every connection, whatever odds traffic there is, so silence is detectable. `seq` is counted by the server, not per connection: after a reconnect it can jump ahead or start again from 1. Compare it only within one connection, where a gap means messages were missed.
- `server-notice`: why the server closed your previous connection, sent once on the next connect: `{"code", "message", "at", "socketId", "details", "hint"}`. `SERVER_RESTART` is instead sent to open connections just before a restart; the client reconnects and re-subscribes on its own.

| `code` | Meaning |
|---|---|
| `BACKLOG_CAP` | The connection fell too far behind: more data was waiting for it than the per-connection limit allows. Keep handlers fast and subscribe only to what you need. |
| `AGGREGATE_SHED` | The connection was among those furthest behind when the server had to shed load. Same advice. |
| `GHOST` | Data sent to the connection had not been read for over a minute. |
| `SLOT_EVICTED` | The connection had stopped responding and was closed to admit a new one with the same API key. |
| `CLIENT_FRAME_LIMIT` | Your client closed the connection because a message was larger than its maximum message size (close code 1009). |
| `SERVER_RESTART` | The server is restarting. |

`SERVER_NOTICE_CODES` lists them; the list can grow.

**Message size.** A single realtime frame can be several MB, so allow at least 32 MB per message. The async client accepts up to 64 MiB by default: `AsyncWebSocketClient(..., max_message_size=...)` or `AsyncOwlsInsight(..., ws_max_message_size=...)`, where `0` means no limit and `None` keeps aiohttp's own 4 MiB. The sync client has no limit.

**Compression.** The async client takes the WebSocket compression the server offers (permessage-deflate), so messages travel compressed. Turn it off with `AsyncWebSocketClient(..., compression=False)` or `AsyncOwlsInsight(..., ws_compression=False)`, for example behind a proxy that mishandles it. The sync client's transport (`websocket-client`) cannot compress, so every message reaches it uncompressed: for wide subscriptions prefer the async client.

**Refused connections.** A failed `connect()` raises `OwlsInsightConnectionError` (a subclass of `socketio.exceptions.ConnectionError`, so existing `except` clauses still work):

```python
from owls_insight import OwlsInsightConnectionError

try:
    client.ws.connect(subscription={"sports": ["nba"]})
except OwlsInsightConnectionError as e:
    print(e.code, e.retryable, e.retry_after_ms, e.max_connections, e.server_message)
```

| `code` | `retryable` |
|---|---|
| `MISSING_API_KEY`, `INVALID_API_KEY`, `API_KEY_DEACTIVATED`, `TIER_NO_WS`, `SUBSCRIPTION_INACTIVE`, `PAYMENT_OVERDUE`, `TRIAL_EXPIRED`, `TRIAL_UNVERIFIABLE` | no |
| `SUBSCRIPTION_CHECK_FAILED`, `INTERNAL`, `CLIENT_CLOSED` | yes |
| `IP_BLOCKED` (`retry_after_ms` is how long the block lasts) | yes |
| `CONNECTION_LIMIT` (every connection slot of the plan is in use; `max_connections` is how many the plan has, `retry_after_ms` when to try again) | yes |

`code` is `None` when the server sent none (for example the address could not be reached); `retryable` then comes from the refusal message, or is `None`.

**Reconnecting.** After a connection drops, the client reconnects on its own, doubling the wait between attempts up to 30 seconds. When a connection that lasted at least a minute drops, the first wait is about 1 second; each drop of a connection younger than a minute doubles it, and a connection that lasts a minute resets it. So a subscription that is too wide for your client to keep up with slows down instead of reconnecting every second, and `server-notice` tells you why the server closed it.

A reconnect refused as not retryable stops the client, with the refusal kept in `client.ws.last_refusal`: a missing, unknown or deactivated key (`MISSING_API_KEY`, `INVALID_API_KEY`, `API_KEY_DEACTIVATED`; repeated refusals get your IP blocked by the server) or a code this version does not know. Fix the cause and call `connect()` again, which clears it. Retryable refusals such as `CONNECTION_LIMIT` keep the client reconnecting, about every 30 seconds once the backoff has grown, and so do the account refusals (`TIER_NO_WS`, `SUBSCRIPTION_INACTIVE`, `PAYMENT_OVERDUE`, `TRIAL_EXPIRED`, `TRIAL_UNVERIFIABLE`), so the reconnect goes through once the invoice is paid or the plan upgraded. Two limits: when the server itself closes a connection because of the account, the client does not reconnect (call `connect()` again once it is fixed), and an expired trial deactivates the key, so the next attempt stops. A `connect_error` listener receives the server's `{"message", "data"}` for every refused attempt; call `disconnect()` there if you would rather stop.

### Polymarket v2 options

`v2.polymarket` takes two options next to its sport keys:

```python
client.ws.connect(subscription={
    "v2": {"polymarket": {
        "mlb": "*",
        "eventTypes": ["best_bid_ask", "last_trade_price", "market_resolved"],
        "marketTypes": ["moneyline"],
    }},
})
```

- `eventTypes`: `"*"` or a list of message types. Without it you receive `book`, `best_bid_ask`, `last_trade_price` and `tick_size_change`. `market_resolved` is sent only to subscriptions that name it or pass `"*"`.
- `marketTypes`: `"*"` or up to 50 market types. Without it every market type is sent. Values are not checked when you subscribe: take them from `sportsMarketType` in `get_polymarket_v2` data.
- Options need at least one sport key beside them. `update_subscription(v2=...)` replaces the whole `v2` object, so send the sport keys again with the options.
- The `subscribed` acknowledgement carries `v2OptionsAdvisory` when an option was dropped, widened, or asks for a type this connection will not receive: a list of `{"book", "field", "sport"?, "outcome", "accepted", "rejectedCount", "rejectedSample"?, "message"}`.

Each `polymarket-v2-update` carries `sport`, `league` (the first entry of `leagues`), `leagues` (every league the market is tagged with), `marketId`, `eventSlug`, `marketType`, `assetId`, `timestamp` and `raw`, the Polymarket message itself (`raw["event_type"]` is its type).

### v2: removals, hashes and 304s

`pinnacle-v2-update` carries changed `matchupIds` plus the league `hash`, and every 30 seconds a heartbeat per league (`heartbeat: true`) whose `matchupIds` is the league's full membership.

- `hash` equals the `etag` in the body of the league's `get_pinnacle_v2` response (the `ETag` header without its quotes) whenever that response is not partial. If it equals the `etag` you hold, you are current and need no refetch. It is an opaque SHA-1 hex string.
- A delta or a heartbeat may carry `removedMatchupIds`: matchups the league dropped, which you delete. The list is not exhaustive, and an id can come back later in `matchupIds`, so heartbeat membership and the REST board stay authoritative.
- When a league empties, its final heartbeat can carry `matchupIds: []` with `hash` equal to `PINNACLE_V2_EMPTY_LEAGUE_HASH`. After that the league sends nothing until it has matchups again, and its REST board has no `etag`.

The SDK's v2 methods do not send `If-None-Match`. If you make conditional requests yourself, send the `ETag` response header back exactly as received (quoted; a leading `W/` is fine): the body `etag` has no quotes and never matches. A 304 on `/api/v2` does not count toward your monthly quota; it still counts toward your per-minute limit and appears in your usage log. A key that has used up its monthly quota gets 429 even when the request would have been a 304. A 429 for the per-minute limit does not count toward the monthly quota.

## Errors

```python
from owls_insight import AuthenticationError, RateLimitError, ServiceBusyError, OwlsInsightError

try:
    client.rest.get_odds("nba")
except AuthenticationError:
    ...  # 401
except RateLimitError as e:
    # e.code is "HISTORY_CONCURRENCY" when too many history requests were in flight
    print("retry after", e.retry_after_ms, "ms", e.code)
except ServiceBusyError as e:
    # 503: too expensive to serve in one request, or a transient outage
    print("busy, retry after", e.retry_after_ms, "ms or narrow the request")
except OwlsInsightError as e:
    print(e.status, e.message)
```

Retries are opt-in for direct calls: `OwlsInsight(api_key, max_retries=3)` retries 429 and 503 honouring `Retry-After` (else exponential backoff). `iter_history_odds` / `iter_history_props` retry by default. `history_concurrency` (default 3, the MVP plan's per-key history allowance; Hall of Fame allows 4) caps the history requests a client keeps in flight; requests beyond it wait client-side instead of being refused.

## License

MIT
