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.
@@ -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