diff --git a/README.md b/README.md index c5ec3de..8862ed8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/python/Cargo.lock b/python/Cargo.lock index 352cf9c..3be16ed 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -17,6 +17,28 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -873,12 +895,14 @@ dependencies = [ [[package]] name = "redis-cloud" -version = "0.7.6" +version = "0.8.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "base64", "chrono", + "futures-core", "reqwest", "serde", "serde_json", diff --git a/python/src/client.rs b/python/src/client.rs index 75a4100..4c7d0a5 100644 --- a/python/src/client.rs +++ b/python/src/client.rs @@ -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; @@ -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> { + 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> { + 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> { + 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> { + 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 @@ -350,3 +420,7 @@ pub fn py_to_json(py: Python<'_>, obj: Py) -> PyResult )) } } + +// 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. diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..b186e32 --- /dev/null +++ b/python/tests/__init__.py @@ -0,0 +1 @@ +# Python tests for redis-cloud bindings diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 0000000..6a03914 --- /dev/null +++ b/python/tests/test_client.py @@ -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)