|
| 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