Skip to content

Commit d5466ff

Browse files
authored
Adds configurable state directory to client initialization (#6)
TL;DR ----- Enables custom state directory configuration through an optional parameter Details ------- Adds an optional `state_directory` parameter to both `ReplicatedClient` and `AsyncReplicatedClient` to support scenarios where the OS-standard location is ephemeral. When omitted, the parameter defaults to None and the SDK uses existing platform-specific logic unchanged. When provided, the SDK normalizes the path by expanding tilde notation and resolving relative paths to absolute ones, ensuring consistent behavior across different path styles. This enhancement unblocks containerized deployments that need explicit volume mount points, enables proper test isolation without filesystem pollution, and supports multi-tenant architectures where state must remain separate between customers. The implementation maintains the SDK's silent failure philosophy for state operations, ensuring custom directories integrate seamlessly with existing error handling patterns.
1 parent 9a5e071 commit d5466ff

File tree

6 files changed

+185
-11
lines changed

6 files changed

+185
-11
lines changed

API_REFERENCE.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,17 @@ ReplicatedClient(
5656
publishable_key: str,
5757
app_slug: str,
5858
base_url: str = "https://replicated.app",
59-
timeout: float = 30.0
59+
timeout: float = 30.0,
60+
state_directory: Optional[str] = None
6061
)
6162
```
6263

6364
**Parameters:**
6465
- `publishable_key`: Your publishable API key from the Vendor Portal
6566
- `app_slug`: Your application slug
66-
- `base_url`: Base URL for the API (optional)
67-
- `timeout`: Request timeout in seconds (optional)
67+
- `base_url`: Base URL for the API (optional, defaults to "https://replicated.app")
68+
- `timeout`: Request timeout in seconds (optional, defaults to 30.0)
69+
- `state_directory`: Custom directory for state storage (optional). If not provided, uses platform-specific defaults. Supports `~` expansion and relative paths.
6870

6971
#### Methods
7072

@@ -160,11 +162,32 @@ The SDK automatically manages local state for:
160162

161163
### State Directory
162164

163-
State is stored in platform-specific directories:
165+
State is stored in platform-specific directories by default:
164166
- **macOS:** `~/Library/Application Support/Replicated/<app_slug>`
165167
- **Linux:** `${XDG_STATE_HOME:-~/.local/state}/replicated/<app_slug>`
166168
- **Windows:** `%APPDATA%\Replicated\<app_slug>`
167169

170+
You can override the state directory by providing the `state_directory` parameter:
171+
172+
```python
173+
client = ReplicatedClient(
174+
publishable_key="...",
175+
app_slug="my-app",
176+
state_directory="/custom/path/to/state"
177+
)
178+
```
179+
180+
**Path Handling:**
181+
- The SDK automatically expands `~` to your home directory
182+
- Relative paths are resolved to absolute paths
183+
- The directory will be created automatically if it doesn't exist
184+
185+
**Use Cases for Custom Directories:**
186+
- **Testing:** Use temporary directories for isolated test runs
187+
- **Containers:** Mount persistent volumes at custom paths
188+
- **Multi-tenant:** Isolate state per tenant in separate directories
189+
- **Development:** Use project-local directories for development state
190+
168191
## Machine Fingerprinting
169192

170193
The SDK generates unique machine fingerprints using:
@@ -207,4 +230,4 @@ async with AsyncReplicatedClient(...) as client:
207230

208231
## Thread Safety
209232

210-
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.
233+
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.

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,34 @@ instance.set_status(InstanceStatus.RUNNING)
3434
instance.set_version("1.2.0")
3535
```
3636

37+
### Custom State Directory
38+
39+
By default, the SDK stores state in platform-specific directories. You can override this for testing, containerization, or custom deployments:
40+
41+
```python
42+
from replicated import ReplicatedClient
43+
44+
# Use a custom directory (supports ~ and relative paths)
45+
client = ReplicatedClient(
46+
publishable_key="replicated_pk_...",
47+
app_slug="my-app",
48+
state_directory="/var/lib/my-app/replicated-state"
49+
)
50+
51+
# Or use a relative path (will be resolved to absolute)
52+
client = ReplicatedClient(
53+
publishable_key="replicated_pk_...",
54+
app_slug="my-app",
55+
state_directory="./local-state"
56+
)
57+
```
58+
59+
**When to use custom state directories:**
60+
- Testing with temporary directories
61+
- Docker containers with mounted volumes
62+
- Multi-tenant applications requiring isolated state
63+
- Development with project-local state
64+
3765
### Async Example
3866

3967
```python

replicated/async_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict
1+
from typing import Any, Dict, Optional
22

33
from .http_client import AsyncHTTPClient
44
from .services import AsyncCustomerService
@@ -14,17 +14,19 @@ def __init__(
1414
app_slug: str,
1515
base_url: str = "https://replicated.app",
1616
timeout: float = 30.0,
17+
state_directory: Optional[str] = None,
1718
) -> None:
1819
self.publishable_key = publishable_key
1920
self.app_slug = app_slug
2021
self.base_url = base_url
2122
self.timeout = timeout
23+
self.state_directory = state_directory
2224

2325
self.http_client = AsyncHTTPClient(
2426
base_url=base_url,
2527
timeout=timeout,
2628
)
27-
self.state_manager = StateManager(app_slug)
29+
self.state_manager = StateManager(app_slug, state_directory=state_directory)
2830
self.customer = AsyncCustomerService(self)
2931

3032
async def __aenter__(self) -> "AsyncReplicatedClient":

replicated/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict
1+
from typing import Any, Dict, Optional
22

33
from .http_client import SyncHTTPClient
44
from .services import CustomerService
@@ -14,17 +14,19 @@ def __init__(
1414
app_slug: str,
1515
base_url: str = "https://replicated.app",
1616
timeout: float = 30.0,
17+
state_directory: Optional[str] = None,
1718
) -> None:
1819
self.publishable_key = publishable_key
1920
self.app_slug = app_slug
2021
self.base_url = base_url
2122
self.timeout = timeout
23+
self.state_directory = state_directory
2224

2325
self.http_client = SyncHTTPClient(
2426
base_url=base_url,
2527
timeout=timeout,
2628
)
27-
self.state_manager = StateManager(app_slug)
29+
self.state_manager = StateManager(app_slug, state_directory=state_directory)
2830
self.customer = CustomerService(self)
2931

3032
def __enter__(self) -> "ReplicatedClient":

replicated/state.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@
88
class StateManager:
99
"""Manages local SDK state for idempotency and caching."""
1010

11-
def __init__(self, app_slug: str) -> None:
11+
def __init__(self, app_slug: str, state_directory: Optional[str] = None) -> None:
1212
self.app_slug = app_slug
13-
self._state_dir = self._get_state_directory()
13+
14+
# Use provided directory or derive platform-specific one
15+
if state_directory:
16+
# Normalize path: expand ~ and resolve relative paths
17+
self._state_dir = Path(state_directory).expanduser().resolve()
18+
else:
19+
self._state_dir = self._get_state_directory()
20+
1421
self._state_file = self._state_dir / "state.json"
1522
self._ensure_state_dir()
1623

tests/test_client.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import os
2+
import tempfile
3+
from pathlib import Path
14
from unittest.mock import Mock, patch
25

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

46+
def test_custom_state_directory(self):
47+
"""Test client with custom absolute state directory."""
48+
with tempfile.TemporaryDirectory() as tmpdir:
49+
custom_dir = Path(tmpdir) / "custom_state"
50+
client = ReplicatedClient(
51+
publishable_key="pk_test_123",
52+
app_slug="my-app",
53+
state_directory=str(custom_dir),
54+
)
55+
# Resolve both paths to handle symlinks
56+
# (e.g., /var vs /private/var on macOS)
57+
assert client.state_manager._state_dir == custom_dir.resolve()
58+
expected_file = custom_dir.resolve() / "state.json"
59+
assert client.state_manager._state_file == expected_file
60+
assert custom_dir.exists()
61+
62+
def test_custom_state_directory_with_tilde(self):
63+
"""Test that ~ expansion works in custom state directory."""
64+
client = ReplicatedClient(
65+
publishable_key="pk_test_123",
66+
app_slug="my-app",
67+
state_directory="~/test-replicated-state",
68+
)
69+
# Should be expanded to actual home directory
70+
assert "~" not in str(client.state_manager._state_dir)
71+
assert str(client.state_manager._state_dir).startswith(str(Path.home()))
72+
73+
def test_custom_state_directory_relative_path(self):
74+
"""Test that relative paths are resolved in custom state directory."""
75+
with tempfile.TemporaryDirectory() as tmpdir:
76+
# Change to temp directory and use relative path
77+
original_cwd = os.getcwd()
78+
try:
79+
os.chdir(tmpdir)
80+
client = ReplicatedClient(
81+
publishable_key="pk_test_123",
82+
app_slug="my-app",
83+
state_directory="./relative_state",
84+
)
85+
# Should be resolved to absolute path
86+
assert client.state_manager._state_dir.is_absolute()
87+
assert str(tmpdir) in str(client.state_manager._state_dir)
88+
finally:
89+
os.chdir(original_cwd)
90+
91+
def test_default_state_directory_unchanged(self):
92+
"""Test that default behavior is unchanged when state_directory not provided."""
93+
client = ReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
94+
# Should use platform-specific directory
95+
state_dir_str = str(client.state_manager._state_dir)
96+
assert "my-app" in state_dir_str
97+
assert "Replicated" in state_dir_str
98+
4399

44100
class TestAsyncReplicatedClient:
45101
@pytest.mark.asyncio
@@ -54,3 +110,59 @@ async def test_context_manager(self):
54110
publishable_key="pk_test_123", app_slug="my-app"
55111
) as client:
56112
assert client is not None
113+
114+
@pytest.mark.asyncio
115+
async def test_custom_state_directory(self):
116+
"""Test async client with custom state directory."""
117+
with tempfile.TemporaryDirectory() as tmpdir:
118+
custom_dir = Path(tmpdir) / "custom_state"
119+
client = AsyncReplicatedClient(
120+
publishable_key="pk_test_123",
121+
app_slug="my-app",
122+
state_directory=str(custom_dir),
123+
)
124+
# Resolve both paths to handle symlinks
125+
# (e.g., /var vs /private/var on macOS)
126+
assert client.state_manager._state_dir == custom_dir.resolve()
127+
expected_file = custom_dir.resolve() / "state.json"
128+
assert client.state_manager._state_file == expected_file
129+
assert custom_dir.exists()
130+
131+
@pytest.mark.asyncio
132+
async def test_custom_state_directory_with_tilde(self):
133+
"""Test that ~ expansion works in async client custom state directory."""
134+
client = AsyncReplicatedClient(
135+
publishable_key="pk_test_123",
136+
app_slug="my-app",
137+
state_directory="~/test-replicated-state",
138+
)
139+
# Should be expanded to actual home directory
140+
assert "~" not in str(client.state_manager._state_dir)
141+
assert str(client.state_manager._state_dir).startswith(str(Path.home()))
142+
143+
@pytest.mark.asyncio
144+
async def test_custom_state_directory_relative_path(self):
145+
"""Test that relative paths are resolved in async client."""
146+
with tempfile.TemporaryDirectory() as tmpdir:
147+
# Change to temp directory and use relative path
148+
original_cwd = os.getcwd()
149+
try:
150+
os.chdir(tmpdir)
151+
client = AsyncReplicatedClient(
152+
publishable_key="pk_test_123",
153+
app_slug="my-app",
154+
state_directory="./relative_state",
155+
)
156+
# Should be resolved to absolute path
157+
assert client.state_manager._state_dir.is_absolute()
158+
assert str(tmpdir) in str(client.state_manager._state_dir)
159+
finally:
160+
os.chdir(original_cwd)
161+
162+
@pytest.mark.asyncio
163+
async def test_default_state_directory_unchanged(self):
164+
"""Test that async client default behavior is unchanged."""
165+
client = AsyncReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
166+
state_dir_str = str(client.state_manager._state_dir)
167+
assert "my-app" in state_dir_str
168+
assert "Replicated" in state_dir_str

0 commit comments

Comments
 (0)