diff --git a/API_REFERENCE.md b/API_REFERENCE.md index fbf9fd8..4dbb741 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 @@ -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: @@ -207,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. 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 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() diff --git a/tests/test_client.py b/tests/test_client.py index b103831..8f3e579 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,59 @@ 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_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.""" + 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