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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,19 @@ subs = client.subscriptions_sync()

- `CloudClient(api_key, api_secret, base_url=None, timeout_secs=None)`
- `CloudClient.from_env()` - Create from environment variables
- `client.timeout` - Get configured timeout in seconds (property)

#### Account
- `account()` / `account_sync()` - Get current account information

#### Subscriptions
- `subscriptions()` / `subscriptions_sync()` - List all subscriptions
- `subscription(id)` / `subscription_sync(id)` - Get subscription by ID

#### Databases
- `databases(subscription_id)` / `databases_sync(subscription_id)` - List databases
- `database(subscription_id, database_id)` / `database_sync(subscription_id, database_id)` - Get database
- `databases(subscription_id, offset=None, limit=None)` / `databases_sync(...)` - List databases (paginated)
- `database(subscription_id, database_id)` / `database_sync(...)` - Get database
- `all_databases(subscription_id)` / `all_databases_sync(...)` - Get all databases (auto-pagination)

#### Raw API
- `get(path)` / `get_sync(path)` - Raw GET request
Expand Down
26 changes: 25 additions & 1 deletion python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 75 additions & 1 deletion python/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::error::IntoPyResult;
use crate::runtime::{block_on, future_into_py};
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use redis_cloud::{CloudClient, DatabaseHandler, SubscriptionHandler};
use redis_cloud::{AccountHandler, CloudClient, DatabaseHandler, SubscriptionHandler};
use std::sync::Arc;
use std::time::Duration;

Expand Down Expand Up @@ -286,6 +286,76 @@ impl PyCloudClient {
})?;
Ok(json_to_py(py, result))
}

// Properties

/// Get the configured timeout in seconds
#[getter]
fn timeout(&self) -> f64 {
self.client.timeout().as_secs_f64()
}

// Account API

/// Get current account information (async)
fn account<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let client = self.client.clone();
future_into_py(py, async move {
let handler = AccountHandler::new((*client).clone());
let account = handler.get_current_account().await.into_py_result()?;
let json = serde_json::to_value(&account)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Python::with_gil(|py| Ok(json_to_py(py, json)))
})
}

/// Get current account information (sync)
fn account_sync(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let client = self.client.clone();
let result = block_on(py, async move {
let handler = AccountHandler::new((*client).clone());
handler.get_current_account().await.into_py_result()
})?;
let json = serde_json::to_value(&result)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Ok(json_to_py(py, json))
}

// Pagination helpers

/// Get all databases in a subscription with automatic pagination (async)
fn all_databases<'py>(
&self,
py: Python<'py>,
subscription_id: i64,
) -> PyResult<Bound<'py, PyAny>> {
let client = self.client.clone();
future_into_py(py, async move {
let handler = DatabaseHandler::new((*client).clone());
let dbs = handler
.get_all_databases(subscription_id as i32)
.await
.into_py_result()?;
let json = serde_json::to_value(&dbs)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Python::with_gil(|py| Ok(json_to_py(py, json)))
})
}

/// Get all databases in a subscription with automatic pagination (sync)
fn all_databases_sync(&self, py: Python<'_>, subscription_id: i64) -> PyResult<Py<PyAny>> {
let client = self.client.clone();
let result = block_on(py, async move {
let handler = DatabaseHandler::new((*client).clone());
handler
.get_all_databases(subscription_id as i32)
.await
.into_py_result()
})?;
let json = serde_json::to_value(&result)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Ok(json_to_py(py, json))
}
}

/// Convert serde_json::Value to Python object
Expand Down Expand Up @@ -350,3 +420,7 @@ pub fn py_to_json(py: Python<'_>, obj: Py<PyAny>) -> PyResult<serde_json::Value>
))
}
}

// Note: Rust-side unit tests for json_to_py and py_to_json require linking
// against Python which is complex in pure Rust test context. These functions
// are tested via Python-side integration tests in tests/test_client.py instead.
1 change: 1 addition & 0 deletions python/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Python tests for redis-cloud bindings
180 changes: 180 additions & 0 deletions python/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Tests for the Redis Cloud Python client."""

import os
import pytest
from redis_cloud import CloudClient, RedisCloudError


class TestClientCreation:
"""Tests for client creation."""

def test_client_creation_with_credentials(self):
"""Test creating a client with explicit credentials."""
client = CloudClient(api_key="test-key", api_secret="test-secret")
assert client is not None

def test_client_creation_with_base_url(self):
"""Test creating a client with a custom base URL."""
client = CloudClient(
api_key="test-key",
api_secret="test-secret",
base_url="https://custom.api.example.com",
)
assert client is not None

def test_client_creation_with_timeout(self):
"""Test creating a client with a custom timeout."""
client = CloudClient(
api_key="test-key", api_secret="test-secret", timeout_secs=60
)
assert client is not None
assert client.timeout == 60.0

def test_client_timeout_default(self):
"""Test that timeout has a default value when not specified."""
client = CloudClient(api_key="test-key", api_secret="test-secret")
# Default timeout is set by the underlying Rust client
assert client.timeout > 0

def test_from_env_missing_api_key(self):
"""Test that from_env raises error when API key is missing."""
# Clear any existing env vars
for var in [
"REDIS_CLOUD_API_KEY",
"REDIS_CLOUD_ACCOUNT_KEY",
"REDIS_CLOUD_API_SECRET",
"REDIS_CLOUD_SECRET_KEY",
"REDIS_CLOUD_USER_KEY",
]:
os.environ.pop(var, None)

with pytest.raises(ValueError, match="API key not found"):
CloudClient.from_env()

def test_from_env_missing_api_secret(self):
"""Test that from_env raises error when API secret is missing."""
os.environ["REDIS_CLOUD_API_KEY"] = "test-key"
# Clear secret vars
for var in [
"REDIS_CLOUD_API_SECRET",
"REDIS_CLOUD_SECRET_KEY",
"REDIS_CLOUD_USER_KEY",
]:
os.environ.pop(var, None)

try:
with pytest.raises(ValueError, match="API secret not found"):
CloudClient.from_env()
finally:
os.environ.pop("REDIS_CLOUD_API_KEY", None)

def test_from_env_with_valid_credentials(self):
"""Test that from_env works with valid environment variables."""
os.environ["REDIS_CLOUD_API_KEY"] = "test-key"
os.environ["REDIS_CLOUD_API_SECRET"] = "test-secret"

try:
client = CloudClient.from_env()
assert client is not None
finally:
os.environ.pop("REDIS_CLOUD_API_KEY", None)
os.environ.pop("REDIS_CLOUD_API_SECRET", None)

def test_from_env_with_alternate_key_names(self):
"""Test that from_env works with alternate environment variable names."""
os.environ["REDIS_CLOUD_ACCOUNT_KEY"] = "test-key"
os.environ["REDIS_CLOUD_SECRET_KEY"] = "test-secret"

try:
client = CloudClient.from_env()
assert client is not None
finally:
os.environ.pop("REDIS_CLOUD_ACCOUNT_KEY", None)
os.environ.pop("REDIS_CLOUD_SECRET_KEY", None)


class TestClientMethods:
"""Tests for client methods (without actual API calls)."""

@pytest.fixture
def client(self):
"""Create a client for testing."""
return CloudClient(api_key="test-key", api_secret="test-secret")

def test_client_has_subscriptions_method(self, client):
"""Test that client has subscriptions method."""
assert hasattr(client, "subscriptions")
assert hasattr(client, "subscriptions_sync")

def test_client_has_subscription_method(self, client):
"""Test that client has subscription method."""
assert hasattr(client, "subscription")
assert hasattr(client, "subscription_sync")

def test_client_has_databases_method(self, client):
"""Test that client has databases method."""
assert hasattr(client, "databases")
assert hasattr(client, "databases_sync")

def test_client_has_database_method(self, client):
"""Test that client has database method."""
assert hasattr(client, "database")
assert hasattr(client, "database_sync")

def test_client_has_all_databases_method(self, client):
"""Test that client has all_databases pagination helper."""
assert hasattr(client, "all_databases")
assert hasattr(client, "all_databases_sync")

def test_client_has_account_method(self, client):
"""Test that client has account method."""
assert hasattr(client, "account")
assert hasattr(client, "account_sync")

def test_client_has_raw_methods(self, client):
"""Test that client has raw HTTP methods."""
assert hasattr(client, "get")
assert hasattr(client, "get_sync")
assert hasattr(client, "post")
assert hasattr(client, "post_sync")
assert hasattr(client, "delete")
assert hasattr(client, "delete_sync")

def test_client_has_timeout_property(self, client):
"""Test that client has timeout property."""
assert hasattr(client, "timeout")


class TestErrorHandling:
"""Tests for error handling."""

def test_redis_cloud_error_exists(self):
"""Test that RedisCloudError is exported."""
assert RedisCloudError is not None

def test_redis_cloud_error_is_exception(self):
"""Test that RedisCloudError is an Exception subclass."""
assert issubclass(RedisCloudError, Exception)


class TestModuleExports:
"""Tests for module exports."""

def test_cloud_client_exported(self):
"""Test that CloudClient is exported."""
from redis_cloud import CloudClient

assert CloudClient is not None

def test_redis_cloud_error_exported(self):
"""Test that RedisCloudError is exported."""
from redis_cloud import RedisCloudError

assert RedisCloudError is not None

def test_version_exported(self):
"""Test that __version__ is exported."""
import redis_cloud

assert hasattr(redis_cloud, "__version__")
assert isinstance(redis_cloud.__version__, str)