Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ user = User()

async def run_client():
# Use async context manager for automatic cleanup
async with OrderBookClient(user=user, base_url=API_BASE_URL) as client:
async with OrderBookClient(API_BASE_URL, default_user=user) as client:
print("Client initialized.")
# ... use client methods ...

Expand Down
2 changes: 1 addition & 1 deletion examples/decimals.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


async def main():
client = ClearingEngineClient(User(), CLEARING_ENGINE_HOST)
client = ClearingEngineClient(CLEARING_ENGINE_HOST, default_user=User())
assets: list[AssetIdentifier | str] = [
AssetIdentifier("0xf3c3351d6bd0098eeb33ca8f830faf2a141ea2e1@421614")
]
Expand Down
2 changes: 1 addition & 1 deletion examples/deposit.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async def main():
)

# Connect to the t+ clearing engine.
client = ClearingEngineClient(tplus_user, CLEARING_ENGINE_HOST)
client = ClearingEngineClient(CLEARING_ENGINE_HOST, default_user=tplus_user)

deposit_to_chain(blockchain_user, tplus_user)
await deposit_to_ce(tplus_user, client)
Expand Down
2 changes: 1 addition & 1 deletion examples/rest_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def main():
# Initialize client with the API base URL
# Using async context manager ensures the client connection is closed properly
try:
async with OrderBookClient(user, base_url=API_BASE_URL) as client:
async with OrderBookClient(API_BASE_URL, default_user=user) as client:
logger.info("Client initialized.")

# --- Simple GET Test First ---
Expand Down
2 changes: 1 addition & 1 deletion examples/settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async def init_settlement(client, tplus_user):

async def main():
tplus_user = load_user(USERNAME)
client = ClearingEngineClient(tplus_user, CLEARING_ENGINE_HOST)
client = ClearingEngineClient(CLEARING_ENGINE_HOST, default_user=tplus_user)
await init_settlement(client, tplus_user)


Expand Down
4 changes: 2 additions & 2 deletions examples/two_users_trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ async def main() -> None:
logger.info("Connecting to OMS at %s", API_BASE_URL)

async with (
OrderBookClient(user_a, base_url=API_BASE_URL) as client_a,
OrderBookClient(user_b, base_url=API_BASE_URL) as client_b,
OrderBookClient(API_BASE_URL, default_user=user_a) as client_a,
OrderBookClient(API_BASE_URL, default_user=user_b) as client_b,
):
# -------------------------------------------------------------------
# Ensure the market exists (idempotent – returns 409 if already there)
Expand Down
2 changes: 1 addition & 1 deletion examples/vaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

async def main():
tplus_user = load_user(USERNAME)
client = ClearingEngineClient(tplus_user, CLEARING_ENGINE_HOST)
client = ClearingEngineClient(CLEARING_ENGINE_HOST, default_user=tplus_user)
vault_addresses = await client.vaults.get()
pprint(vault_addresses)

Expand Down
2 changes: 1 addition & 1 deletion examples/websocket_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def main():
# Removed signal handling setup - Not supported on Windows default loop

try:
async with OrderBookClient(user, base_url=API_BASE_URL) as client:
async with OrderBookClient(API_BASE_URL, default_user=user) as client:
logger.info("Client initialized.")

# Create tasks for the stream listeners
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ ignore = [
"S501", # ADD IT LATER
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S106"]

[tool.ruff.lint.pydocstyle]
convention = "google"

Expand Down
242 changes: 242 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import asyncio
import time

import httpx
import pytest

from tplus.client.auth import Auth, AuthenticatedClient
from tplus.client.base import BaseClient, ClientSettings
from tplus.exceptions import MissingClientUserError
from tplus.model.types import UserPublicKey


class TestAuth:
def test_new_auth_is_expired(self):
auth = Auth()
assert auth.is_expired() is True

def test_auth_with_token_but_zero_expiry_is_expired(self):
auth = Auth(token="some-token")
assert auth.is_expired() is True

def test_auth_with_valid_token_not_expired(self):
auth = Auth(token="some-token")
# Set expiry far in the future.
auth.expiry_ns = time.time_ns() + (120 * 1_000_000_000)
assert auth.is_expired() is False

def test_auth_expired_within_safety_margin(self):
auth = Auth(token="some-token")
# Set expiry just 30s from now (within 60s safety margin).
auth.expiry_ns = time.time_ns() + (30 * 1_000_000_000)
assert auth.is_expired() is True

def test_auth_expired_exactly_at_margin(self):
auth = Auth(token="some-token")
# Set expiry exactly at the margin boundary.
auth.expiry_ns = time.time_ns() + Auth.SAFETY_MARGIN_NS
assert auth.is_expired() is True

def test_auth_has_lock(self):
auth = Auth()
assert isinstance(auth.lock, asyncio.Lock)


class TestBaseClient:
def _make_client(self, **settings_kwargs) -> BaseClient:
return BaseClient(ClientSettings(**settings_kwargs))

def test_constructor_with_settings(self):
client = self._make_client(base_url="http://localhost:9999")
assert isinstance(client._client, httpx.AsyncClient)
assert client._settings.base_url == "http://localhost:9999"

def test_constructor_with_url_string(self):
client = BaseClient("http://localhost:9999")
assert client._settings.base_url == "http://localhost:9999"

def test_from_client_shares_internals(self):
parent = self._make_client()
child = BaseClient.from_client(parent)
assert child._client is parent._client
assert child._settings is parent._settings

def test_validate_user_with_no_default_raises(self):
client = self._make_client()
with pytest.raises(MissingClientUserError):
client._validate_user()

def test_validate_user_returns_default(self):
class FakeUser:
public_key = "abc"

client = BaseClient(ClientSettings(), default_user=FakeUser()) # type: ignore
assert client._validate_user().public_key == "abc" # type: ignore

def test_validate_user_prefers_explicit(self):
class FakeUser:
public_key = "abc"

class OtherUser:
public_key = "xyz"

client = BaseClient(ClientSettings(), default_user=FakeUser()) # type: ignore
assert client._validate_user(user=OtherUser()).public_key == "xyz" # type: ignore

def test_validate_user_public_key_from_string(self):
key = UserPublicKey("ab" * 32)
client = BaseClient(ClientSettings())
assert client._validate_user_public_key(key) == key

def test_validate_user_public_key_from_user(self):
from tplus.utils.user import User

user = User()
client = BaseClient(ClientSettings())
assert client._validate_user_public_key(user) == user.public_key

def test_validate_user_public_key_no_default_raises(self):
client = self._make_client()
with pytest.raises(MissingClientUserError):
client._validate_user_public_key()

def test_get_request_headers_returns_settings_headers(self):
client = self._make_client()
headers = client._get_request_headers()
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"

def test_get_request_headers_returns_copy(self):
client = self._make_client()
h1 = client._get_request_headers()
h1["X-Custom"] = "foo"
h2 = client._get_request_headers()
assert "X-Custom" not in h2

def test_get_websocket_url_ws(self):
client = self._make_client(base_url="http://localhost:3032")
assert client._get_websocket_url("/stream") == "ws://localhost:3032/stream"

def test_get_websocket_url_wss(self):
client = self._make_client(base_url="https://example.com")
assert client._get_websocket_url("/stream") == "wss://example.com/stream"

def test_get_websocket_url_no_leading_slash(self):
client = self._make_client(base_url="http://localhost:3032")
assert client._get_websocket_url("stream") == "ws://localhost:3032/stream"

def test_handle_response_204(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(204, request=req)
client = self._make_client()
assert client._handle_response(resp) == {}

def test_handle_response_empty_body(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(200, request=req, content=b"")
client = self._make_client()
assert client._handle_response(resp) == {}

def test_handle_response_json(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(200, request=req, json={"key": "value"})
client = self._make_client()
assert client._handle_response(resp) == {"key": "value"}

def test_handle_response_json_null(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(
200, request=req, text="null", headers={"content-type": "application/json"}
)
client = self._make_client()
assert client._handle_response(resp) == {}

def test_handle_response_invalid_json(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(
200, request=req, text="not json", headers={"content-type": "application/json"}
)
client = self._make_client()
with pytest.raises(ValueError, match="Invalid JSON"):
client._handle_response(resp)

def test_handle_response_http_error(self):
req = httpx.Request("GET", "http://example.com/test")
resp = httpx.Response(500, request=req, text="server error")
client = self._make_client()
with pytest.raises(httpx.HTTPStatusError):
client._handle_response(resp)


class TestAuthenticatedClient:
def _make_client(self, auth=None, default_user=None) -> AuthenticatedClient:
return AuthenticatedClient(ClientSettings(), default_user=default_user, auth=auth)

def test_default_auth_created(self):
client = self._make_client()
assert isinstance(client._auth, Auth)
assert client._auth.is_expired() is True

def test_custom_auth_used(self):
auth = Auth(token="pre-set")
client = self._make_client(auth=auth)
assert client._auth is auth
assert client._auth.token == "pre-set"

def test_none_auth_creates_default(self):
client = self._make_client(auth=None)
assert isinstance(client._auth, Auth)

def test_get_request_headers_no_token(self):
client = self._make_client()
headers = client._get_request_headers()
assert "Authorization" not in headers
assert headers["Content-Type"] == "application/json"

def test_get_auth_headers_with_token(self):
class FakeUser:
public_key = "user123"

auth = Auth(token="my-token")
client = self._make_client(auth=auth, default_user=FakeUser()) # type: ignore
headers = client._get_auth_headers()
assert headers["Authorization"] == "Bearer my-token"
assert headers["User-Id"] == "user123"

def test_get_auth_headers_no_token(self):
client = self._make_client()
assert client._get_auth_headers() == {}

def test_from_client_preserves_type(self):
parent = self._make_client()
child = AuthenticatedClient.from_client(parent)
assert isinstance(child, AuthenticatedClient)


class TestClientSettings:
def test_defaults(self):
settings = ClientSettings()
assert settings.base_url == "http://localhost:3032"
assert settings.timeout == 10.0
assert settings.insecure_ssl is False
assert settings.verify_requests is True

def test_insecure_ssl(self):
settings = ClientSettings(insecure_ssl=True)
assert settings.verify_requests is False

def test_parsed_base_url(self):
settings = ClientSettings(base_url="https://api.example.com:8080")
parsed = settings.parsed_base_url
assert parsed.scheme == "https"
assert parsed.hostname == "api.example.com"
assert parsed.port == 8080

def test_custom_headers(self):
settings = ClientSettings(headers={"X-Custom": "value"})
assert settings.headers == {"X-Custom": "value"}

def test_from_url(self):
settings = ClientSettings.from_url("http://example.com", insecure_ssl=True)
assert settings.base_url == "http://example.com"
assert settings.insecure_ssl is True
5 changes: 4 additions & 1 deletion tests/client/test_orderbook_control_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ async def _ensure_control_ws(self) -> None:
class DummyUser:
public_key = "USER"

client = DummyClient(user=DummyUser(), base_url="http://example.com") # type: ignore
client = DummyClient(
"http://example.com",
default_user=DummyUser(), # type: ignore
)
client._use_ws_control = True

order_id = "abc"
Expand Down
2 changes: 1 addition & 1 deletion tests/client/test_orders_404.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ async def _request(self, method, endpoint, json_data=None, params=None):
class DummyUser:
public_key = "USER"

client = DummyClient(user=DummyUser(), base_url="http://example.com") # type: ignore
client = DummyClient("http://example.com", default_user=DummyUser()) # type: ignore
orders = await client.get_user_orders_for_book(
asset_id=type("A", (), {"__str__": lambda self: "200"})()
)
Expand Down
21 changes: 7 additions & 14 deletions tests/client/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,23 @@

@pytest.mark.anyio
async def test_http_client_verify_flag(monkeypatch):
from tplus.client.base import BaseClient
from tplus.client.base import BaseClient, ClientSettings

class DummyUser:
public_key = "USER"

c = BaseClient(user=DummyUser(), base_url="http://localhost") # type: ignore
settings = ClientSettings(base_url="http://localhost")
c = BaseClient(settings)
try:
# Insecure is False by default → default httpx AsyncClient verifies certs by default
# We cannot access private verify attribute reliably; ensure auth headers would carry token when set
assert isinstance(c._client, type(c._client))
finally:
await c.close()


@pytest.mark.anyio
async def test_http_client_insecure_ssl_disables_verify(monkeypatch):
from tplus.client.base import BaseClient

class DummyUser:
public_key = "USER"
from tplus.client.base import BaseClient, ClientSettings

c = BaseClient(user=DummyUser(), base_url="http://localhost", insecure_ssl=True) # type: ignore
settings = ClientSettings(base_url="http://localhost", insecure_ssl=True)
c = BaseClient(settings)
try:
# Construction should succeed with insecure flag; httpx accepts verify=False
assert c._insecure_ssl is True
assert settings.insecure_ssl is True
finally:
await c.close()
2 changes: 1 addition & 1 deletion tests/integration/test_clearing_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def user() -> User:

@pytest.fixture(scope="module")
def clearing_engine(user):
return ClearingEngineClient(user, "http://127.0.0.1:3032")
return ClearingEngineClient.from_local(user)


@pytest.fixture(scope="module")
Expand Down
Loading
Loading