grass-redis 0.1.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,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: grass-redis
3
+ Version: 0.1.0
4
+ Summary: Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters
5
+ Author: Alex
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: redis>=5.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+
25
+ # grass-redis
26
+
27
+ Redis Sentinel client with mandatory mutual TLS.
28
+
29
+ ## Features
30
+
31
+ - Automatic failover via Sentinel
32
+ - Connection pooling and caching
33
+ - Read replicas for load distribution
34
+ - Mandatory TLS with client certificates
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install -e libs/python/grass-redis
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from pathlib import Path
46
+ from grass_redis import (
47
+ RedisSentinelClient,
48
+ SentinelConfig,
49
+ load_passwords_from_env,
50
+ load_sentinel_hosts_from_env,
51
+ )
52
+
53
+ # Load from environment (never hardcode)
54
+ sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
55
+ passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
56
+
57
+ config = SentinelConfig(
58
+ sentinel_hosts=sentinel_hosts,
59
+ databases=passwords,
60
+ certs_dir=Path("/etc/redis/certs"),
61
+ )
62
+
63
+ with RedisSentinelClient(config) as client:
64
+ primary = client.get_primary("db1")
65
+ primary.set("key", "value")
66
+
67
+ replica = client.get_replica("db1")
68
+ value = replica.get("key")
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ **Environment (optional):**
74
+ ```bash
75
+ export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
76
+ ```
77
+
78
+ **Deploy certs via Ansible to `/etc/redis/certs/`:**
79
+ - `client.crt` - Client certificate (644)
80
+ - `client.key` - Private key (600)
81
+ - `ca.crt` - CA certificate (644)
82
+ - `passwords.env` - Passwords (600)
83
+
84
+ **`passwords.env` format:**
85
+ ```bash
86
+ REDIS_SCRAPER_DB1_PASSWORD=xxx
87
+ REDIS_SCRAPER_DB2_PASSWORD=yyy
88
+ ```
89
+
90
+ Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
91
+
92
+ ## API
93
+
94
+ **SentinelConfig:**
95
+ - `sentinel_hosts` - List[(host, port)]
96
+ - `databases` - Dict[db_name, password]
97
+ - `certs_dir` - Path (default: /etc/redis/certs)
98
+
99
+ **RedisSentinelClient:**
100
+ - `get_primary(db)` - Connection for writes
101
+ - `get_replica(db)` - Connection for reads
102
+ - `discover_primary(db)` - Get current primary address
103
+ - `test_connection(db)` - PING test
104
+
105
+ See `grass-platform/reference_implementation/python/redis/` for examples.
@@ -0,0 +1,81 @@
1
+ # grass-redis
2
+
3
+ Redis Sentinel client with mandatory mutual TLS.
4
+
5
+ ## Features
6
+
7
+ - Automatic failover via Sentinel
8
+ - Connection pooling and caching
9
+ - Read replicas for load distribution
10
+ - Mandatory TLS with client certificates
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install -e libs/python/grass-redis
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from pathlib import Path
22
+ from grass_redis import (
23
+ RedisSentinelClient,
24
+ SentinelConfig,
25
+ load_passwords_from_env,
26
+ load_sentinel_hosts_from_env,
27
+ )
28
+
29
+ # Load from environment (never hardcode)
30
+ sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
31
+ passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
32
+
33
+ config = SentinelConfig(
34
+ sentinel_hosts=sentinel_hosts,
35
+ databases=passwords,
36
+ certs_dir=Path("/etc/redis/certs"),
37
+ )
38
+
39
+ with RedisSentinelClient(config) as client:
40
+ primary = client.get_primary("db1")
41
+ primary.set("key", "value")
42
+
43
+ replica = client.get_replica("db1")
44
+ value = replica.get("key")
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ **Environment (optional):**
50
+ ```bash
51
+ export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
52
+ ```
53
+
54
+ **Deploy certs via Ansible to `/etc/redis/certs/`:**
55
+ - `client.crt` - Client certificate (644)
56
+ - `client.key` - Private key (600)
57
+ - `ca.crt` - CA certificate (644)
58
+ - `passwords.env` - Passwords (600)
59
+
60
+ **`passwords.env` format:**
61
+ ```bash
62
+ REDIS_SCRAPER_DB1_PASSWORD=xxx
63
+ REDIS_SCRAPER_DB2_PASSWORD=yyy
64
+ ```
65
+
66
+ Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
67
+
68
+ ## API
69
+
70
+ **SentinelConfig:**
71
+ - `sentinel_hosts` - List[(host, port)]
72
+ - `databases` - Dict[db_name, password]
73
+ - `certs_dir` - Path (default: /etc/redis/certs)
74
+
75
+ **RedisSentinelClient:**
76
+ - `get_primary(db)` - Connection for writes
77
+ - `get_replica(db)` - Connection for reads
78
+ - `discover_primary(db)` - Get current primary address
79
+ - `test_connection(db)` - PING test
80
+
81
+ See `grass-platform/reference_implementation/python/redis/` for examples.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "grass-redis"
7
+ version = "0.1.0"
8
+ description = "Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Alex" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Database",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "redis>=5.0.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.0",
36
+ ]
37
+
38
+ # Update these URLs when publishing
39
+ [project.urls]
40
+ # Homepage = "https://github.com/your-org/grass-platform/tree/main/libs/python/grass-redis"
41
+ # Repository = "https://github.com/your-org/grass-platform"
42
+ # Issues = "https://github.com/your-org/grass-platform/issues"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """
2
+ grass-redis: Production-ready Redis Sentinel client with mandatory mutual TLS.
3
+
4
+ This library provides a clean abstraction for connecting to Redis through Sentinel
5
+ with automatic failover, connection pooling, and mandatory TLS security.
6
+ """
7
+
8
+ from grass_redis.client import (
9
+ RedisSentinelClient,
10
+ SentinelConfig,
11
+ load_passwords_from_env,
12
+ load_sentinel_hosts_from_env,
13
+ )
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "RedisSentinelClient",
19
+ "SentinelConfig",
20
+ "load_passwords_from_env",
21
+ "load_sentinel_hosts_from_env",
22
+ ]
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Redis Sentinel client with mandatory mutual TLS.
4
+
5
+ SECURITY: All connections require client certificates (client.crt, client.key, ca.crt).
6
+ TLS cannot be disabled - this is a security-first design.
7
+
8
+ Basic usage:
9
+ config = SentinelConfig(
10
+ sentinel_hosts=[("host1", 26379), ("host2", 26379)],
11
+ databases={"db1": "password1"},
12
+ certs_dir=Path("/etc/redis/certs"),
13
+ )
14
+
15
+ with RedisSentinelClient(config) as client:
16
+ primary = client.get_primary("db1")
17
+ primary.set("key", "value")
18
+ """
19
+
20
+ import os
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ import redis
26
+ from redis.sentinel import Sentinel
27
+
28
+
29
+ @dataclass
30
+ class SentinelConfig:
31
+ """
32
+ Configuration for Redis Sentinel cluster.
33
+
34
+ Required:
35
+ sentinel_hosts: List of (host, port) tuples
36
+ databases: Dict mapping database names to passwords
37
+
38
+ Optional:
39
+ certs_dir: Certificate directory (default: /etc/redis/certs)
40
+ socket_timeout: Socket operation timeout in seconds (default: 5)
41
+ socket_connect_timeout: Connection timeout in seconds (default: 5)
42
+ decode_responses: Decode responses as UTF-8 (default: True)
43
+ """
44
+ sentinel_hosts: List[Tuple[str, int]]
45
+ databases: Dict[str, str]
46
+ certs_dir: Path = Path("/etc/redis/certs")
47
+ socket_timeout: int = 5
48
+ socket_connect_timeout: int = 5
49
+ decode_responses: bool = True
50
+ client_cert_name: str = "client.crt"
51
+ client_key_name: str = "client.key"
52
+ ca_cert_name: str = "ca.crt"
53
+
54
+ def __post_init__(self):
55
+ if not self.sentinel_hosts:
56
+ raise ValueError("At least one Sentinel host is required")
57
+ if not self.databases:
58
+ raise ValueError("At least one database is required")
59
+
60
+ @property
61
+ def client_cert_path(self) -> str:
62
+ return str(self.certs_dir / self.client_cert_name)
63
+
64
+ @property
65
+ def client_key_path(self) -> str:
66
+ return str(self.certs_dir / self.client_key_name)
67
+
68
+ @property
69
+ def ca_cert_path(self) -> str:
70
+ return str(self.certs_dir / self.ca_cert_name)
71
+
72
+ def verify_certificates(self) -> Tuple[bool, List[str]]:
73
+ """Check that all required certificate files exist."""
74
+ missing = []
75
+ for path in [self.client_cert_path, self.client_key_path, self.ca_cert_path]:
76
+ if not Path(path).exists():
77
+ missing.append(path)
78
+ return len(missing) == 0, missing
79
+
80
+ def build_connection_kwargs(self, database: str, max_connections: Optional[int] = None) -> Dict:
81
+ """Build redis connection kwargs with TLS configuration."""
82
+ kwargs = {
83
+ "password": self.databases[database],
84
+ "decode_responses": self.decode_responses,
85
+ "socket_timeout": self.socket_timeout,
86
+ "socket_connect_timeout": self.socket_connect_timeout,
87
+ "ssl": True,
88
+ "ssl_certfile": self.client_cert_path,
89
+ "ssl_keyfile": self.client_key_path,
90
+ "ssl_ca_certs": self.ca_cert_path,
91
+ }
92
+ if max_connections:
93
+ kwargs["max_connections"] = max_connections
94
+ return kwargs
95
+
96
+
97
+ class RedisSentinelClient:
98
+ """
99
+ Redis Sentinel client with automatic failover and connection pooling.
100
+
101
+ Features:
102
+ - Automatic primary discovery through Sentinel
103
+ - Connection pooling with caching
104
+ - Read replicas for load distribution
105
+ - Mandatory mutual TLS
106
+ """
107
+
108
+ def __init__(self, config: SentinelConfig):
109
+ self.config = config
110
+ self._sentinel: Optional[Sentinel] = None
111
+ self._primary_clients: Dict[str, redis.Redis] = {}
112
+ self._replica_clients: Dict[str, redis.Redis] = {}
113
+
114
+ # Verify certificates exist
115
+ success, missing = self.config.verify_certificates()
116
+ if not success:
117
+ raise FileNotFoundError(f"Missing certificates: {', '.join(missing)}")
118
+
119
+ @property
120
+ def sentinel(self) -> Sentinel:
121
+ """Get or create Sentinel connection (lazy initialization)."""
122
+ if self._sentinel is None:
123
+ self._sentinel = Sentinel(
124
+ self.config.sentinel_hosts,
125
+ sentinel_kwargs={
126
+ "socket_timeout": self.config.socket_timeout,
127
+ "socket_connect_timeout": self.config.socket_connect_timeout,
128
+ "ssl": True,
129
+ "ssl_certfile": self.config.client_cert_path,
130
+ "ssl_keyfile": self.config.client_key_path,
131
+ "ssl_ca_certs": self.config.ca_cert_path,
132
+ },
133
+ )
134
+ return self._sentinel
135
+
136
+ def get_primary(self, database: str, max_connections: Optional[int] = None) -> redis.Redis:
137
+ """Get primary connection for writes."""
138
+ if database not in self.config.databases:
139
+ raise KeyError(f"Database '{database}' not in config")
140
+
141
+ if database in self._primary_clients:
142
+ return self._primary_clients[database]
143
+
144
+ kwargs = self.config.build_connection_kwargs(database, max_connections)
145
+ primary = self.sentinel.master_for(database, **kwargs)
146
+ self._primary_clients[database] = primary
147
+ return primary
148
+
149
+ def get_replica(self, database: str, max_connections: Optional[int] = None) -> redis.Redis:
150
+ """Get replica connection for reads."""
151
+ if database not in self.config.databases:
152
+ raise KeyError(f"Database '{database}' not in config")
153
+
154
+ if database in self._replica_clients:
155
+ return self._replica_clients[database]
156
+
157
+ kwargs = self.config.build_connection_kwargs(database, max_connections)
158
+ replica = self.sentinel.slave_for(database, **kwargs)
159
+ self._replica_clients[database] = replica
160
+ return replica
161
+
162
+ def discover_primary(self, database: str) -> Tuple[str, int]:
163
+ """Get current primary address."""
164
+ return self.sentinel.discover_master(database)
165
+
166
+ def discover_replicas(self, database: str) -> List[Tuple[str, int]]:
167
+ """Get all replica addresses."""
168
+ return self.sentinel.discover_slaves(database)
169
+
170
+ def test_connection(self, database: str, use_replica: bool = False) -> bool:
171
+ """Test connection with PING."""
172
+ try:
173
+ client = self.get_replica(database) if use_replica else self.get_primary(database)
174
+ return client.ping()
175
+ except Exception:
176
+ return False
177
+
178
+ def close(self):
179
+ """Close all connections."""
180
+ for client in self._primary_clients.values():
181
+ try:
182
+ client.close()
183
+ except Exception:
184
+ pass
185
+ for client in self._replica_clients.values():
186
+ try:
187
+ client.close()
188
+ except Exception:
189
+ pass
190
+ self._primary_clients.clear()
191
+ self._replica_clients.clear()
192
+ self._sentinel = None
193
+
194
+ def __enter__(self):
195
+ return self
196
+
197
+ def __exit__(self, exc_type, exc_val, exc_tb):
198
+ self.close()
199
+ return False
200
+
201
+ def __repr__(self) -> str:
202
+ return (
203
+ f"RedisSentinelClient("
204
+ f"sentinels={len(self.config.sentinel_hosts)}, "
205
+ f"databases={list(self.config.databases.keys())})"
206
+ )
207
+
208
+
209
+ # Credential loading utilities
210
+
211
+ def load_passwords_from_env(password_file: Path = Path("/etc/redis/certs/passwords.env")) -> Dict[str, str]:
212
+ """
213
+ Load Redis passwords from shell-style env file.
214
+
215
+ Format: REDIS_DBNAME_PASSWORD=xxx
216
+ Returns: {"dbname": "xxx"}
217
+ """
218
+ if not password_file.exists():
219
+ raise FileNotFoundError(
220
+ f"Password file not found: {password_file}. "
221
+ "Deploy credentials via Ansible first."
222
+ )
223
+
224
+ passwords = {}
225
+ with open(password_file) as f:
226
+ for line in f:
227
+ line = line.strip()
228
+ if line and not line.startswith("#") and "=" in line:
229
+ key, value = line.split("=", 1)
230
+ if key.startswith("REDIS_") and key.endswith("_PASSWORD"):
231
+ db_name = key[6:-9].lower().replace("_", "-")
232
+ passwords[db_name] = value.strip('"').strip("'")
233
+ return passwords
234
+
235
+
236
+ def load_sentinel_hosts_from_env(env_var: str = "REDIS_SENTINEL_HOSTS") -> List[Tuple[str, int]]:
237
+ """
238
+ Load Sentinel hosts from environment variable.
239
+
240
+ Format: host1:26379,host2:26379,host3:26379
241
+ Returns: [("host1", 26379), ("host2", 26379), ("host3", 26379)]
242
+ """
243
+ hosts_str = os.environ.get(env_var)
244
+ if not hosts_str:
245
+ raise ValueError(
246
+ f"Environment variable {env_var} not set. "
247
+ "Set it to: host1:26379,host2:26379,host3:26379"
248
+ )
249
+
250
+ hosts = []
251
+ for entry in hosts_str.split(","):
252
+ entry = entry.strip()
253
+ if ":" not in entry:
254
+ raise ValueError(f"Invalid format '{entry}'. Expected 'host:port'")
255
+ host, port_str = entry.rsplit(":", 1)
256
+ try:
257
+ port = int(port_str)
258
+ except ValueError:
259
+ raise ValueError(f"Invalid port '{port_str}' for host '{host}'")
260
+ hosts.append((host, port))
261
+
262
+ if not hosts:
263
+ raise ValueError(f"No hosts found in {env_var}")
264
+
265
+ return hosts
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: grass-redis
3
+ Version: 0.1.0
4
+ Summary: Redis Sentinel client with mandatory mutual TLS for high-availability Redis clusters
5
+ Author: Alex
6
+ License: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: redis>=5.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+
25
+ # grass-redis
26
+
27
+ Redis Sentinel client with mandatory mutual TLS.
28
+
29
+ ## Features
30
+
31
+ - Automatic failover via Sentinel
32
+ - Connection pooling and caching
33
+ - Read replicas for load distribution
34
+ - Mandatory TLS with client certificates
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install -e libs/python/grass-redis
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from pathlib import Path
46
+ from grass_redis import (
47
+ RedisSentinelClient,
48
+ SentinelConfig,
49
+ load_passwords_from_env,
50
+ load_sentinel_hosts_from_env,
51
+ )
52
+
53
+ # Load from environment (never hardcode)
54
+ sentinel_hosts = load_sentinel_hosts_from_env() # $REDIS_SENTINEL_HOSTS
55
+ passwords = load_passwords_from_env() # /etc/redis/certs/passwords.env
56
+
57
+ config = SentinelConfig(
58
+ sentinel_hosts=sentinel_hosts,
59
+ databases=passwords,
60
+ certs_dir=Path("/etc/redis/certs"),
61
+ )
62
+
63
+ with RedisSentinelClient(config) as client:
64
+ primary = client.get_primary("db1")
65
+ primary.set("key", "value")
66
+
67
+ replica = client.get_replica("db1")
68
+ value = replica.get("key")
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ **Environment (optional):**
74
+ ```bash
75
+ export REDIS_SENTINEL_HOSTS="host1:26379,host2:26379,host3:26379"
76
+ ```
77
+
78
+ **Deploy certs via Ansible to `/etc/redis/certs/`:**
79
+ - `client.crt` - Client certificate (644)
80
+ - `client.key` - Private key (600)
81
+ - `ca.crt` - CA certificate (644)
82
+ - `passwords.env` - Passwords (600)
83
+
84
+ **`passwords.env` format:**
85
+ ```bash
86
+ REDIS_SCRAPER_DB1_PASSWORD=xxx
87
+ REDIS_SCRAPER_DB2_PASSWORD=yyy
88
+ ```
89
+
90
+ Names are converted: `REDIS_SCRAPER_DB1_PASSWORD` → database `scraper-db1`
91
+
92
+ ## API
93
+
94
+ **SentinelConfig:**
95
+ - `sentinel_hosts` - List[(host, port)]
96
+ - `databases` - Dict[db_name, password]
97
+ - `certs_dir` - Path (default: /etc/redis/certs)
98
+
99
+ **RedisSentinelClient:**
100
+ - `get_primary(db)` - Connection for writes
101
+ - `get_replica(db)` - Connection for reads
102
+ - `discover_primary(db)` - Get current primary address
103
+ - `test_connection(db)` - PING test
104
+
105
+ See `grass-platform/reference_implementation/python/redis/` for examples.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/grass_redis/__init__.py
4
+ src/grass_redis/client.py
5
+ src/grass_redis.egg-info/PKG-INFO
6
+ src/grass_redis.egg-info/SOURCES.txt
7
+ src/grass_redis.egg-info/dependency_links.txt
8
+ src/grass_redis.egg-info/requires.txt
9
+ src/grass_redis.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ redis>=5.0.0
2
+
3
+ [dev]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ grass_redis