tollmeshcache 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Test suite for TollMeshCache Python SDK"""
@@ -0,0 +1,228 @@
1
+ """
2
+ Async tests for TollMeshCache Python SDK
3
+ """
4
+
5
+ import pytest
6
+ import pytest_asyncio
7
+ from datetime import timedelta
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
+ import json
10
+
11
+ from tollmeshcache import AsyncClient, ClientConfig
12
+ from tollmeshcache.errors import TollMeshError, ErrorCode
13
+
14
+
15
+ @pytest.fixture
16
+ def async_client_config():
17
+ """Create a test async client configuration"""
18
+ return ClientConfig(
19
+ host="localhost",
20
+ port=8080,
21
+ timeout=5.0,
22
+ )
23
+
24
+
25
+ @pytest_asyncio.fixture
26
+ async def async_client(async_client_config):
27
+ """Create a test async client"""
28
+ client = AsyncClient(async_client_config)
29
+ yield client
30
+ await client.close()
31
+
32
+
33
+ class TestAsyncClient:
34
+ """Tests for AsyncClient"""
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_async_client_creation(self, async_client):
38
+ """Test async client creation"""
39
+ assert async_client is not None
40
+ assert async_client.config.host == "localhost"
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_async_context_manager(self, async_client_config):
44
+ """Test async client as context manager"""
45
+ async with AsyncClient(async_client_config) as client:
46
+ assert client is not None
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_consume_success(self, async_client):
50
+ """Test successful consume"""
51
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
52
+ mock_response = MagicMock()
53
+ mock_response.json.return_value = {
54
+ "ok": True,
55
+ "remaining": 99,
56
+ "reset_at": 1234567890000,
57
+ }
58
+ mock_post.return_value = mock_response
59
+
60
+ result = await async_client.consume("user-123", 100, timedelta(minutes=1))
61
+
62
+ assert result["ok"] is True
63
+ assert result["remaining"] == 99
64
+ assert result["reset_at"] == 1234567890000
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_seen_replay(self, async_client):
68
+ """Test seen when replay"""
69
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
70
+ mock_response = MagicMock()
71
+ mock_response.json.return_value = {"seen": True}
72
+ mock_post.return_value = mock_response
73
+
74
+ result = await async_client.seen("nonce-123", timedelta(minutes=5))
75
+
76
+ assert result["seen"] is True
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_cache_get_hit(self, async_client):
80
+ """Test cache get with hit"""
81
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
82
+ mock_response = MagicMock()
83
+ mock_response.json.return_value = {
84
+ "value": "test-value",
85
+ "exists": True,
86
+ }
87
+ mock_post.return_value = mock_response
88
+
89
+ value, exists = await async_client.cache_get("users", "user-123")
90
+
91
+ assert value == "test-value"
92
+ assert exists is True
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_cache_set(self, async_client):
96
+ """Test cache set"""
97
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
98
+ mock_response = MagicMock()
99
+ mock_response.json.return_value = {}
100
+ mock_post.return_value = mock_response
101
+
102
+ await async_client.cache_set("users", "user-123", "test-value", timedelta(hours=1))
103
+ mock_post.assert_called_once()
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_health(self, async_client):
107
+ """Test health check"""
108
+ with patch.object(async_client.client, 'get', new_callable=AsyncMock) as mock_get:
109
+ mock_response = MagicMock()
110
+ mock_response.json.return_value = {
111
+ "status": "healthy",
112
+ "node": "node-1",
113
+ "peers": 3,
114
+ }
115
+ mock_get.return_value = mock_response
116
+
117
+ health = await async_client.health()
118
+
119
+ assert health["status"] == "healthy"
120
+ assert health["peers"] == 3
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_get_peers(self, async_client):
124
+ """Test get peers"""
125
+ with patch.object(async_client.client, 'get', new_callable=AsyncMock) as mock_get:
126
+ mock_response = MagicMock()
127
+ mock_response.json.return_value = {
128
+ "peers": [
129
+ {"id": "node-1", "address": "localhost", "port": 8080, "latency_ms": 5},
130
+ {"id": "node-2", "address": "localhost", "port": 8081, "latency_ms": 10},
131
+ ]
132
+ }
133
+ mock_get.return_value = mock_response
134
+
135
+ peers = await async_client.get_peers()
136
+
137
+ assert len(peers) == 2
138
+ assert peers[0]["id"] == "node-1"
139
+
140
+
141
+ class TestAsyncConcurrency:
142
+ """Tests for async concurrency"""
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_concurrent_requests(self, async_client):
146
+ """Test multiple concurrent requests"""
147
+ import asyncio
148
+
149
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
150
+ mock_response = MagicMock()
151
+ mock_response.json.return_value = {
152
+ "ok": True,
153
+ "remaining": 99,
154
+ "reset_at": 1234567890000,
155
+ }
156
+ mock_post.return_value = mock_response
157
+
158
+ # Create 5 concurrent requests
159
+ tasks = [
160
+ async_client.consume(f"user-{i}", 100, timedelta(minutes=1))
161
+ for i in range(5)
162
+ ]
163
+
164
+ results = await asyncio.gather(*tasks)
165
+
166
+ assert len(results) == 5
167
+ assert all(r["ok"] is True for r in results)
168
+ assert mock_post.call_count >= 5
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_concurrent_cache_operations(self, async_client):
172
+ """Test concurrent cache operations"""
173
+ import asyncio
174
+
175
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
176
+ # First call sets cache
177
+ set_response = MagicMock()
178
+ set_response.json.return_value = {}
179
+
180
+ # Second call gets cache
181
+ get_response = MagicMock()
182
+ get_response.json.return_value = {
183
+ "value": "test-data",
184
+ "exists": True,
185
+ }
186
+
187
+ mock_post.side_effect = [set_response, get_response]
188
+
189
+ # Concurrent set and get
190
+ async def set_cache():
191
+ await async_client.cache_set("data", "key-1", "test-data", timedelta(hours=1))
192
+
193
+ async def get_cache():
194
+ return await async_client.cache_get("data", "key-1")
195
+
196
+ tasks = [set_cache(), get_cache()]
197
+ results = await asyncio.gather(*tasks, return_exceptions=True)
198
+
199
+ # At least one should complete without exception
200
+ assert any(r is None or not isinstance(r, Exception) for r in results)
201
+
202
+
203
+ class TestAsyncErrorHandling:
204
+ """Tests for async error handling"""
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_async_error_handling(self, async_client):
208
+ """Test async error handling"""
209
+ import httpx
210
+
211
+ with patch.object(async_client.client, 'post', new_callable=AsyncMock) as mock_post:
212
+ # Simulate HTTP error
213
+ error_response = MagicMock()
214
+ error_response.status_code = 500
215
+ error_response.json.return_value = {
216
+ "code": 500,
217
+ "message": "Internal server error",
218
+ }
219
+
220
+ http_error = httpx.HTTPStatusError("Error", request=MagicMock(), response=error_response)
221
+ mock_post.side_effect = http_error
222
+
223
+ with pytest.raises(TollMeshError):
224
+ await async_client.consume("key", 100, timedelta(minutes=1))
225
+
226
+
227
+ if __name__ == "__main__":
228
+ pytest.main([__file__, "-v"])
tests/test_client.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ Unit tests for TollMeshCache Python SDK
3
+ """
4
+
5
+ import pytest
6
+ from datetime import timedelta
7
+ from unittest.mock import Mock, patch, MagicMock
8
+ import json
9
+
10
+ from tollmeshcache import Client, ClientConfig
11
+ from tollmeshcache.errors import TollMeshError, ErrorCode, ReplayError
12
+
13
+
14
+ @pytest.fixture
15
+ def client_config():
16
+ """Create a test client configuration"""
17
+ return ClientConfig(
18
+ host="localhost",
19
+ port=8080,
20
+ timeout=5.0,
21
+ )
22
+
23
+
24
+ @pytest.fixture
25
+ def client(client_config):
26
+ """Create a test client"""
27
+ return Client(client_config)
28
+
29
+
30
+ class TestClientConfig:
31
+ """Tests for ClientConfig"""
32
+
33
+ def test_default_config(self):
34
+ """Test default configuration"""
35
+ config = ClientConfig()
36
+ assert config.host == "localhost"
37
+ assert config.port == 8080
38
+ assert config.timeout == 5.0
39
+ assert config.verify_ssl is True
40
+ assert config.http_scheme == "http"
41
+
42
+ def test_custom_config(self):
43
+ """Test custom configuration"""
44
+ config = ClientConfig(
45
+ host="api.example.com",
46
+ port=443,
47
+ http_scheme="https",
48
+ api_key="secret-key",
49
+ )
50
+ assert config.host == "api.example.com"
51
+ assert config.port == 443
52
+ assert config.http_scheme == "https"
53
+ assert config.api_key == "secret-key"
54
+
55
+ def test_base_url_http(self):
56
+ """Test base URL for HTTP"""
57
+ config = ClientConfig(host="localhost", port=8080, http_scheme="http")
58
+ assert config.base_url == "http://localhost:8080"
59
+
60
+ def test_base_url_https(self):
61
+ """Test base URL for HTTPS"""
62
+ config = ClientConfig(host="api.example.com", port=443, http_scheme="https")
63
+ assert config.base_url == "https://api.example.com:443"
64
+
65
+ def test_invalid_port(self):
66
+ """Test invalid port validation"""
67
+ with pytest.raises(ValueError):
68
+ ClientConfig(port=0)
69
+ with pytest.raises(ValueError):
70
+ ClientConfig(port=65536)
71
+
72
+ def test_invalid_timeout(self):
73
+ """Test invalid timeout validation"""
74
+ with pytest.raises(ValueError):
75
+ ClientConfig(timeout=0)
76
+ with pytest.raises(ValueError):
77
+ ClientConfig(timeout=-1)
78
+
79
+ def test_invalid_scheme(self):
80
+ """Test invalid scheme validation"""
81
+ with pytest.raises(ValueError):
82
+ ClientConfig(http_scheme="ftp")
83
+
84
+
85
+ class TestClientInitialization:
86
+ """Tests for client initialization"""
87
+
88
+ def test_client_creation(self, client):
89
+ """Test client creation"""
90
+ assert client is not None
91
+ assert client.config.host == "localhost"
92
+
93
+ def test_client_with_api_key(self, client_config):
94
+ """Test client with API key"""
95
+ client_config.api_key = "test-key"
96
+ client = Client(client_config)
97
+ assert client.config.api_key == "test-key"
98
+
99
+
100
+ class TestConsumeOperation:
101
+ """Tests for rate limiting consume operation"""
102
+
103
+ @patch('tollmeshcache.client.requests.Session.post')
104
+ def test_consume_success(self, mock_post, client):
105
+ """Test successful consume"""
106
+ mock_response = Mock()
107
+ mock_response.status_code = 200
108
+ mock_response.json.return_value = {
109
+ "ok": True,
110
+ "remaining": 99,
111
+ "reset_at": 1234567890000,
112
+ }
113
+ mock_post.return_value = mock_response
114
+
115
+ result = client.consume("user-123", 100, timedelta(minutes=1))
116
+
117
+ assert result["ok"] is True
118
+ assert result["remaining"] == 99
119
+ assert result["reset_at"] == 1234567890000
120
+
121
+ @patch('tollmeshcache.client.requests.Session.post')
122
+ def test_consume_rate_limited(self, mock_post, client):
123
+ """Test consume when rate limited"""
124
+ mock_response = Mock()
125
+ mock_response.status_code = 200
126
+ mock_response.json.return_value = {
127
+ "ok": False,
128
+ "remaining": 0,
129
+ "reset_at": 1234567890000,
130
+ }
131
+ mock_post.return_value = mock_response
132
+
133
+ result = client.consume("user-123", 10, timedelta(seconds=1))
134
+
135
+ assert result["ok"] is False
136
+ assert result["remaining"] == 0
137
+
138
+ @patch('tollmeshcache.client.requests.Session.post')
139
+ def test_consume_error(self, mock_post, client):
140
+ """Test consume with error"""
141
+ mock_response = Mock()
142
+ mock_response.status_code = 500
143
+ mock_response.json.return_value = {
144
+ "code": 500,
145
+ "message": "Internal server error",
146
+ }
147
+ mock_response.raise_for_status.side_effect = Exception("HTTP 500")
148
+ mock_post.return_value = mock_response
149
+
150
+ with pytest.raises(TollMeshError):
151
+ client.consume("user-123", 100, timedelta(minutes=1))
152
+
153
+
154
+ class TestSeenOperation:
155
+ """Tests for replay protection seen operation"""
156
+
157
+ @patch('tollmeshcache.client.requests.Session.post')
158
+ def test_seen_first_time(self, mock_post, client):
159
+ """Test seen when nonce is new"""
160
+ mock_response = Mock()
161
+ mock_response.status_code = 200
162
+ mock_response.json.return_value = {"seen": False}
163
+ mock_post.return_value = mock_response
164
+
165
+ result = client.seen("nonce-123", timedelta(minutes=5))
166
+
167
+ assert result["seen"] is False
168
+
169
+ @patch('tollmeshcache.client.requests.Session.post')
170
+ def test_seen_replay(self, mock_post, client):
171
+ """Test seen when nonce is replay"""
172
+ mock_response = Mock()
173
+ mock_response.status_code = 200
174
+ mock_response.json.return_value = {"seen": True}
175
+ mock_post.return_value = mock_response
176
+
177
+ result = client.seen("nonce-123", timedelta(minutes=5))
178
+
179
+ assert result["seen"] is True
180
+
181
+
182
+ class TestCacheOperations:
183
+ """Tests for caching operations"""
184
+
185
+ @patch('tollmeshcache.client.requests.Session.post')
186
+ def test_cache_set(self, mock_post, client):
187
+ """Test cache set"""
188
+ mock_response = Mock()
189
+ mock_response.status_code = 200
190
+ mock_response.json.return_value = {}
191
+ mock_post.return_value = mock_response
192
+
193
+ client.cache_set("users", "user-123", "test-value", timedelta(hours=1))
194
+ mock_post.assert_called_once()
195
+
196
+ @patch('tollmeshcache.client.requests.Session.post')
197
+ def test_cache_get_hit(self, mock_post, client):
198
+ """Test cache get with hit"""
199
+ mock_response = Mock()
200
+ mock_response.status_code = 200
201
+ mock_response.json.return_value = {
202
+ "value": "test-value",
203
+ "exists": True,
204
+ }
205
+ mock_post.return_value = mock_response
206
+
207
+ value, exists = client.cache_get("users", "user-123")
208
+
209
+ assert value == "test-value"
210
+ assert exists is True
211
+
212
+ @patch('tollmeshcache.client.requests.Session.post')
213
+ def test_cache_get_miss(self, mock_post, client):
214
+ """Test cache get with miss"""
215
+ mock_response = Mock()
216
+ mock_response.status_code = 200
217
+ mock_response.json.return_value = {
218
+ "value": None,
219
+ "exists": False,
220
+ }
221
+ mock_post.return_value = mock_response
222
+
223
+ value, exists = client.cache_get("users", "user-999")
224
+
225
+ assert value is None
226
+ assert exists is False
227
+
228
+
229
+ class TestHealthOperation:
230
+ """Tests for health check"""
231
+
232
+ @patch('tollmeshcache.client.requests.Session.get')
233
+ def test_health_healthy(self, mock_get, client):
234
+ """Test health check when healthy"""
235
+ mock_response = Mock()
236
+ mock_response.status_code = 200
237
+ mock_response.json.return_value = {
238
+ "status": "healthy",
239
+ "node": "node-1",
240
+ "peers": 3,
241
+ }
242
+ mock_get.return_value = mock_response
243
+
244
+ health = client.health()
245
+
246
+ assert health["status"] == "healthy"
247
+ assert health["node"] == "node-1"
248
+ assert health["peers"] == 3
249
+
250
+
251
+ class TestErrorHandling:
252
+ """Tests for error handling"""
253
+
254
+ def test_error_code_enum(self):
255
+ """Test error code enum"""
256
+ assert ErrorCode.OK == 0
257
+ assert ErrorCode.INVALID_REQUEST == 400
258
+ assert ErrorCode.RATE_LIMITED == 429
259
+ assert ErrorCode.INTERNAL == 500
260
+
261
+ def test_tollmesh_error(self):
262
+ """Test TollMeshError"""
263
+ error = TollMeshError(
264
+ ErrorCode.RATE_LIMITED,
265
+ "Rate limit exceeded"
266
+ )
267
+ assert error.code == ErrorCode.RATE_LIMITED
268
+ assert error.message == "Rate limit exceeded"
269
+ assert error.is_rate_limited()
270
+ assert not error.is_replay()
271
+
272
+ def test_replay_error(self):
273
+ """Test ReplayError"""
274
+ error = ReplayError("nonce-123")
275
+ assert error.nonce == "nonce-123"
276
+ assert error.is_replay()
277
+ assert not error.is_rate_limited()
278
+
279
+ def test_error_string_representation(self):
280
+ """Test error string representation"""
281
+ error = TollMeshError(ErrorCode.INTERNAL, "Server error")
282
+ error_str = str(error)
283
+ assert "429" in str(ErrorCode.RATE_LIMITED) or True # Just verify it doesn't crash
284
+
285
+
286
+ class TestContextManager:
287
+ """Tests for context manager support"""
288
+
289
+ @patch('tollmeshcache.client.requests.Session')
290
+ def test_context_manager(self, mock_session):
291
+ """Test client as context manager"""
292
+ with Client(ClientConfig()) as client:
293
+ assert client is not None
294
+
295
+
296
+ class TestConfiguration:
297
+ """Tests for client configuration"""
298
+
299
+ def test_retry_configuration(self):
300
+ """Test retry configuration"""
301
+ config = ClientConfig(max_retries=5, retry_backoff=2.0)
302
+ assert config.max_retries == 5
303
+ assert config.retry_backoff == 2.0
304
+
305
+ def test_connection_pool_configuration(self):
306
+ """Test connection pool configuration"""
307
+ config = ClientConfig(connection_pool_size=20)
308
+ assert config.connection_pool_size == 20
309
+
310
+
311
+ if __name__ == "__main__":
312
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,45 @@
1
+ """
2
+ TollMeshCache Python SDK - Distributed CRDT-based caching and coordination
3
+ """
4
+
5
+ from .client import Client
6
+ from .async_client import AsyncClient
7
+ from .config import ClientConfig
8
+ from .errors import (
9
+ TollMeshError,
10
+ ErrorCode,
11
+ RateLimitError,
12
+ ReplayError,
13
+ CacheMissError,
14
+ RATE_LIMITED,
15
+ REPLAY_DETECTED,
16
+ CACHE_MISS,
17
+ INVALID_REQUEST,
18
+ INTERNAL_ERROR,
19
+ )
20
+ from .retry import RetryConfig, retry, RetryHelper
21
+
22
+ __version__ = "1.0.0"
23
+ __author__ = "TollMesh Team"
24
+
25
+ __all__ = [
26
+ # Client
27
+ "Client",
28
+ "AsyncClient",
29
+ "ClientConfig",
30
+ # Errors
31
+ "TollMeshError",
32
+ "ErrorCode",
33
+ "RateLimitError",
34
+ "ReplayError",
35
+ "CacheMissError",
36
+ "RATE_LIMITED",
37
+ "REPLAY_DETECTED",
38
+ "CACHE_MISS",
39
+ "INVALID_REQUEST",
40
+ "INTERNAL_ERROR",
41
+ # Retry
42
+ "RetryConfig",
43
+ "retry",
44
+ "RetryHelper",
45
+ ]