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
33 changes: 28 additions & 5 deletions API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<app_slug>`
- **Linux:** `${XDG_STATE_HOME:-~/.local/state}/replicated/<app_slug>`
- **Windows:** `%APPDATA%\Replicated\<app_slug>`

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:
Expand Down Expand Up @@ -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.
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.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions replicated/async_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict
from typing import Any, Dict, Optional

from .http_client import AsyncHTTPClient
from .services import AsyncCustomerService
Expand All @@ -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":
Expand Down
6 changes: 4 additions & 2 deletions replicated/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict
from typing import Any, Dict, Optional

from .http_client import SyncHTTPClient
from .services import CustomerService
Expand All @@ -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":
Expand Down
11 changes: 9 additions & 2 deletions replicated/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
112 changes: 112 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch

import pytest
Expand Down Expand Up @@ -40,6 +43,59 @@ def test_customer_creation(self, mock_httpx):
assert customer.customer_id == "customer_123"
assert customer.email_address == "[email protected]"

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
Expand All @@ -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