Skip to content

Commit 3987ff1

Browse files
keitajclaude
andauthored
feat: assert minimum hyperliquid-python-sdk version at bot startup (#155)
If a stale venv ends up with an older SDK than what pyproject.toml pins, the bot can otherwise crash deep inside the SDK during exchange initialization with an opaque traceback. Validating the installed version up front turns that into a single, actionable error message and prevents watchdog crash loops on a deployment that is silently out of sync. The minimum is a code-level invariant (like `requires-python`) rather than a runtime-tunable parameter, so it is kept as a module-scope constant in `bot.py` and bumped in lockstep with the pyproject pin. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c6c3d22 commit 3987ff1

2 files changed

Lines changed: 284 additions & 1 deletion

File tree

bot.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,82 @@
11
import logging
22
import os
3+
import sys
34
import time
45
import signal
56
import types
67
import warnings
7-
from typing import Any, Dict, List, Optional
8+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
9+
from typing import Any, Dict, List, Optional, Tuple
810

911
from log_config import setup_logging
1012
setup_logging()
1113

14+
# ---------------------------------------------------------------------------
15+
# SDK compatibility gate
16+
# ---------------------------------------------------------------------------
17+
# Hyperliquid mainnet metadata evolves over time. The installed
18+
# ``hyperliquid-python-sdk`` must be at least this version to handle the
19+
# current spot universe correctly. When upgrading the SDK, bump this constant
20+
# in the same PR so deployed environments are validated at startup.
21+
MINIMUM_HYPERLIQUID_SDK_VERSION: Tuple[int, int, int] = (0, 23, 0)
22+
23+
24+
def _parse_version_tuple(version: str) -> Tuple[int, int, int]:
25+
"""Parse a version string into a comparable ``MAJOR.MINOR.PATCH`` tuple.
26+
27+
Only the first three numeric components are considered. Pre-release or
28+
development suffixes (e.g. ``rc1``, ``a2``) on any component are stripped
29+
so that ``0.23.0rc1`` is treated as ``(0, 23, 0)``. Missing components
30+
default to 0 (``"1.0"`` becomes ``(1, 0, 0)``).
31+
"""
32+
parts = version.split(".")
33+
nums: List[int] = []
34+
for part in parts[:3]:
35+
digits = ""
36+
for ch in part:
37+
if ch.isdigit():
38+
digits += ch
39+
else:
40+
break
41+
nums.append(int(digits) if digits else 0)
42+
while len(nums) < 3:
43+
nums.append(0)
44+
return (nums[0], nums[1], nums[2])
45+
46+
47+
def _assert_sdk_version() -> None:
48+
"""Abort startup if ``hyperliquid-python-sdk`` is below the minimum.
49+
50+
Exits with status 2 (configuration error) and a clear remediation message
51+
so a crash loop in production can be diagnosed from a single log line
52+
rather than a deep API traceback.
53+
"""
54+
pkg = "hyperliquid-python-sdk"
55+
try:
56+
installed = _pkg_version(pkg)
57+
except PackageNotFoundError:
58+
min_str = ".".join(str(n) for n in MINIMUM_HYPERLIQUID_SDK_VERSION)
59+
sys.stderr.write(
60+
f"FATAL: {pkg} is not installed but is required (>= {min_str}). "
61+
f"Run `pip install \".[dev]\"` from the bot repo to install all "
62+
f"dependencies.\n"
63+
)
64+
sys.exit(2)
65+
66+
if _parse_version_tuple(installed) < MINIMUM_HYPERLIQUID_SDK_VERSION:
67+
min_str = ".".join(str(n) for n in MINIMUM_HYPERLIQUID_SDK_VERSION)
68+
sys.stderr.write(
69+
f"FATAL: {pkg} {installed} is installed, but minimum required "
70+
f"version is {min_str}. The bot will not function correctly with "
71+
f"this SDK due to upstream API metadata changes.\n"
72+
f"To fix: `pip install \".[dev]\"` from the bot repo "
73+
f"(or `pip install --upgrade '{pkg}>={min_str}'`).\n"
74+
)
75+
sys.exit(2)
76+
77+
78+
_assert_sdk_version()
79+
1280
from hyperliquid.exchange import Exchange # noqa: E402
1381
from config import Config # noqa: E402
1482
from market_data import MarketDataManager # noqa: E402

tests/test_sdk_version_check.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Tests for the hyperliquid-python-sdk minimum-version startup gate.
2+
3+
The gate lives at module scope in ``bot.py`` and runs at import time.
4+
Importing ``bot`` directly therefore has side effects (it tries to read
5+
the installed SDK version) and pulls in the rest of the bot. Here we
6+
import only the gate's helpers via a lazy importer that loads bot.py's
7+
source and extracts the two functions, avoiding the heavy import graph
8+
while still exercising the real implementation.
9+
"""
10+
11+
import importlib.util
12+
from importlib.metadata import PackageNotFoundError
13+
from pathlib import Path
14+
from unittest.mock import patch
15+
16+
import pytest
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Helpers — load the two gate functions from bot.py without executing
21+
# the module body (which would import hyperliquid and call _assert_sdk_version).
22+
# ---------------------------------------------------------------------------
23+
24+
_BOT_PATH = Path(__file__).resolve().parent.parent / "bot.py"
25+
26+
27+
def _extract_gate_module():
28+
"""Compile bot.py's gate block as a standalone module.
29+
30+
We slice the file up to (but excluding) the ``_assert_sdk_version()``
31+
invocation at module scope, so the helpers are defined but not run.
32+
"""
33+
source = _BOT_PATH.read_text()
34+
marker = "\n_assert_sdk_version()\n"
35+
idx = source.index(marker)
36+
head = source[:idx]
37+
spec = importlib.util.spec_from_loader("bot_gate_under_test", loader=None)
38+
module = importlib.util.module_from_spec(spec)
39+
exec(compile(head, str(_BOT_PATH), "exec"), module.__dict__)
40+
return module
41+
42+
43+
@pytest.fixture(scope="module")
44+
def gate():
45+
return _extract_gate_module()
46+
47+
48+
def _parse(gate, v: str):
49+
return gate._parse_version_tuple(v)
50+
51+
52+
def _run_assert(gate, version_or_exc):
53+
"""Return a patch context-manager for the gate's ``_pkg_version``.
54+
55+
Uses ``patch.object`` because the gate module is dynamically built and
56+
not registered in ``sys.modules``, so the string-target form of
57+
``patch`` cannot locate it.
58+
"""
59+
if isinstance(version_or_exc, Exception):
60+
return patch.object(gate, "_pkg_version", side_effect=version_or_exc)
61+
return patch.object(gate, "_pkg_version", return_value=version_or_exc)
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# _parse_version_tuple
66+
# ---------------------------------------------------------------------------
67+
68+
class TestParseVersionTuple:
69+
def test_standard_three_components(self, gate):
70+
assert _parse(gate, "0.23.0") == (0, 23, 0)
71+
assert _parse(gate, "1.2.3") == (1, 2, 3)
72+
assert _parse(gate, "10.20.30") == (10, 20, 30)
73+
74+
def test_pre_release_suffix_on_patch(self, gate):
75+
# "0.23.0rc1" → digits before suffix: 0, 23, 0
76+
assert _parse(gate, "0.23.0rc1") == (0, 23, 0)
77+
assert _parse(gate, "0.23.0a2") == (0, 23, 0)
78+
assert _parse(gate, "0.23.0.dev5") == (0, 23, 0)
79+
80+
def test_pre_release_suffix_on_minor(self, gate):
81+
# "0.23a1.0" → minor digits "23"; patch "0"
82+
assert _parse(gate, "0.23a1.0") == (0, 23, 0)
83+
84+
def test_two_components_pads_to_three(self, gate):
85+
assert _parse(gate, "1.0") == (1, 0, 0)
86+
assert _parse(gate, "2.5") == (2, 5, 0)
87+
88+
def test_one_component_pads_to_three(self, gate):
89+
assert _parse(gate, "1") == (1, 0, 0)
90+
91+
def test_extra_components_ignored(self, gate):
92+
# Anything beyond the first 3 is dropped.
93+
assert _parse(gate, "0.23.0.1") == (0, 23, 0)
94+
assert _parse(gate, "1.2.3.4.5") == (1, 2, 3)
95+
96+
def test_non_numeric_component_yields_zero(self, gate):
97+
# A component with no leading digits → 0.
98+
assert _parse(gate, "0.foo.0") == (0, 0, 0)
99+
assert _parse(gate, "0.23.bar") == (0, 23, 0)
100+
101+
def test_ordering_consistent_with_semver(self, gate):
102+
# Sanity: ordering used by the gate must match human intuition.
103+
assert _parse(gate, "0.22.0") < _parse(gate, "0.23.0")
104+
assert _parse(gate, "0.23.0") < _parse(gate, "0.23.1")
105+
assert _parse(gate, "0.23.0") < _parse(gate, "1.0.0")
106+
assert _parse(gate, "0.23.0") == _parse(gate, "0.23.0")
107+
108+
109+
# ---------------------------------------------------------------------------
110+
# _assert_sdk_version
111+
# ---------------------------------------------------------------------------
112+
113+
class TestAssertSdkVersion:
114+
def test_minimum_version_passes(self, gate, capsys):
115+
"""SDK == MINIMUM_HYPERLIQUID_SDK_VERSION → no exit."""
116+
min_str = ".".join(str(n) for n in gate.MINIMUM_HYPERLIQUID_SDK_VERSION)
117+
with _run_assert(gate, min_str):
118+
# Must NOT raise SystemExit.
119+
gate._assert_sdk_version()
120+
captured = capsys.readouterr()
121+
assert captured.err == ""
122+
123+
def test_above_minimum_passes(self, gate, capsys):
124+
"""SDK > minimum → no exit."""
125+
major, minor, patch_ = gate.MINIMUM_HYPERLIQUID_SDK_VERSION
126+
higher = f"{major}.{minor}.{patch_ + 1}"
127+
with _run_assert(gate, higher):
128+
gate._assert_sdk_version()
129+
captured = capsys.readouterr()
130+
assert captured.err == ""
131+
132+
def test_much_above_minimum_passes(self, gate, capsys):
133+
"""SDK far above minimum (e.g. major bump) → no exit."""
134+
major, _, _ = gate.MINIMUM_HYPERLIQUID_SDK_VERSION
135+
higher = f"{major + 1}.0.0"
136+
with _run_assert(gate, higher):
137+
gate._assert_sdk_version()
138+
captured = capsys.readouterr()
139+
assert captured.err == ""
140+
141+
def test_below_minimum_exits_with_status_2(self, gate, capsys):
142+
"""SDK < minimum → SystemExit(2) with remediation message."""
143+
major, minor, patch_ = gate.MINIMUM_HYPERLIQUID_SDK_VERSION
144+
# Construct a version strictly less than the minimum.
145+
if patch_ > 0:
146+
lower = f"{major}.{minor}.{patch_ - 1}"
147+
elif minor > 0:
148+
lower = f"{major}.{minor - 1}.0"
149+
else:
150+
lower = f"{major - 1}.0.0" if major > 0 else "0.0.0rc1" # pragma: no cover
151+
152+
with _run_assert(gate, lower):
153+
with pytest.raises(SystemExit) as exc_info:
154+
gate._assert_sdk_version()
155+
assert exc_info.value.code == 2
156+
captured = capsys.readouterr()
157+
assert "hyperliquid-python-sdk" in captured.err
158+
assert lower in captured.err
159+
# Remediation hint must be present.
160+
assert "pip install" in captured.err
161+
162+
def test_package_not_installed_exits_with_status_2(self, gate, capsys):
163+
"""Missing SDK package → SystemExit(2)."""
164+
with _run_assert(gate, PackageNotFoundError("hyperliquid-python-sdk")):
165+
with pytest.raises(SystemExit) as exc_info:
166+
gate._assert_sdk_version()
167+
assert exc_info.value.code == 2
168+
captured = capsys.readouterr()
169+
assert "not installed" in captured.err
170+
assert "pip install" in captured.err
171+
172+
def test_pre_release_of_minimum_passes(self, gate, capsys):
173+
"""0.23.0rc1 → parses as (0, 23, 0); passes minimum (0, 23, 0)."""
174+
min_str = ".".join(str(n) for n in gate.MINIMUM_HYPERLIQUID_SDK_VERSION)
175+
with _run_assert(gate, f"{min_str}rc1"):
176+
gate._assert_sdk_version()
177+
captured = capsys.readouterr()
178+
assert captured.err == ""
179+
180+
181+
# ---------------------------------------------------------------------------
182+
# Constant invariant
183+
# ---------------------------------------------------------------------------
184+
185+
class TestMinimumConstant:
186+
def test_constant_is_three_tuple_of_ints(self, gate):
187+
v = gate.MINIMUM_HYPERLIQUID_SDK_VERSION
188+
assert isinstance(v, tuple)
189+
assert len(v) == 3
190+
assert all(isinstance(n, int) and n >= 0 for n in v)
191+
192+
def test_constant_matches_pyproject(self, gate):
193+
"""The gate's minimum must match the pin in pyproject.toml so that
194+
a `pip install .` deployment satisfies the gate by construction."""
195+
pyproject = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text()
196+
# Find a line like: "hyperliquid-python-sdk==X.Y.Z",
197+
pinned = None
198+
for raw in pyproject.splitlines():
199+
line = raw.strip().strip(",").strip('"').strip("'")
200+
if line.startswith("hyperliquid-python-sdk"):
201+
# Strip env-marker suffix (e.g. "; python_version>=3.11") just in case.
202+
line = line.split(";", 1)[0].strip()
203+
for sep in ("==", ">=", "~="):
204+
if sep in line:
205+
_, pinned_str = line.split(sep, 1)
206+
pinned = pinned_str.strip()
207+
break
208+
if pinned is not None:
209+
break
210+
assert pinned is not None, "could not find hyperliquid-python-sdk pin in pyproject.toml"
211+
pinned_tuple = gate._parse_version_tuple(pinned)
212+
assert pinned_tuple >= gate.MINIMUM_HYPERLIQUID_SDK_VERSION, (
213+
f"pyproject.toml pins {pinned} but bot.py requires "
214+
f">= {gate.MINIMUM_HYPERLIQUID_SDK_VERSION}"
215+
)

0 commit comments

Comments
 (0)