xlambda-redis 0.2.0__tar.gz
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.
- xlambda_redis-0.2.0/.gitignore +23 -0
- xlambda_redis-0.2.0/PKG-INFO +113 -0
- xlambda_redis-0.2.0/README.md +87 -0
- xlambda_redis-0.2.0/pyproject.toml +40 -0
- xlambda_redis-0.2.0/src/xlambda/redis/__init__.py +68 -0
- xlambda_redis-0.2.0/src/xlambda/redis/backups.py +73 -0
- xlambda_redis-0.2.0/src/xlambda/redis/client.py +86 -0
- xlambda_redis-0.2.0/src/xlambda/redis/connection.py +47 -0
- xlambda_redis-0.2.0/src/xlambda/redis/instances.py +224 -0
- xlambda_redis-0.2.0/src/xlambda/redis/py.typed +0 -0
- xlambda_redis-0.2.0/src/xlambda/redis/users.py +97 -0
- xlambda_redis-0.2.0/tests/__init__.py +0 -0
- xlambda_redis-0.2.0/tests/test_redis.py +128 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
*.log
|
|
5
|
+
.DS_Store
|
|
6
|
+
cpp/build/
|
|
7
|
+
cpp/*.o
|
|
8
|
+
cpp/image-processor
|
|
9
|
+
cpp/video-processor
|
|
10
|
+
cpp/tour360-processor
|
|
11
|
+
temp/
|
|
12
|
+
.pnpm-store/
|
|
13
|
+
.vscode/*
|
|
14
|
+
.claude/*
|
|
15
|
+
|
|
16
|
+
# Python - the SDK packages under packages-python/, plus the captions/ and
|
|
17
|
+
# moderation/ workers. `dist/` above already covers built wheels.
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.py[cod]
|
|
20
|
+
.pytest_cache/
|
|
21
|
+
*.egg-info/
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xlambda-redis
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: xlambda managed Redis SDK: provisioning, sub-users, backups, and a get_client() convenience.
|
|
5
|
+
Project-URL: Homepage, https://xlambda.tech
|
|
6
|
+
Project-URL: Source, https://github.com/randyryan177-cloud/media-server/tree/main/packages-python/redis
|
|
7
|
+
Author: xlambda
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: cache,redis,sdk,xlambda
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: xlambda-core==0.2.0
|
|
23
|
+
Provides-Extra: client
|
|
24
|
+
Requires-Dist: redis>=5.0; extra == 'client'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# xlambda-redis
|
|
28
|
+
|
|
29
|
+
Managed Redis: provisioning, sub-users, backups, and a `get_client()`
|
|
30
|
+
convenience.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install xlambda-redis # provisioning only
|
|
34
|
+
pip install "xlambda-redis[client]" # plus redis-py, for get_client()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python port of [`@xlambda-tech/redis`](../../packages/redis).
|
|
38
|
+
|
|
39
|
+
`xlambda.redis` and redis-py's top-level `redis` coexist without shadowing
|
|
40
|
+
each other — Python 3 imports are absolute, so importing one never resolves
|
|
41
|
+
to the other.
|
|
42
|
+
|
|
43
|
+
## Provisioning
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from xlambda.redis import RedisClient
|
|
47
|
+
|
|
48
|
+
client = RedisClient(api_key=os.environ["XLAMBDA_API_KEY"])
|
|
49
|
+
|
|
50
|
+
instance = client.instances.create(
|
|
51
|
+
project_id="proj_123",
|
|
52
|
+
name="cache",
|
|
53
|
+
tier="starter", # shared; 'pro'/'business' are dedicated
|
|
54
|
+
persistence_mode="rdb", # 'none' | 'rdb' | 'rdb_aof'
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`create()`, `rotate_password()`, and `reveal_connection()` return a
|
|
59
|
+
`RedisInstanceHandle` — a plain `dict` subclass, so every response field reads
|
|
60
|
+
normally, with client constructors added on top:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
cache = instance.get_client() # redis-py, blocking
|
|
64
|
+
cache.set("hello", "world")
|
|
65
|
+
|
|
66
|
+
acache = instance.get_async_client() # redis.asyncio
|
|
67
|
+
await acache.set("hello", "world")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
On shared-tier instances every key is namespaced behind `instance["keyPrefix"]`.
|
|
71
|
+
|
|
72
|
+
`reveal_connection()` discloses the *current* password rather than changing
|
|
73
|
+
it. It 404s for instances that predate the feature or were created without
|
|
74
|
+
`CONSOLE_CREDENTIAL_ENCRYPTION_KEY` configured — rotate to recover.
|
|
75
|
+
|
|
76
|
+
## Sub-users and backups
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
user = client.instances.users(instance["id"]).create(permission="readonly")
|
|
80
|
+
user["password"] # shown once
|
|
81
|
+
|
|
82
|
+
backups = client.instances.backups(instance["id"])
|
|
83
|
+
backups.create()
|
|
84
|
+
backups.restore("bkp_123") # overwrites all current data; cannot be undone
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## What this doesn't wrap
|
|
88
|
+
|
|
89
|
+
The dashboard's own command / EVAL / pub-sub browser. Running commands isn't
|
|
90
|
+
something the REST API mediates, so once an instance is provisioned you talk
|
|
91
|
+
to it with redis-py through the handle above. `get_client()` is a thin
|
|
92
|
+
ergonomic wrapper, not a reimplementation, and redis-py stays an optional
|
|
93
|
+
extra so provisioning-only callers don't install a driver they never use.
|
|
94
|
+
|
|
95
|
+
## Async
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from xlambda.redis import AsyncRedisClient
|
|
99
|
+
|
|
100
|
+
async with AsyncRedisClient(api_key=...) as client:
|
|
101
|
+
instance = await client.instances.create(project_id="proj_123", name="cache", tier="starter")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Note the two axes are independent: `AsyncRedisClient` is about the
|
|
105
|
+
*provisioning* API, `get_async_client()` about talking to Redis itself. You
|
|
106
|
+
can mix them freely.
|
|
107
|
+
|
|
108
|
+
## Conventions
|
|
109
|
+
|
|
110
|
+
Arguments are snake_case; responses are the API's own camelCase, returned as
|
|
111
|
+
plain dicts typed by `TypedDict`. `memoryBytes` and backup `sizeBytes` are
|
|
112
|
+
bigints server-side and arrive as strings. See
|
|
113
|
+
[xlambda-core](../core#conventions).
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# xlambda-redis
|
|
2
|
+
|
|
3
|
+
Managed Redis: provisioning, sub-users, backups, and a `get_client()`
|
|
4
|
+
convenience.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
pip install xlambda-redis # provisioning only
|
|
8
|
+
pip install "xlambda-redis[client]" # plus redis-py, for get_client()
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python port of [`@xlambda-tech/redis`](../../packages/redis).
|
|
12
|
+
|
|
13
|
+
`xlambda.redis` and redis-py's top-level `redis` coexist without shadowing
|
|
14
|
+
each other — Python 3 imports are absolute, so importing one never resolves
|
|
15
|
+
to the other.
|
|
16
|
+
|
|
17
|
+
## Provisioning
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from xlambda.redis import RedisClient
|
|
21
|
+
|
|
22
|
+
client = RedisClient(api_key=os.environ["XLAMBDA_API_KEY"])
|
|
23
|
+
|
|
24
|
+
instance = client.instances.create(
|
|
25
|
+
project_id="proj_123",
|
|
26
|
+
name="cache",
|
|
27
|
+
tier="starter", # shared; 'pro'/'business' are dedicated
|
|
28
|
+
persistence_mode="rdb", # 'none' | 'rdb' | 'rdb_aof'
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`create()`, `rotate_password()`, and `reveal_connection()` return a
|
|
33
|
+
`RedisInstanceHandle` — a plain `dict` subclass, so every response field reads
|
|
34
|
+
normally, with client constructors added on top:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
cache = instance.get_client() # redis-py, blocking
|
|
38
|
+
cache.set("hello", "world")
|
|
39
|
+
|
|
40
|
+
acache = instance.get_async_client() # redis.asyncio
|
|
41
|
+
await acache.set("hello", "world")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On shared-tier instances every key is namespaced behind `instance["keyPrefix"]`.
|
|
45
|
+
|
|
46
|
+
`reveal_connection()` discloses the *current* password rather than changing
|
|
47
|
+
it. It 404s for instances that predate the feature or were created without
|
|
48
|
+
`CONSOLE_CREDENTIAL_ENCRYPTION_KEY` configured — rotate to recover.
|
|
49
|
+
|
|
50
|
+
## Sub-users and backups
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
user = client.instances.users(instance["id"]).create(permission="readonly")
|
|
54
|
+
user["password"] # shown once
|
|
55
|
+
|
|
56
|
+
backups = client.instances.backups(instance["id"])
|
|
57
|
+
backups.create()
|
|
58
|
+
backups.restore("bkp_123") # overwrites all current data; cannot be undone
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## What this doesn't wrap
|
|
62
|
+
|
|
63
|
+
The dashboard's own command / EVAL / pub-sub browser. Running commands isn't
|
|
64
|
+
something the REST API mediates, so once an instance is provisioned you talk
|
|
65
|
+
to it with redis-py through the handle above. `get_client()` is a thin
|
|
66
|
+
ergonomic wrapper, not a reimplementation, and redis-py stays an optional
|
|
67
|
+
extra so provisioning-only callers don't install a driver they never use.
|
|
68
|
+
|
|
69
|
+
## Async
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from xlambda.redis import AsyncRedisClient
|
|
73
|
+
|
|
74
|
+
async with AsyncRedisClient(api_key=...) as client:
|
|
75
|
+
instance = await client.instances.create(project_id="proj_123", name="cache", tier="starter")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Note the two axes are independent: `AsyncRedisClient` is about the
|
|
79
|
+
*provisioning* API, `get_async_client()` about talking to Redis itself. You
|
|
80
|
+
can mix them freely.
|
|
81
|
+
|
|
82
|
+
## Conventions
|
|
83
|
+
|
|
84
|
+
Arguments are snake_case; responses are the API's own camelCase, returned as
|
|
85
|
+
plain dicts typed by `TypedDict`. `memoryBytes` and backup `sizeBytes` are
|
|
86
|
+
bigints server-side and arrive as strings. See
|
|
87
|
+
[xlambda-core](../core#conventions).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xlambda-redis"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "xlambda managed Redis SDK: provisioning, sub-users, backups, and a get_client() convenience."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "xlambda" }]
|
|
13
|
+
keywords = ["xlambda", "redis", "cache", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Database",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["xlambda-core==0.2.0"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
client = ["redis>=5.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://xlambda.tech"
|
|
34
|
+
Source = "https://github.com/randyryan177-cloud/media-server/tree/main/packages-python/redis"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/xlambda"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""xlambda-redis: managed Redis provisioning, sub-users, backups, clients.
|
|
2
|
+
|
|
3
|
+
from xlambda.redis import RedisClient
|
|
4
|
+
|
|
5
|
+
Deliberately doesn't wrap the dashboard's own command/EVAL/pub-sub browser (a
|
|
6
|
+
dashboard feature, not something a backend app calls) - once an instance is
|
|
7
|
+
provisioned, talk to it with redis-py through the handle's get_client().
|
|
8
|
+
|
|
9
|
+
``xlambda.redis`` and top-level ``redis`` (redis-py) coexist fine: Python 3
|
|
10
|
+
imports are absolute, so neither shadows the other.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from xlambda.core import XlambdaError
|
|
16
|
+
|
|
17
|
+
from .backups import (
|
|
18
|
+
AsyncRedisBackupsResource,
|
|
19
|
+
RedisBackup,
|
|
20
|
+
RedisBackupList,
|
|
21
|
+
RedisBackupsResource,
|
|
22
|
+
)
|
|
23
|
+
from .client import AsyncRedisClient, RedisClient
|
|
24
|
+
from .connection import RedisInstanceHandle
|
|
25
|
+
from .instances import (
|
|
26
|
+
AsyncRedisInstancesResource,
|
|
27
|
+
IsolationModel,
|
|
28
|
+
PersistenceMode,
|
|
29
|
+
RedisInstance,
|
|
30
|
+
RedisInstanceList,
|
|
31
|
+
RedisInstancesResource,
|
|
32
|
+
RedisInstanceWithSecrets,
|
|
33
|
+
)
|
|
34
|
+
from .users import (
|
|
35
|
+
AsyncRedisUsersResource,
|
|
36
|
+
Permission,
|
|
37
|
+
RedisUser,
|
|
38
|
+
RedisUserList,
|
|
39
|
+
RedisUsersResource,
|
|
40
|
+
RedisUserWithSecret,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.2.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AsyncRedisBackupsResource",
|
|
47
|
+
"AsyncRedisClient",
|
|
48
|
+
"AsyncRedisInstancesResource",
|
|
49
|
+
"AsyncRedisUsersResource",
|
|
50
|
+
"IsolationModel",
|
|
51
|
+
"Permission",
|
|
52
|
+
"PersistenceMode",
|
|
53
|
+
"RedisBackup",
|
|
54
|
+
"RedisBackupList",
|
|
55
|
+
"RedisBackupsResource",
|
|
56
|
+
"RedisClient",
|
|
57
|
+
"RedisInstance",
|
|
58
|
+
"RedisInstanceHandle",
|
|
59
|
+
"RedisInstanceList",
|
|
60
|
+
"RedisInstanceWithSecrets",
|
|
61
|
+
"RedisInstancesResource",
|
|
62
|
+
"RedisUser",
|
|
63
|
+
"RedisUserList",
|
|
64
|
+
"RedisUserWithSecret",
|
|
65
|
+
"RedisUsersResource",
|
|
66
|
+
"XlambdaError",
|
|
67
|
+
"__version__",
|
|
68
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Backups for one managed Redis instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
from xlambda.core import AsyncXlambdaClient, XlambdaClient
|
|
8
|
+
from xlambda.core._transport import quote_segment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisBackup(TypedDict):
|
|
12
|
+
id: str
|
|
13
|
+
managedRedisInstanceId: str
|
|
14
|
+
status: str
|
|
15
|
+
# A bigint server-side, so it arrives as a string rather than losing
|
|
16
|
+
# precision in JSON.
|
|
17
|
+
sizeBytes: Optional[str]
|
|
18
|
+
triggeredBy: str
|
|
19
|
+
startedAt: str
|
|
20
|
+
completedAt: Optional[str]
|
|
21
|
+
failureReason: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RedisBackupList(TypedDict):
|
|
25
|
+
backups: List[RedisBackup]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RestoreResult(TypedDict):
|
|
29
|
+
success: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _backups_path(instance_id: str) -> str:
|
|
33
|
+
return f"/v1/redis/{quote_segment(instance_id)}/backups"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _restore_path(instance_id: str, backup_id: str) -> str:
|
|
37
|
+
return f"{_backups_path(instance_id)}/{quote_segment(backup_id)}/restore"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RedisBackupsResource:
|
|
41
|
+
"""``client.instances.backups(instance_id)`` - blocking."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, http: XlambdaClient, instance_id: str) -> None:
|
|
44
|
+
self._http = http
|
|
45
|
+
self._instance_id = instance_id
|
|
46
|
+
|
|
47
|
+
def create(self) -> RedisBackup:
|
|
48
|
+
return self._http.request(_backups_path(self._instance_id), method="POST")
|
|
49
|
+
|
|
50
|
+
def list(self) -> RedisBackupList:
|
|
51
|
+
return self._http.request(_backups_path(self._instance_id))
|
|
52
|
+
|
|
53
|
+
def restore(self, backup_id: str) -> RestoreResult:
|
|
54
|
+
"""Overwrites all current data with this backup's contents. Cannot be undone."""
|
|
55
|
+
return self._http.request(_restore_path(self._instance_id, backup_id), method="POST")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncRedisBackupsResource:
|
|
59
|
+
"""``client.instances.backups(instance_id)`` - awaitable."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, http: AsyncXlambdaClient, instance_id: str) -> None:
|
|
62
|
+
self._http = http
|
|
63
|
+
self._instance_id = instance_id
|
|
64
|
+
|
|
65
|
+
async def create(self) -> RedisBackup:
|
|
66
|
+
return await self._http.request(_backups_path(self._instance_id), method="POST")
|
|
67
|
+
|
|
68
|
+
async def list(self) -> RedisBackupList:
|
|
69
|
+
return await self._http.request(_backups_path(self._instance_id))
|
|
70
|
+
|
|
71
|
+
async def restore(self, backup_id: str) -> RestoreResult:
|
|
72
|
+
"""Overwrites all current data with this backup's contents. Cannot be undone."""
|
|
73
|
+
return await self._http.request(_restore_path(self._instance_id, backup_id), method="POST")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The two entry points: ``RedisClient`` and ``AsyncRedisClient``.
|
|
2
|
+
|
|
3
|
+
Named for the managed service, not for redis-py - this client talks to
|
|
4
|
+
xlambda's provisioning API. The thing that runs GET/SET is redis-py, reached
|
|
5
|
+
through ``handle.get_client()``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from xlambda.core import AsyncXlambdaClient, XlambdaClient
|
|
15
|
+
from xlambda.core._transport import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS
|
|
16
|
+
|
|
17
|
+
from .instances import AsyncRedisInstancesResource, RedisInstancesResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisClient:
|
|
21
|
+
"""Blocking managed-Redis client.
|
|
22
|
+
|
|
23
|
+
client = RedisClient(api_key=os.environ["XLAMBDA_API_KEY"])
|
|
24
|
+
instance = client.instances.create(project_id="proj_123", name="cache", tier="starter")
|
|
25
|
+
cache = instance.get_client()
|
|
26
|
+
|
|
27
|
+
See :class:`xlambda.core.XlambdaClient` for what the shared options do.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: str,
|
|
33
|
+
*,
|
|
34
|
+
base_url: Optional[str] = None,
|
|
35
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
37
|
+
http_client: Optional[httpx.Client] = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._http = XlambdaClient(
|
|
40
|
+
api_key,
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
http_client=http_client,
|
|
45
|
+
)
|
|
46
|
+
self.instances = RedisInstancesResource(self._http)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._http.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> "RedisClient":
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
55
|
+
self.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncRedisClient:
|
|
59
|
+
"""Awaitable twin of :class:`RedisClient`."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: str,
|
|
64
|
+
*,
|
|
65
|
+
base_url: Optional[str] = None,
|
|
66
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
68
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._http = AsyncXlambdaClient(
|
|
71
|
+
api_key,
|
|
72
|
+
base_url=base_url,
|
|
73
|
+
max_retries=max_retries,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
http_client=http_client,
|
|
76
|
+
)
|
|
77
|
+
self.instances = AsyncRedisInstancesResource(self._http)
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
await self._http.aclose()
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "AsyncRedisClient":
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
86
|
+
await self.aclose()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Client convenience over a provisioned instance's credentials.
|
|
2
|
+
|
|
3
|
+
``redis`` (redis-py) is an optional dependency
|
|
4
|
+
(``pip install "xlambda-redis[client]"``) imported lazily here, so a consumer
|
|
5
|
+
who never calls get_client() doesn't need it installed. Same reasoning as
|
|
6
|
+
xlambda-postgres's pool.py: the platform's REST API never mediates actual Redis
|
|
7
|
+
commands (the dashboard's own command/EVAL/pub-sub browser does that,
|
|
8
|
+
deliberately not wrapped here), so this is a thin ergonomic wrapper, not a
|
|
9
|
+
reimplementation.
|
|
10
|
+
|
|
11
|
+
Note the absolute import below resolves to redis-py, not this module - Python 3
|
|
12
|
+
imports are absolute, so ``xlambda.redis`` and top-level ``redis`` coexist
|
|
13
|
+
without shadowing each other.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict
|
|
19
|
+
|
|
20
|
+
_MISSING_DRIVER = (
|
|
21
|
+
'get_client() requires redis-py - install it with `pip install "xlambda-redis[client]"`.'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedisInstanceHandle(Dict[str, Any]):
|
|
26
|
+
"""A provisioned instance, plus client constructors.
|
|
27
|
+
|
|
28
|
+
Subclasses ``dict``, so every field of the API response is still read the
|
|
29
|
+
normal way (``handle["connectionString"]``, ``handle["keyPrefix"]``) - the
|
|
30
|
+
methods are additive.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def get_client(self, **kwargs: Any) -> Any:
|
|
34
|
+
"""A blocking redis-py client bound to this instance's connection string."""
|
|
35
|
+
try:
|
|
36
|
+
import redis
|
|
37
|
+
except ImportError as err: # pragma: no cover - depends on the environment
|
|
38
|
+
raise ImportError(_MISSING_DRIVER) from err
|
|
39
|
+
return redis.Redis.from_url(self["connectionString"], **kwargs)
|
|
40
|
+
|
|
41
|
+
def get_async_client(self, **kwargs: Any) -> Any:
|
|
42
|
+
"""An asyncio redis-py client bound to this instance's connection string."""
|
|
43
|
+
try:
|
|
44
|
+
import redis.asyncio as redis_asyncio
|
|
45
|
+
except ImportError as err: # pragma: no cover
|
|
46
|
+
raise ImportError(_MISSING_DRIVER) from err
|
|
47
|
+
return redis_asyncio.Redis.from_url(self["connectionString"], **kwargs)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Provisioning and lifecycle for managed Redis instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
from xlambda.core import AsyncXlambdaClient, XlambdaClient
|
|
8
|
+
from xlambda.core._transport import compact, quote_segment
|
|
9
|
+
|
|
10
|
+
from .backups import AsyncRedisBackupsResource, RedisBackupsResource
|
|
11
|
+
from .connection import RedisInstanceHandle
|
|
12
|
+
from .users import AsyncRedisUsersResource, RedisUsersResource
|
|
13
|
+
|
|
14
|
+
REDIS_PATH = "/v1/redis"
|
|
15
|
+
|
|
16
|
+
PersistenceMode = Literal["none", "rdb", "rdb_aof"]
|
|
17
|
+
IsolationModel = Literal["shared", "dedicated"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisInstance(TypedDict):
|
|
21
|
+
id: str
|
|
22
|
+
projectId: str
|
|
23
|
+
name: str
|
|
24
|
+
isolationModel: IsolationModel
|
|
25
|
+
status: str
|
|
26
|
+
tier: str
|
|
27
|
+
maxMemoryMb: int
|
|
28
|
+
maxMemoryPolicy: str
|
|
29
|
+
persistenceMode: PersistenceMode
|
|
30
|
+
connectionLimit: Optional[int]
|
|
31
|
+
# A bigint server-side, so it arrives as a string.
|
|
32
|
+
memoryBytes: str
|
|
33
|
+
activeConnections: int
|
|
34
|
+
backupRetentionDays: int
|
|
35
|
+
lastBackupAt: Optional[str]
|
|
36
|
+
suspendedAt: Optional[str]
|
|
37
|
+
createdAt: str
|
|
38
|
+
updatedAt: str
|
|
39
|
+
host: Optional[str]
|
|
40
|
+
port: Optional[int]
|
|
41
|
+
username: str
|
|
42
|
+
# Set on shared-tier instances, which namespace every key behind it.
|
|
43
|
+
keyPrefix: Optional[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RedisInstanceWithSecrets(RedisInstance):
|
|
47
|
+
"""Only returned by create(), rotate_password(), and reveal_connection()."""
|
|
48
|
+
|
|
49
|
+
password: str
|
|
50
|
+
connectionString: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RedisInstanceList(TypedDict):
|
|
54
|
+
instances: List[RedisInstance]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DeleteResult(TypedDict):
|
|
58
|
+
success: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _instance_path(instance_id: str) -> str:
|
|
62
|
+
return f"{REDIS_PATH}/{quote_segment(instance_id)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _create_body(
|
|
66
|
+
*,
|
|
67
|
+
project_id: str,
|
|
68
|
+
name: str,
|
|
69
|
+
tier: str,
|
|
70
|
+
max_memory_mb: Optional[int],
|
|
71
|
+
max_memory_policy: Optional[str],
|
|
72
|
+
persistence_mode: Optional[PersistenceMode],
|
|
73
|
+
connection_limit: Optional[int],
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
return compact(
|
|
76
|
+
{
|
|
77
|
+
"projectId": project_id,
|
|
78
|
+
"name": name,
|
|
79
|
+
"tier": tier,
|
|
80
|
+
"maxMemoryMb": max_memory_mb,
|
|
81
|
+
"maxMemoryPolicy": max_memory_policy,
|
|
82
|
+
"persistenceMode": persistence_mode,
|
|
83
|
+
"connectionLimit": connection_limit,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RedisInstancesResource:
|
|
89
|
+
"""``client.instances`` - blocking."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, http: XlambdaClient) -> None:
|
|
92
|
+
self._http = http
|
|
93
|
+
|
|
94
|
+
def create(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
project_id: str,
|
|
98
|
+
name: str,
|
|
99
|
+
tier: str,
|
|
100
|
+
max_memory_mb: Optional[int] = None,
|
|
101
|
+
max_memory_policy: Optional[str] = None,
|
|
102
|
+
persistence_mode: Optional[PersistenceMode] = None,
|
|
103
|
+
connection_limit: Optional[int] = None,
|
|
104
|
+
) -> RedisInstanceHandle:
|
|
105
|
+
"""Provision an instance. The returned handle also builds redis-py clients.
|
|
106
|
+
|
|
107
|
+
``tier`` is e.g. 'starter' (shared) or 'pro'/'business' (dedicated) -
|
|
108
|
+
see the dashboard for the current tier list.
|
|
109
|
+
"""
|
|
110
|
+
return RedisInstanceHandle(
|
|
111
|
+
self._http.request(
|
|
112
|
+
REDIS_PATH,
|
|
113
|
+
method="POST",
|
|
114
|
+
body=_create_body(
|
|
115
|
+
project_id=project_id,
|
|
116
|
+
name=name,
|
|
117
|
+
tier=tier,
|
|
118
|
+
max_memory_mb=max_memory_mb,
|
|
119
|
+
max_memory_policy=max_memory_policy,
|
|
120
|
+
persistence_mode=persistence_mode,
|
|
121
|
+
connection_limit=connection_limit,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def list(self, *, project_id: Optional[str] = None) -> RedisInstanceList:
|
|
127
|
+
return self._http.request(REDIS_PATH, query=compact({"projectId": project_id}))
|
|
128
|
+
|
|
129
|
+
def get(self, instance_id: str) -> RedisInstance:
|
|
130
|
+
return self._http.request(_instance_path(instance_id))
|
|
131
|
+
|
|
132
|
+
def delete(self, instance_id: str) -> DeleteResult:
|
|
133
|
+
return self._http.request(_instance_path(instance_id), method="DELETE")
|
|
134
|
+
|
|
135
|
+
def rotate_password(self, instance_id: str) -> RedisInstanceHandle:
|
|
136
|
+
return RedisInstanceHandle(
|
|
137
|
+
self._http.request(f"{_instance_path(instance_id)}/rotate-password", method="POST")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def reveal_connection(self, instance_id: str) -> RedisInstanceHandle:
|
|
141
|
+
"""Disclose the CURRENT password rather than changing it.
|
|
142
|
+
|
|
143
|
+
404s if the instance predates this feature or was created without
|
|
144
|
+
CONSOLE_CREDENTIAL_ENCRYPTION_KEY configured - rotate to recover.
|
|
145
|
+
"""
|
|
146
|
+
return RedisInstanceHandle(
|
|
147
|
+
self._http.request(f"{_instance_path(instance_id)}/reveal-connection", method="POST")
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def set_connection_limit(self, instance_id: str, limit: int) -> RedisInstance:
|
|
151
|
+
return self._http.request(
|
|
152
|
+
f"{_instance_path(instance_id)}/connection-limit", method="POST", body={"limit": limit}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def users(self, instance_id: str) -> RedisUsersResource:
|
|
156
|
+
return RedisUsersResource(self._http, instance_id)
|
|
157
|
+
|
|
158
|
+
def backups(self, instance_id: str) -> RedisBackupsResource:
|
|
159
|
+
return RedisBackupsResource(self._http, instance_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncRedisInstancesResource:
|
|
163
|
+
"""``client.instances`` - awaitable."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, http: AsyncXlambdaClient) -> None:
|
|
166
|
+
self._http = http
|
|
167
|
+
|
|
168
|
+
async def create(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
project_id: str,
|
|
172
|
+
name: str,
|
|
173
|
+
tier: str,
|
|
174
|
+
max_memory_mb: Optional[int] = None,
|
|
175
|
+
max_memory_policy: Optional[str] = None,
|
|
176
|
+
persistence_mode: Optional[PersistenceMode] = None,
|
|
177
|
+
connection_limit: Optional[int] = None,
|
|
178
|
+
) -> RedisInstanceHandle:
|
|
179
|
+
return RedisInstanceHandle(
|
|
180
|
+
await self._http.request(
|
|
181
|
+
REDIS_PATH,
|
|
182
|
+
method="POST",
|
|
183
|
+
body=_create_body(
|
|
184
|
+
project_id=project_id,
|
|
185
|
+
name=name,
|
|
186
|
+
tier=tier,
|
|
187
|
+
max_memory_mb=max_memory_mb,
|
|
188
|
+
max_memory_policy=max_memory_policy,
|
|
189
|
+
persistence_mode=persistence_mode,
|
|
190
|
+
connection_limit=connection_limit,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def list(self, *, project_id: Optional[str] = None) -> RedisInstanceList:
|
|
196
|
+
return await self._http.request(REDIS_PATH, query=compact({"projectId": project_id}))
|
|
197
|
+
|
|
198
|
+
async def get(self, instance_id: str) -> RedisInstance:
|
|
199
|
+
return await self._http.request(_instance_path(instance_id))
|
|
200
|
+
|
|
201
|
+
async def delete(self, instance_id: str) -> DeleteResult:
|
|
202
|
+
return await self._http.request(_instance_path(instance_id), method="DELETE")
|
|
203
|
+
|
|
204
|
+
async def rotate_password(self, instance_id: str) -> RedisInstanceHandle:
|
|
205
|
+
return RedisInstanceHandle(
|
|
206
|
+
await self._http.request(f"{_instance_path(instance_id)}/rotate-password", method="POST")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def reveal_connection(self, instance_id: str) -> RedisInstanceHandle:
|
|
210
|
+
"""See :meth:`RedisInstancesResource.reveal_connection`."""
|
|
211
|
+
return RedisInstanceHandle(
|
|
212
|
+
await self._http.request(f"{_instance_path(instance_id)}/reveal-connection", method="POST")
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def set_connection_limit(self, instance_id: str, limit: int) -> RedisInstance:
|
|
216
|
+
return await self._http.request(
|
|
217
|
+
f"{_instance_path(instance_id)}/connection-limit", method="POST", body={"limit": limit}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def users(self, instance_id: str) -> AsyncRedisUsersResource:
|
|
221
|
+
return AsyncRedisUsersResource(self._http, instance_id)
|
|
222
|
+
|
|
223
|
+
def backups(self, instance_id: str) -> AsyncRedisBackupsResource:
|
|
224
|
+
return AsyncRedisBackupsResource(self._http, instance_id)
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Sub-users scoped to one managed Redis instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Literal, Optional, TypedDict
|
|
6
|
+
|
|
7
|
+
from xlambda.core import AsyncXlambdaClient, XlambdaClient
|
|
8
|
+
from xlambda.core._transport import compact, quote_segment
|
|
9
|
+
|
|
10
|
+
Permission = Literal["readonly", "readwrite"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RedisUser(TypedDict):
|
|
14
|
+
id: str
|
|
15
|
+
managedRedisInstanceId: str
|
|
16
|
+
permission: Permission
|
|
17
|
+
expiresAt: Optional[str]
|
|
18
|
+
revokedAt: Optional[str]
|
|
19
|
+
createdAt: str
|
|
20
|
+
lastRotatedAt: Optional[str]
|
|
21
|
+
username: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RedisUserWithSecret(RedisUser):
|
|
25
|
+
password: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedisUserList(TypedDict):
|
|
29
|
+
users: List[RedisUser]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeleteResult(TypedDict):
|
|
33
|
+
success: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _users_path(instance_id: str) -> str:
|
|
37
|
+
return f"/v1/redis/{quote_segment(instance_id)}/users"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _user_path(instance_id: str, user_id: str) -> str:
|
|
41
|
+
return f"{_users_path(instance_id)}/{quote_segment(user_id)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _create_body(permission: Optional[Permission], expires_at: Optional[str]) -> dict:
|
|
45
|
+
return compact({"permission": permission, "expiresAt": expires_at})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RedisUsersResource:
|
|
49
|
+
"""``client.instances.users(instance_id)`` - blocking."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, http: XlambdaClient, instance_id: str) -> None:
|
|
52
|
+
self._http = http
|
|
53
|
+
self._instance_id = instance_id
|
|
54
|
+
|
|
55
|
+
def create(
|
|
56
|
+
self, *, permission: Optional[Permission] = None, expires_at: Optional[str] = None
|
|
57
|
+
) -> RedisUserWithSecret:
|
|
58
|
+
return self._http.request(
|
|
59
|
+
_users_path(self._instance_id), method="POST", body=_create_body(permission, expires_at)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def list(self) -> RedisUserList:
|
|
63
|
+
return self._http.request(_users_path(self._instance_id))
|
|
64
|
+
|
|
65
|
+
def delete(self, user_id: str) -> DeleteResult:
|
|
66
|
+
return self._http.request(_user_path(self._instance_id, user_id), method="DELETE")
|
|
67
|
+
|
|
68
|
+
def rotate_password(self, user_id: str) -> RedisUserWithSecret:
|
|
69
|
+
return self._http.request(
|
|
70
|
+
f"{_user_path(self._instance_id, user_id)}/rotate-password", method="POST"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AsyncRedisUsersResource:
|
|
75
|
+
"""``client.instances.users(instance_id)`` - awaitable."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, http: AsyncXlambdaClient, instance_id: str) -> None:
|
|
78
|
+
self._http = http
|
|
79
|
+
self._instance_id = instance_id
|
|
80
|
+
|
|
81
|
+
async def create(
|
|
82
|
+
self, *, permission: Optional[Permission] = None, expires_at: Optional[str] = None
|
|
83
|
+
) -> RedisUserWithSecret:
|
|
84
|
+
return await self._http.request(
|
|
85
|
+
_users_path(self._instance_id), method="POST", body=_create_body(permission, expires_at)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def list(self) -> RedisUserList:
|
|
89
|
+
return await self._http.request(_users_path(self._instance_id))
|
|
90
|
+
|
|
91
|
+
async def delete(self, user_id: str) -> DeleteResult:
|
|
92
|
+
return await self._http.request(_user_path(self._instance_id, user_id), method="DELETE")
|
|
93
|
+
|
|
94
|
+
async def rotate_password(self, user_id: str) -> RedisUserWithSecret:
|
|
95
|
+
return await self._http.request(
|
|
96
|
+
f"{_user_path(self._instance_id, user_id)}/rotate-password", method="POST"
|
|
97
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Route shapes, the instance handle, and the xlambda.redis / redis-py coexistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from xlambda.redis import AsyncRedisClient, RedisClient, RedisInstanceHandle
|
|
12
|
+
|
|
13
|
+
CREATED = {
|
|
14
|
+
"id": "red_1",
|
|
15
|
+
"projectId": "p1",
|
|
16
|
+
"name": "cache",
|
|
17
|
+
"isolationModel": "shared",
|
|
18
|
+
"status": "ready",
|
|
19
|
+
"tier": "starter",
|
|
20
|
+
"maxMemoryMb": 256,
|
|
21
|
+
"username": "u",
|
|
22
|
+
"password": "secret",
|
|
23
|
+
"keyPrefix": "red_1:",
|
|
24
|
+
"connectionString": "redis://u:secret@redis.example.com:6379",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def recorder(response=None):
|
|
29
|
+
seen = []
|
|
30
|
+
|
|
31
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
32
|
+
seen.append(
|
|
33
|
+
{
|
|
34
|
+
"method": request.method,
|
|
35
|
+
"url": str(request.url),
|
|
36
|
+
"body": json.loads(request.content) if request.content else None,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return httpx.Response(200, json=response if response is not None else {"ok": True})
|
|
40
|
+
|
|
41
|
+
return handler, seen
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sync_client(handler) -> RedisClient:
|
|
45
|
+
return RedisClient("key", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_create_sends_camel_case_and_omits_unset_options():
|
|
49
|
+
handler, seen = recorder(CREATED)
|
|
50
|
+
sync_client(handler).instances.create(
|
|
51
|
+
project_id="p1", name="cache", tier="starter", persistence_mode="rdb"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
assert seen[0]["url"] == "https://api.xlambda.tech/v1/redis"
|
|
55
|
+
assert seen[0]["body"] == {
|
|
56
|
+
"projectId": "p1",
|
|
57
|
+
"name": "cache",
|
|
58
|
+
"tier": "starter",
|
|
59
|
+
"persistenceMode": "rdb",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_create_returns_a_handle_that_is_still_a_plain_dict():
|
|
64
|
+
handler, _ = recorder(CREATED)
|
|
65
|
+
instance = sync_client(handler).instances.create(project_id="p1", name="cache", tier="starter")
|
|
66
|
+
|
|
67
|
+
assert isinstance(instance, RedisInstanceHandle)
|
|
68
|
+
assert isinstance(instance, dict)
|
|
69
|
+
# Shared-tier instances namespace every key behind this prefix.
|
|
70
|
+
assert instance["keyPrefix"] == "red_1:"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_client_raises_a_useful_error_when_redis_py_is_missing(monkeypatch):
|
|
74
|
+
import builtins
|
|
75
|
+
|
|
76
|
+
real_import = builtins.__import__
|
|
77
|
+
|
|
78
|
+
def blocked(name, *args, **kwargs):
|
|
79
|
+
if name == "redis" or name.startswith("redis."):
|
|
80
|
+
raise ImportError("no redis-py here")
|
|
81
|
+
return real_import(name, *args, **kwargs)
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(builtins, "__import__", blocked)
|
|
84
|
+
|
|
85
|
+
with pytest.raises(ImportError, match="xlambda-redis\\[client\\]"):
|
|
86
|
+
RedisInstanceHandle(CREATED).get_client()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_this_package_does_not_shadow_redis_py():
|
|
90
|
+
"""xlambda.redis and top-level redis have to coexist - Python 3 imports are
|
|
91
|
+
absolute, so importing one must never resolve to the other."""
|
|
92
|
+
import xlambda.redis
|
|
93
|
+
|
|
94
|
+
assert xlambda.redis.__name__ == "xlambda.redis"
|
|
95
|
+
assert hasattr(xlambda.redis, "RedisClient")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_nested_user_and_backup_routes():
|
|
99
|
+
handler, seen = recorder()
|
|
100
|
+
instances = sync_client(handler).instances
|
|
101
|
+
|
|
102
|
+
instances.users("red_1").create(permission="readwrite")
|
|
103
|
+
instances.backups("red_1").restore("bkp_1")
|
|
104
|
+
instances.reveal_connection("red_1")
|
|
105
|
+
instances.set_connection_limit("red_1", 10)
|
|
106
|
+
|
|
107
|
+
assert seen[0]["url"] == "https://api.xlambda.tech/v1/redis/red_1/users"
|
|
108
|
+
assert seen[0]["body"] == {"permission": "readwrite"}
|
|
109
|
+
assert seen[1]["url"] == "https://api.xlambda.tech/v1/redis/red_1/backups/bkp_1/restore"
|
|
110
|
+
assert seen[2]["url"] == "https://api.xlambda.tech/v1/redis/red_1/reveal-connection"
|
|
111
|
+
assert seen[3]["body"] == {"limit": 10}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_async_client_produces_the_same_requests():
|
|
115
|
+
sync_handler, sync_seen = recorder(CREATED)
|
|
116
|
+
async_handler, async_seen = recorder(CREATED)
|
|
117
|
+
|
|
118
|
+
sync_client(sync_handler).instances.create(project_id="p1", name="cache", tier="starter")
|
|
119
|
+
|
|
120
|
+
async def run():
|
|
121
|
+
async with AsyncRedisClient(
|
|
122
|
+
"key", http_client=httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
|
|
123
|
+
) as client:
|
|
124
|
+
instance = await client.instances.create(project_id="p1", name="cache", tier="starter")
|
|
125
|
+
assert isinstance(instance, RedisInstanceHandle)
|
|
126
|
+
|
|
127
|
+
asyncio.run(run())
|
|
128
|
+
assert sync_seen == async_seen
|