Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: CI

# Pull-request and push-to-main gates: lint, type-check, full test+coverage,
# static security analysis (bandit), and a dependency CVE audit (pip-audit).
#
# A weekly cron run keeps the dependency audit fresh — newly-disclosed CVEs
# get caught even on quiet weeks with no PR activity.
#
# Mirrors the gates available locally via `make lint / mypy / cov / security`.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
quality:
name: Quality gate (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Sync dependencies (dev + security)
run: uv sync --group dev --group security --python ${{ matrix.python-version }}

- name: Lint (ruff)
run: uv run ruff check .

- name: Format check (ruff)
run: uv run ruff format --check .

- name: Type check (mypy)
run: uv run mypy --config-file mypy.ini --follow-imports=silent

- name: Tests with coverage
run: uv run pytest --cov=src --cov-report=term-missing tests/unit

- name: Static security analysis (bandit)
run: uv run bandit -r src -c pyproject.toml --severity-level high

- name: Dependency CVE audit (pip-audit)
run: uv run pip-audit --strict
9 changes: 9 additions & 0 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ jobs:
- name: Install build tooling
run: python -m pip install --upgrade build twine

# Defense-in-depth: refuse to publish if any production dep has a known
# CVE. CI runs the same audit on every PR (.github/workflows/ci.yml),
# but a vulnerability can be disclosed between the last green PR and the
# release tag — this step closes that window.
- name: Pre-publish dependency CVE audit (pip-audit)
run: |
python -m pip install --upgrade pip-audit
pip-audit --strict

- name: Build distributions
run: python -m build

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Thumbs.db

# Virtual environments
.venv/
.venv-py*/
venv/
env/
ENV/
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ Per-(chain_id, address) asyncio locks enforce sequential nonce acquisition. High

- **Python interpreter**: always use `.venv/bin/python` (never the system `python3`)
- **Package manager**: `uv` — not pip, not poetry
- **Python version**: >=3.12, <3.14 (uses match statements, PEP 695 generics)
- **Python version**: >=3.12, <3.15 — supported matrix is 3.12 / 3.13 / 3.14 (uses match statements, PEP 695 generics)
- **Setup**: `make setup` (runs `uv venv && uv sync --group dev`)
- **Test**: `make test` (unit, fast) / `make int` (integration, requires live env)
- **Lint**: `make lint` / `make lint-fix` (ruff, line-length=100)
- **Types**: `make mypy` (strict mode)
- **Coverage**: `make cov`
- **Security**: `make security` (bandit static analysis at `--severity-level high` + `pip-audit --strict`). CI mirrors the same gates on every PR/push to main and a weekly cron — see `.github/workflows/ci.yml`.

Unit tests in `tests/unit/` have no external dependencies. Integration tests in `tests/integration/` require a live API environment.

Expand All @@ -81,7 +82,8 @@ Unit tests in `tests/unit/` have no external dependencies. Integration tests in
- **Cache key generation**: Uses `(func_name, api_base_url, args[1:], frozenset(kwargs.items()))` — `self` is excluded; keys are namespaced by `api_base_url`. Kwarg ordering affects cache hits; deep objects may produce false misses.
- **Config validation timing**: `config.validate()` is called automatically inside `DexalotBaseClient.__init__`. Invalid configs raise at construction time.
- **Error sanitization is lossy**: Regex stripping makes production debugging harder. Use DEBUG logging in development.
- **Python 3.12+ is required**: CI must enforce this. Match statements and PEP 695 generics are used throughout.
- **Python 3.12+ is required**: CI must enforce this. Match statements and PEP 695 generics are used throughout. Supported matrix is 3.12 / 3.13 / 3.14 — `requires-python = ">=3.12,<3.15"` in `pyproject.toml`.
- **WebSocketManager captures the asyncio loop lazily**: `WebSocketManager.__init__` does *not* call `asyncio.get_event_loop()` (deprecated since 3.10, removed in 3.14). Instead, `_get_loop()` calls `asyncio.get_running_loop()` on the first sync entry point (`connect`/`subscribe`/`unsubscribe`), which works identically across 3.12/3.13/3.14. This also means the manager can be constructed outside a running event loop (useful in test fixtures and sync configuration code) — only the sync entry points require a running loop.
- **Cache key for multi-env**: Cache keys are namespaced by `api_base_url`, so simultaneous testnet/mainnet clients do not share cached data. Test suites that use module-level caches must clear them between tests (e.g. `_SEMI_STATIC_CACHE.clear()`) since the key is env-based, not instance-based.
- **`timestamped_auth` flag**: `_get_auth_headers` supports timestamped signing (`f"dexalot{ts}"` + `x-timestamp` header) via `config.timestamped_auth = True` (env: `DEXALOT_TIMESTAMPED_AUTH=true`). Defaults to `False` — the backend currently only accepts the static `"dexalot"` message. Enable only after backend confirms timestamp window validation. See remediation plan C-2.
- **Cache stampede protection**: `async_ttl_cached` coalesces concurrent callers for the same uncached key using `asyncio.Future`. Only the first caller fetches; the rest await the same future. Prevents thundering herd on cache misses.
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ RUFF := .venv/bin/ruff
MYPY := .venv/bin/mypy
MYPY_CONFIG := --config-file mypy.ini

.PHONY: setup test cov cov-file int int-file lint lint-fix format mypy typecheck clean docs-serve docs-build
.PHONY: setup test cov cov-file int int-file lint lint-fix format mypy typecheck security clean docs-serve docs-build

setup:
uv venv && uv sync --group dev
Expand Down Expand Up @@ -51,6 +51,13 @@ mypy:

typecheck: mypy

# Run static security analysis (bandit) and dependency CVE audit (pip-audit).
# Mirrors the gates that run in .github/workflows/ci.yml. Run before opening a
# PR to catch issues your downstream consumers would otherwise flag.
security:
uv run --group security bandit -r src -c pyproject.toml --severity-level high
uv run --group security pip-audit --strict

docs-serve:
uv run --group docs zensical serve

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ User-facing methods accept common symbol variants: surrounding whitespace is ign

## Installation

**Requirements:** Python 3.12, 3.13, or 3.14.

Install from PyPI:

```sh
Expand Down
2 changes: 1 addition & 1 deletion docs/python-sdk-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ End-to-end guide from installation to production usage of the Dexalot Python SDK

## Prerequisites & installation

**Python 3.12** is required (match statements and PEP 695 generics are used throughout).
**Python 3.12, 3.13, or 3.14** is supported (match statements and PEP 695 generics are used throughout).

```bash
pip install dexalot-sdk
Expand Down
18 changes: 16 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ license-files = [
version = "0.5.12"
description = "Dexalot Python SDK - Core library for Dexalot interaction"
readme = "README.md"
requires-python = ">=3.12,<3.14"
requires-python = ">=3.12,<3.15"
authors = [
{ name = "Dexalot" }
]
Expand All @@ -33,7 +33,7 @@ dependencies = [
"python-dotenv>=1.0,<2",
"eth-account>=0.11,<1",
"websockets>=13.0,<15",
"cryptography>=43,<45",
"cryptography>=46,<48",
]

[project.urls]
Expand All @@ -57,6 +57,13 @@ docs = [
"zensical>=0.0.29",
]

# Security audit tooling — kept out of `dev` so a normal contributor install
# stays lean. CI installs this group explicitly via `uv sync --group security`.
security = [
"bandit[toml]",
"pip-audit",
]

[project.scripts]
secrets-vault = "dexalot_sdk.scripts.secrets_vault_cli:main"

Expand Down Expand Up @@ -84,3 +91,10 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"

[tool.ruff.lint.mccabe]
max-complexity = 15

# bandit configuration — used by `make security` and the CI security gate.
# Skip rules that don't apply to a typed library: assert_used (we use asserts
# in tests only), and try_except_pass (intentional in some retry paths).
[tool.bandit]
exclude_dirs = ["tests", ".venv", ".venv-py313", ".venv-py314", "site", "dist", "docs"]
skips = ["B101", "B110"]
38 changes: 31 additions & 7 deletions src/dexalot_sdk/utils/websocket_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ def __init__(
self.config = config
self.logger = logger or get_logger(__name__)

# Store the running event loop at construction time. The SDK always
# instantiates WebSocketManager from async contexts (DexalotBaseClient
# methods are all async), so the loop is always available here.
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
# Capture the running event loop lazily at the first sync entry point
# (connect/subscribe/unsubscribe). Doing it here would call
# ``asyncio.get_event_loop()``, which is deprecated outside an async
# context and raises ``RuntimeError`` in Python 3.14.
# ``asyncio.get_running_loop()`` (used in ``_get_loop``) has the same
# semantics on 3.12/3.13/3.14: returns the running loop or raises.
self._loop: asyncio.AbstractEventLoop | None = None

# Connection state
self._state = ConnectionState.DISCONNECTED
Expand Down Expand Up @@ -82,6 +85,27 @@ def is_connected(self) -> bool:
# Sync public API (schedule work on the event loop)
# ------------------------------------------------------------------

def _get_loop(self) -> asyncio.AbstractEventLoop:
"""Return the asyncio loop used to schedule work, capturing it lazily.

The sync entry points below (``connect``, ``subscribe``, ``unsubscribe``)
all schedule coroutines on the running event loop. We capture the loop
on first use rather than at ``__init__`` time so that:

* Constructing a ``WebSocketManager`` outside an event loop is allowed
(e.g. in test fixtures or sync configuration code).
* Work is always scheduled on the loop that's actually running when the
sync entry point is called, not on whatever loop happened to be
current at construction time.

Raises ``RuntimeError`` if no loop is running, which is the same error
the underlying ``asyncio.get_running_loop()`` raises on every supported
Python version (3.12 / 3.13 / 3.14).
"""
if self._loop is None:
self._loop = asyncio.get_running_loop()
return self._loop

def connect(self) -> None:
"""
Start the WebSocket background task.
Expand All @@ -100,7 +124,7 @@ def connect(self) -> None:

self._state = ConnectionState.CONNECTING
self._should_reconnect = True
self._run_task = self._loop.create_task(self._run())
self._run_task = self._get_loop().create_task(self._run())

def subscribe(
self,
Expand All @@ -127,7 +151,7 @@ def subscribe(
self._subscriptions[subscription_key] = (callback, is_private, meta)

if self.is_connected and self._ws:
self._loop.create_task(self._send_subscribe(subscription_key))
self._get_loop().create_task(self._send_subscribe(subscription_key))
elif not self.is_connected:
self.connect()

Expand All @@ -138,7 +162,7 @@ def unsubscribe(self, subscription_key: str) -> None:
return
self.logger.info(f"Unsubscribed from: {subscription_key}")
if self.is_connected and self._ws:
self._loop.create_task(self._send_unsubscribe(subscription_key, spec))
self._get_loop().create_task(self._send_unsubscribe(subscription_key, spec))

# ------------------------------------------------------------------
# Async public API
Expand Down
36 changes: 34 additions & 2 deletions tests/unit/core/test_clob.py
Original file line number Diff line number Diff line change
Expand Up @@ -3824,12 +3824,21 @@ def mock_account(self):

@pytest.fixture
def manager(self, mock_config, mock_account):
"""Create a WebSocketManager instance."""
return WebSocketManager(
"""Create a WebSocketManager instance with a pre-injected mock loop.

``WebSocketManager`` captures the running asyncio loop lazily at the
first sync entry point (``connect``/``subscribe``/``unsubscribe``).
Synchronous tests below patch ``manager._loop.create_task`` directly
rather than spinning up a real loop, so we inject a mock here. Tests
that need the real lazy-capture path use ``@pytest.mark.asyncio``.
"""
m = WebSocketManager(
ws_url="wss://test.example.com/ws",
account=mock_account,
config=mock_config,
)
m._loop = MagicMock(spec=asyncio.AbstractEventLoop)
return m

# ------------------------------------------------------------------
# Initialization
Expand All @@ -3843,6 +3852,29 @@ def test_websocket_manager_initialization(self, manager, mock_config, mock_accou
assert manager.state == ConnectionState.DISCONNECTED
assert not manager.is_connected

async def test_get_loop_captures_running_loop_lazily(self, mock_config, mock_account):
"""First call to _get_loop captures the running loop; subsequent calls reuse it.

Bypasses the `manager` fixture (which pre-injects a mock loop) so we can
exercise the actual lazy-capture path against a real running loop.
"""
m = WebSocketManager(
ws_url="wss://test.example.com/ws",
account=mock_account,
config=mock_config,
)

loop = m._get_loop()
assert loop is asyncio.get_running_loop()
assert m._loop is loop # captured on first call
assert m._get_loop() is loop # cached on subsequent calls

def test_get_loop_raises_when_no_running_loop(self, manager):
"""_get_loop propagates RuntimeError when called outside an async context."""
manager._loop = None # undo the fixture's mock-loop injection
with pytest.raises(RuntimeError):
manager._get_loop()

# ------------------------------------------------------------------
# connect() — sync entry point
# ------------------------------------------------------------------
Expand Down
Loading
Loading