diff --git a/README.md b/README.md index 7383868..36d4229 100644 --- a/README.md +++ b/README.md @@ -130,15 +130,16 @@ All examples read credentials from `REDIS_CLOUD_API_KEY` / `REDIS_CLOUD_API_SECR ## Python Bindings -A thin PyO3 binding covering a subset of read operations is published at +A PyO3 binding providing a convenience layer covering read operations across +all major API domains is published at [redis-cloud on PyPI](https://pypi.org/project/redis-cloud/). See -[`python/README.md`](python/README.md) for the supported API. +[`python/README.md`](python/README.md) for the full supported API, the parity +scope decision, and what is intentionally out of scope (connectivity handlers, +cost reports, and write operations — all reachable via the raw HTTP helpers or +the Rust client). The PyPI publish workflow has been failing since the `reqwest 0.13` upgrade -([#48](https://github.com/redis-developer/redis-cloud-rs/issues/48)) and the -overall scope is still being scoped under -[#66](https://github.com/redis-developer/redis-cloud-rs/issues/66) — treat -the Python surface as experimental for now. +([#48](https://github.com/redis-developer/redis-cloud-rs/issues/48)). ## API Coverage diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..31e3e15 --- /dev/null +++ b/python/README.md @@ -0,0 +1,111 @@ +# redis-cloud (Python) + +PyO3-based Python bindings for the [`redis-cloud`](https://crates.io/crates/redis-cloud) +Rust client for the Redis Cloud REST API. + +## Parity scope + +**Python is a deliberately smaller convenience layer.** It covers read-oriented +operations across all major API domains — the methods you reach for when +scripting, building dashboards, or doing field-engineering work. Write +operations (create, update, delete) are available via the raw HTTP helpers +(`post`, `put`, `patch`, `delete`) or the full-featured Rust client. + +Every domain method comes in two flavors: + +- An **async** variant (e.g. `subscriptions()`), which returns an awaitable. +- A **sync** variant suffixed with `_sync` (e.g. `subscriptions_sync()`), which + blocks and returns the result directly. + +All methods return plain Python objects (dicts, lists, scalars) decoded from the +API's JSON responses. + +## Supported API coverage + +| Domain | Methods | +|--------|---------| +| Account | `account`, `account_sync` | +| Pro subscriptions | `subscriptions`, `subscriptions_sync`, `subscription`, `subscription_sync` | +| Pro databases | `databases`, `databases_sync`, `database`, `database_sync`, `all_databases`, `all_databases_sync` | +| Tasks | `tasks`, `tasks_sync`, `task`, `task_sync` | +| Users | `users`, `users_sync`, `user`, `user_sync` | +| ACL | `acl_redis_rules`, `acl_redis_rules_sync`, `acl_roles`, `acl_roles_sync`, `acl_users`, `acl_users_sync` | +| Cloud accounts | `cloud_accounts`, `cloud_accounts_sync`, `cloud_account`, `cloud_account_sync` | +| Essentials subscriptions | `fixed_subscriptions`, `fixed_subscriptions_sync`, `fixed_subscription`, `fixed_subscription_sync` | +| Essentials databases | `fixed_databases`, `fixed_databases_sync`, `fixed_database`, `fixed_database_sync` | +| Raw HTTP | `get`, `get_sync`, `post`, `post_sync`, `delete`, `delete_sync` | + +## Out of scope + +The following are intentionally deferred from the Python bindings for this +parity round. They are fully supported by the Rust client, and most can be +reached from Python via the raw HTTP helpers when needed: + +- **Write operations** (create / update / delete of subscriptions, databases, + users, etc.) — use `post`, `put`, `patch`, and `delete` with the relevant API + path, or the Rust client for typed request bodies. +- **Connectivity handlers** — VPC peering, Transit Gateway, Private Service + Connect (PSC), and Private Link. These are complex, write-heavy networking + flows better served by the Rust client. +- **Cost reports** (`cost_report`) — FOCUS-format billing exports. +- **Pagination beyond `all_databases`** — the Pro `all_databases` helper is the + one auto-paginating convenience exposed; other list endpoints return a single + page (use the raw helpers with `offset`/`limit` for manual paging). + +## Quick start + +```python +from redis_cloud import CloudClient + +# Construct from explicit credentials... +client = CloudClient(api_key="your-api-key", api_secret="your-api-secret") + +# ...or from environment variables +# (REDIS_CLOUD_API_KEY / REDIS_CLOUD_API_SECRET). +client = CloudClient.from_env() + +# Synchronous calls — block and return the decoded JSON. +account = client.account_sync() +subs = client.subscriptions_sync() + +for sub in subs.get("subscriptions", []): + print(sub["id"], sub["name"]) + for db in client.databases_sync(sub["id"]).get("subscription", []): + print(" ", db) + +# Async calls — await the awaitable. +import asyncio + +async def main(): + tasks = await client.tasks() + print(tasks) + +asyncio.run(main()) + +# Raw HTTP for anything not covered by a typed method, including writes. +created = client.post_sync("/subscriptions", {"name": "example"}) +``` + +## Installation + +From PyPI: + +```bash +pip install redis-cloud +``` + +### Building from source + +The extension is built with [maturin](https://www.maturin.rs/). From the +`python/` directory, inside an activated virtualenv: + +```bash +pip install maturin +maturin develop # build and install into the current virtualenv +``` + +To build a release wheel: + +```bash +maturin build --release +``` diff --git a/python/src/client.rs b/python/src/client.rs index 4c7d0a5..2bd6f50 100644 --- a/python/src/client.rs +++ b/python/src/client.rs @@ -4,7 +4,10 @@ use crate::error::IntoPyResult; use crate::runtime::{block_on, future_into_py}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; -use redis_cloud::{AccountHandler, CloudClient, DatabaseHandler, SubscriptionHandler}; +use redis_cloud::{ + AccountHandler, AclHandler, CloudAccountHandler, CloudClient, DatabaseHandler, + FixedDatabaseHandler, FixedSubscriptionHandler, SubscriptionHandler, TaskHandler, UserHandler, +}; use std::sync::Arc; use std::time::Duration; @@ -356,6 +359,373 @@ impl PyCloudClient { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; Ok(json_to_py(py, json)) } + + // Tasks API + + /// List all tasks (async) + fn tasks<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = TaskHandler::new((*client).clone()); + let result = handler.list().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List all tasks (sync) + fn tasks_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = TaskHandler::new((*client).clone()); + handler.list().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)) + } + + /// Get a specific task by ID (async) + fn task<'py>(&self, py: Python<'py>, task_id: String) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = TaskHandler::new((*client).clone()); + let result = handler.get(task_id).await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// Get a specific task by ID (sync) + fn task_sync(&self, py: Python<'_>, task_id: String) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = TaskHandler::new((*client).clone()); + handler.get(task_id).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)) + } + + // Users API + + /// List all users (async) + fn users<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = UserHandler::new((*client).clone()); + let result = handler.list().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List all users (sync) + fn users_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = UserHandler::new((*client).clone()); + handler.list().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)) + } + + /// Get a specific user by ID (async) + fn user<'py>(&self, py: Python<'py>, user_id: i64) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = UserHandler::new((*client).clone()); + let result = handler.get(user_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()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// Get a specific user by ID (sync) + fn user_sync(&self, py: Python<'_>, user_id: i64) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = UserHandler::new((*client).clone()); + handler.get(user_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)) + } + + // ACL API + + /// List ACL Redis rules (async) + fn acl_redis_rules<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = AclHandler::new((*client).clone()); + let result = handler.list_redis_rules().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List ACL Redis rules (sync) + fn acl_redis_rules_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = AclHandler::new((*client).clone()); + handler.list_redis_rules().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)) + } + + /// List ACL roles (async) + fn acl_roles<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = AclHandler::new((*client).clone()); + let result = handler.list_roles().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List ACL roles (sync) + fn acl_roles_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = AclHandler::new((*client).clone()); + handler.list_roles().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)) + } + + /// List ACL users (async) + fn acl_users<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = AclHandler::new((*client).clone()); + let result = handler.list_acl_users().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List ACL users (sync) + fn acl_users_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = AclHandler::new((*client).clone()); + handler.list_acl_users().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)) + } + + // Cloud accounts API + + /// List all cloud accounts (async) + fn cloud_accounts<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = CloudAccountHandler::new((*client).clone()); + let result = handler.list().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List all cloud accounts (sync) + fn cloud_accounts_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = CloudAccountHandler::new((*client).clone()); + handler.list().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)) + } + + /// Get a specific cloud account by ID (async) + fn cloud_account<'py>( + &self, + py: Python<'py>, + cloud_account_id: i64, + ) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = CloudAccountHandler::new((*client).clone()); + let result = handler + .get(cloud_account_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()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// Get a specific cloud account by ID (sync) + fn cloud_account_sync(&self, py: Python<'_>, cloud_account_id: i64) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = CloudAccountHandler::new((*client).clone()); + handler.get(cloud_account_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)) + } + + // Fixed (Essentials) subscriptions API + + /// List all Essentials (fixed) subscriptions (async) + fn fixed_subscriptions<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = FixedSubscriptionHandler::new((*client).clone()); + let result = handler.list().await.into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List all Essentials (fixed) subscriptions (sync) + fn fixed_subscriptions_sync(&self, py: Python<'_>) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = FixedSubscriptionHandler::new((*client).clone()); + handler.list().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)) + } + + /// Get a specific Essentials (fixed) subscription by ID (async) + fn fixed_subscription<'py>( + &self, + py: Python<'py>, + subscription_id: i64, + ) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = FixedSubscriptionHandler::new((*client).clone()); + let result = handler + .get_by_id(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()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// Get a specific Essentials (fixed) subscription by ID (sync) + fn fixed_subscription_sync(&self, py: Python<'_>, subscription_id: i64) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = FixedSubscriptionHandler::new((*client).clone()); + handler + .get_by_id(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)) + } + + // Fixed (Essentials) databases API + + /// List databases in an Essentials (fixed) subscription (async) + fn fixed_databases<'py>( + &self, + py: Python<'py>, + subscription_id: i64, + ) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = FixedDatabaseHandler::new((*client).clone()); + let result = handler + .list(subscription_id as i32, None, None) + .await + .into_py_result()?; + let json = serde_json::to_value(&result) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// List databases in an Essentials (fixed) subscription (sync) + fn fixed_databases_sync(&self, py: Python<'_>, subscription_id: i64) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = FixedDatabaseHandler::new((*client).clone()); + handler + .list(subscription_id as i32, None, None) + .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)) + } + + /// Get a specific database in an Essentials (fixed) subscription (async) + fn fixed_database<'py>( + &self, + py: Python<'py>, + subscription_id: i64, + database_id: i64, + ) -> PyResult> { + let client = self.client.clone(); + future_into_py(py, async move { + let handler = FixedDatabaseHandler::new((*client).clone()); + let result = handler + .get_by_id(subscription_id as i32, database_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()))?; + Python::with_gil(|py| Ok(json_to_py(py, json))) + }) + } + + /// Get a specific database in an Essentials (fixed) subscription (sync) + fn fixed_database_sync( + &self, + py: Python<'_>, + subscription_id: i64, + database_id: i64, + ) -> PyResult> { + let client = self.client.clone(); + let result = block_on(py, async move { + let handler = FixedDatabaseHandler::new((*client).clone()); + handler + .get_by_id(subscription_id as i32, database_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 diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 6a03914..d41c673 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -145,6 +145,80 @@ def test_client_has_timeout_property(self, client): assert hasattr(client, "timeout") +class TestNewDomainMethods: + """Tests that new domain methods are present on CloudClient.""" + + @pytest.fixture + def client(self): + """Create a client for testing.""" + return CloudClient(api_key="test-key", api_secret="test-secret") + + def test_client_has_tasks_methods(self, client): + """Test that client has tasks list method.""" + assert hasattr(client, "tasks") + assert hasattr(client, "tasks_sync") + + def test_client_has_task_method(self, client): + """Test that client has task get method.""" + assert hasattr(client, "task") + assert hasattr(client, "task_sync") + + def test_client_has_users_methods(self, client): + """Test that client has users list method.""" + assert hasattr(client, "users") + assert hasattr(client, "users_sync") + + def test_client_has_user_method(self, client): + """Test that client has user get method.""" + assert hasattr(client, "user") + assert hasattr(client, "user_sync") + + def test_client_has_acl_redis_rules_methods(self, client): + """Test that client has acl_redis_rules method.""" + assert hasattr(client, "acl_redis_rules") + assert hasattr(client, "acl_redis_rules_sync") + + def test_client_has_acl_roles_methods(self, client): + """Test that client has acl_roles method.""" + assert hasattr(client, "acl_roles") + assert hasattr(client, "acl_roles_sync") + + def test_client_has_acl_users_methods(self, client): + """Test that client has acl_users method.""" + assert hasattr(client, "acl_users") + assert hasattr(client, "acl_users_sync") + + def test_client_has_cloud_accounts_methods(self, client): + """Test that client has cloud_accounts list method.""" + assert hasattr(client, "cloud_accounts") + assert hasattr(client, "cloud_accounts_sync") + + def test_client_has_cloud_account_method(self, client): + """Test that client has cloud_account get method.""" + assert hasattr(client, "cloud_account") + assert hasattr(client, "cloud_account_sync") + + def test_client_has_fixed_subscriptions_methods(self, client): + """Test that client has fixed_subscriptions list method.""" + assert hasattr(client, "fixed_subscriptions") + assert hasattr(client, "fixed_subscriptions_sync") + + def test_client_has_fixed_subscription_method(self, client): + """Test that client has fixed_subscription get method.""" + assert hasattr(client, "fixed_subscription") + assert hasattr(client, "fixed_subscription_sync") + + def test_client_has_fixed_databases_methods(self, client): + """Test that client has fixed_databases list method.""" + assert hasattr(client, "fixed_databases") + assert hasattr(client, "fixed_databases_sync") + + def test_client_has_fixed_database_method(self, client): + """Test that client has fixed_database get method.""" + assert hasattr(client, "fixed_database") + assert hasattr(client, "fixed_database_sync") + + class TestErrorHandling: """Tests for error handling."""