From 50f5ac64466c0b861461b1bfa89a122beecedbe1 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 10:13:43 -0400 Subject: [PATCH 1/6] Adds optional state_directory parameter to clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements configurable state directory support in ReplicatedClient and AsyncReplicatedClient, allowing users to override platform-specific defaults. The StateManager now accepts an optional state_directory parameter and normalizes paths using expanduser() and resolve() to handle tilde expansion and relative paths correctly. This enables use cases like testing with temporary directories, containerized deployments with mounted volumes, multi-tenant applications, and development with project-local state while maintaining full backward compatibility with existing code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/async_client.py | 6 ++++-- replicated/client.py | 6 ++++-- replicated/state.py | 11 +++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/replicated/async_client.py b/replicated/async_client.py index 6a34ace..cc69589 100644 --- a/replicated/async_client.py +++ b/replicated/async_client.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional from .http_client import AsyncHTTPClient from .services import AsyncCustomerService @@ -14,17 +14,19 @@ def __init__( app_slug: str, base_url: str = "https://replicated.app", timeout: float = 30.0, + state_directory: Optional[str] = None, ) -> None: self.publishable_key = publishable_key self.app_slug = app_slug self.base_url = base_url self.timeout = timeout + self.state_directory = state_directory self.http_client = AsyncHTTPClient( base_url=base_url, timeout=timeout, ) - self.state_manager = StateManager(app_slug) + self.state_manager = StateManager(app_slug, state_directory=state_directory) self.customer = AsyncCustomerService(self) async def __aenter__(self) -> "AsyncReplicatedClient": diff --git a/replicated/client.py b/replicated/client.py index 7ea7d25..cec7d1c 100644 --- a/replicated/client.py +++ b/replicated/client.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, Optional from .http_client import SyncHTTPClient from .services import CustomerService @@ -14,17 +14,19 @@ def __init__( app_slug: str, base_url: str = "https://replicated.app", timeout: float = 30.0, + state_directory: Optional[str] = None, ) -> None: self.publishable_key = publishable_key self.app_slug = app_slug self.base_url = base_url self.timeout = timeout + self.state_directory = state_directory self.http_client = SyncHTTPClient( base_url=base_url, timeout=timeout, ) - self.state_manager = StateManager(app_slug) + self.state_manager = StateManager(app_slug, state_directory=state_directory) self.customer = CustomerService(self) def __enter__(self) -> "ReplicatedClient": diff --git a/replicated/state.py b/replicated/state.py index 84dfe9b..49fc27c 100644 --- a/replicated/state.py +++ b/replicated/state.py @@ -8,9 +8,16 @@ class StateManager: """Manages local SDK state for idempotency and caching.""" - def __init__(self, app_slug: str) -> None: + def __init__(self, app_slug: str, state_directory: Optional[str] = None) -> None: self.app_slug = app_slug - self._state_dir = self._get_state_directory() + + # Use provided directory or derive platform-specific one + if state_directory: + # Normalize path: expand ~ and resolve relative paths + self._state_dir = Path(state_directory).expanduser().resolve() + else: + self._state_dir = self._get_state_directory() + self._state_file = self._state_dir / "state.json" self._ensure_state_dir() From 1f64e9295bc66905c82d03ef3383eb1ffa5f6631 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 10:14:24 -0400 Subject: [PATCH 2/6] Adds tests for custom state directory feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive test coverage for the state_directory parameter: - Absolute path handling and directory creation - Tilde expansion to home directory - Relative path resolution to absolute paths - Backward compatibility with default platform-specific directories Tests cover both sync and async client implementations to ensure consistent behavior across both APIs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/test_client.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index b103831..f7aa33f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,6 @@ +import os +import tempfile +from pathlib import Path from unittest.mock import Mock, patch import pytest @@ -40,6 +43,59 @@ def test_customer_creation(self, mock_httpx): assert customer.customer_id == "customer_123" assert customer.email_address == "test@example.com" + def test_custom_state_directory(self): + """Test client with custom absolute state directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_dir = Path(tmpdir) / "custom_state" + client = ReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory=str(custom_dir), + ) + # Resolve both paths to handle symlinks + # (e.g., /var vs /private/var on macOS) + assert client.state_manager._state_dir == custom_dir.resolve() + expected_file = custom_dir.resolve() / "state.json" + assert client.state_manager._state_file == expected_file + assert custom_dir.exists() + + def test_custom_state_directory_with_tilde(self): + """Test that ~ expansion works in custom state directory.""" + client = ReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory="~/test-replicated-state", + ) + # Should be expanded to actual home directory + assert "~" not in str(client.state_manager._state_dir) + assert str(client.state_manager._state_dir).startswith(str(Path.home())) + + def test_custom_state_directory_relative_path(self): + """Test that relative paths are resolved in custom state directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Change to temp directory and use relative path + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + client = ReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory="./relative_state", + ) + # Should be resolved to absolute path + assert client.state_manager._state_dir.is_absolute() + assert str(tmpdir) in str(client.state_manager._state_dir) + finally: + os.chdir(original_cwd) + + def test_default_state_directory_unchanged(self): + """Test that default behavior is unchanged when state_directory not provided.""" + client = ReplicatedClient(publishable_key="pk_test_123", app_slug="my-app") + # Should use platform-specific directory + state_dir_str = str(client.state_manager._state_dir) + assert "my-app" in state_dir_str + assert "Replicated" in state_dir_str + class TestAsyncReplicatedClient: @pytest.mark.asyncio @@ -54,3 +110,28 @@ async def test_context_manager(self): publishable_key="pk_test_123", app_slug="my-app" ) as client: assert client is not None + + @pytest.mark.asyncio + async def test_custom_state_directory(self): + """Test async client with custom state directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + custom_dir = Path(tmpdir) / "custom_state" + client = AsyncReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory=str(custom_dir), + ) + # Resolve both paths to handle symlinks + # (e.g., /var vs /private/var on macOS) + assert client.state_manager._state_dir == custom_dir.resolve() + expected_file = custom_dir.resolve() / "state.json" + assert client.state_manager._state_file == expected_file + assert custom_dir.exists() + + @pytest.mark.asyncio + async def test_default_state_directory_unchanged(self): + """Test that async client default behavior is unchanged.""" + client = AsyncReplicatedClient(publishable_key="pk_test_123", app_slug="my-app") + state_dir_str = str(client.state_manager._state_dir) + assert "my-app" in state_dir_str + assert "Replicated" in state_dir_str From 6281caf399660a4172d036ff9e823130785e2e23 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 10:14:33 -0400 Subject: [PATCH 3/6] Documents custom state directory feature and use cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates README.md with practical examples showing how to use custom state directories with absolute paths, tilde expansion, and relative paths. Adds API_REFERENCE.md documentation detailing the state_directory parameter, path handling behavior, and common use cases (testing, containers, multi-tenant apps, development environments). Clarifies that this feature is optional and defaults maintain backward compatibility with platform-specific directories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- API_REFERENCE.md | 33 ++++++++++++++++++++++++++++----- README.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/API_REFERENCE.md b/API_REFERENCE.md index fbf9fd8..078cb12 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -56,15 +56,17 @@ ReplicatedClient( publishable_key: str, app_slug: str, base_url: str = "https://replicated.app", - timeout: float = 30.0 + timeout: float = 30.0, + state_directory: Optional[str] = None ) ``` **Parameters:** - `publishable_key`: Your publishable API key from the Vendor Portal - `app_slug`: Your application slug -- `base_url`: Base URL for the API (optional) -- `timeout`: Request timeout in seconds (optional) +- `base_url`: Base URL for the API (optional, defaults to "https://replicated.app") +- `timeout`: Request timeout in seconds (optional, defaults to 30.0) +- `state_directory`: Custom directory for state storage (optional). If not provided, uses platform-specific defaults. Supports `~` expansion and relative paths. #### Methods @@ -76,7 +78,7 @@ The asynchronous version of ReplicatedClient with identical API but requiring `a #### Constructor -Same parameters as `ReplicatedClient`. +Same parameters as `ReplicatedClient`, including the optional `state_directory`. #### Context Manager @@ -160,11 +162,32 @@ The SDK automatically manages local state for: ### State Directory -State is stored in platform-specific directories: +State is stored in platform-specific directories by default: - **macOS:** `~/Library/Application Support/Replicated/` - **Linux:** `${XDG_STATE_HOME:-~/.local/state}/replicated/` - **Windows:** `%APPDATA%\Replicated\` +You can override the state directory by providing the `state_directory` parameter: + +```python +client = ReplicatedClient( + publishable_key="...", + app_slug="my-app", + state_directory="/custom/path/to/state" +) +``` + +**Path Handling:** +- The SDK automatically expands `~` to your home directory +- Relative paths are resolved to absolute paths +- The directory will be created automatically if it doesn't exist + +**Use Cases for Custom Directories:** +- **Testing:** Use temporary directories for isolated test runs +- **Containers:** Mount persistent volumes at custom paths +- **Multi-tenant:** Isolate state per tenant in separate directories +- **Development:** Use project-local directories for development state + ## Machine Fingerprinting The SDK generates unique machine fingerprints using: diff --git a/README.md b/README.md index 6fd5f6a..de87794 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,34 @@ instance.set_status(InstanceStatus.RUNNING) instance.set_version("1.2.0") ``` +### Custom State Directory + +By default, the SDK stores state in platform-specific directories. You can override this for testing, containerization, or custom deployments: + +```python +from replicated import ReplicatedClient + +# Use a custom directory (supports ~ and relative paths) +client = ReplicatedClient( + publishable_key="replicated_pk_...", + app_slug="my-app", + state_directory="/var/lib/my-app/replicated-state" +) + +# Or use a relative path (will be resolved to absolute) +client = ReplicatedClient( + publishable_key="replicated_pk_...", + app_slug="my-app", + state_directory="./local-state" +) +``` + +**When to use custom state directories:** +- Testing with temporary directories +- Docker containers with mounted volumes +- Multi-tenant applications requiring isolated state +- Development with project-local state + ### Async Example ```python From e7a678a3099bb863927883fd741757c1feec061d Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 11:22:00 -0400 Subject: [PATCH 4/6] Adds missing async client tests for tilde and relative paths Ensures test parity between sync and async clients by adding: - test_custom_state_directory_with_tilde for AsyncReplicatedClient - test_custom_state_directory_relative_path for AsyncReplicatedClient Both clients now have identical test coverage (4 custom state directory tests each), verifying path normalization works consistently across sync and async implementations. --- tests/test_client.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index f7aa33f..8f3e579 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -128,6 +128,37 @@ async def test_custom_state_directory(self): assert client.state_manager._state_file == expected_file assert custom_dir.exists() + @pytest.mark.asyncio + async def test_custom_state_directory_with_tilde(self): + """Test that ~ expansion works in async client custom state directory.""" + client = AsyncReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory="~/test-replicated-state", + ) + # Should be expanded to actual home directory + assert "~" not in str(client.state_manager._state_dir) + assert str(client.state_manager._state_dir).startswith(str(Path.home())) + + @pytest.mark.asyncio + async def test_custom_state_directory_relative_path(self): + """Test that relative paths are resolved in async client.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Change to temp directory and use relative path + original_cwd = os.getcwd() + try: + os.chdir(tmpdir) + client = AsyncReplicatedClient( + publishable_key="pk_test_123", + app_slug="my-app", + state_directory="./relative_state", + ) + # Should be resolved to absolute path + assert client.state_manager._state_dir.is_absolute() + assert str(tmpdir) in str(client.state_manager._state_dir) + finally: + os.chdir(original_cwd) + @pytest.mark.asyncio async def test_default_state_directory_unchanged(self): """Test that async client default behavior is unchanged.""" From c18fa0eabedc6224fcca2423017cdbed9709a06a Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 11:22:29 -0400 Subject: [PATCH 5/6] Removes AI slop --- API_REFERENCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/API_REFERENCE.md b/API_REFERENCE.md index 078cb12..ffb1151 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -78,7 +78,7 @@ The asynchronous version of ReplicatedClient with identical API but requiring `a #### Constructor -Same parameters as `ReplicatedClient`, including the optional `state_directory`. +Same parameters as `ReplicatedClient` #### Context Manager @@ -230,4 +230,4 @@ async with AsyncReplicatedClient(...) as client: ## Thread Safety -The synchronous client creates a new HTTP client per request and is thread-safe. For high-concurrency applications, consider using the async client with a single event loop. \ No newline at end of file +The synchronous client creates a new HTTP client per request and is thread-safe. For high-concurrency applications, consider using the async client with a single event loop. From 88660c6472e511a34bb21be67d853cf80c4f4ea4 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Mon, 13 Oct 2025 11:23:51 -0400 Subject: [PATCH 6/6] Fixes punctuation --- API_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API_REFERENCE.md b/API_REFERENCE.md index ffb1151..4dbb741 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -78,7 +78,7 @@ The asynchronous version of ReplicatedClient with identical API but requiring `a #### Constructor -Same parameters as `ReplicatedClient` +Same parameters as `ReplicatedClient`. #### Context Manager