redis-feature-flags 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.
Files changed (30) hide show
  1. redis_feature_flags-0.1.0/.gitignore +59 -0
  2. redis_feature_flags-0.1.0/PKG-INFO +32 -0
  3. redis_feature_flags-0.1.0/README.md +9 -0
  4. redis_feature_flags-0.1.0/pyproject.toml +52 -0
  5. redis_feature_flags-0.1.0/redis_feature_flags/__init__.py +20 -0
  6. redis_feature_flags-0.1.0/redis_feature_flags/cache.py +121 -0
  7. redis_feature_flags-0.1.0/redis_feature_flags/client.py +173 -0
  8. redis_feature_flags-0.1.0/redis_feature_flags/cohorts.py +153 -0
  9. redis_feature_flags-0.1.0/redis_feature_flags/evaluator.py +154 -0
  10. redis_feature_flags-0.1.0/redis_feature_flags/exceptions.py +137 -0
  11. redis_feature_flags-0.1.0/redis_feature_flags/schema.py +31 -0
  12. redis_feature_flags-0.1.0/redis_feature_flags/utils.py +25 -0
  13. redis_feature_flags-0.1.0/tests/__init__.py +0 -0
  14. redis_feature_flags-0.1.0/tests/e2e/README.md +72 -0
  15. redis_feature_flags-0.1.0/tests/e2e/__init__.py +0 -0
  16. redis_feature_flags-0.1.0/tests/e2e/conftest.py +66 -0
  17. redis_feature_flags-0.1.0/tests/e2e/constants.py +3 -0
  18. redis_feature_flags-0.1.0/tests/e2e/test_cache.py +0 -0
  19. redis_feature_flags-0.1.0/tests/e2e/test_cli.py +396 -0
  20. redis_feature_flags-0.1.0/tests/e2e/test_cohorts.py +234 -0
  21. redis_feature_flags-0.1.0/tests/e2e/test_environments.py +182 -0
  22. redis_feature_flags-0.1.0/tests/e2e/test_evaluation.py +168 -0
  23. redis_feature_flags-0.1.0/tests/e2e/test_flag_lifecycle.py +427 -0
  24. redis_feature_flags-0.1.0/tests/test_cache.py +253 -0
  25. redis_feature_flags-0.1.0/tests/test_client.py +195 -0
  26. redis_feature_flags-0.1.0/tests/test_cohorts.py +207 -0
  27. redis_feature_flags-0.1.0/tests/test_evaluator.py +332 -0
  28. redis_feature_flags-0.1.0/tests/test_exceptions.py +74 -0
  29. redis_feature_flags-0.1.0/tests/test_schema.py +98 -0
  30. redis_feature_flags-0.1.0/tests/test_utils.py +109 -0
@@ -0,0 +1,59 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ eggs/
12
+ parts/
13
+ var/
14
+ sdist/
15
+ develop-eggs/
16
+ .installed.cfg
17
+ lib/
18
+ lib64/
19
+ wheels/
20
+
21
+ # Virtual environment
22
+ .venv/
23
+ venv/
24
+ ENV/
25
+ env/
26
+
27
+ # Testing
28
+ .pytest_cache/
29
+ .coverage
30
+ htmlcov/
31
+ coverage.xml
32
+ *.cover
33
+
34
+ # MyPy
35
+ .mypy_cache/
36
+ .dmypy.json
37
+ dmypy.json
38
+
39
+ # Ruff
40
+ .ruff_cache/
41
+
42
+ # IDE
43
+ .vscode/
44
+ .idea/
45
+ *.swp
46
+ *.swo
47
+ .DS_Store
48
+
49
+ # Environment variables
50
+ .env
51
+ .env.local
52
+
53
+ # Redis
54
+ dump.rdb
55
+ appendonly.aof
56
+
57
+ # Distribution
58
+ *.tar.gz
59
+ *.whl
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: redis-feature-flags
3
+ Version: 0.1.0
4
+ Summary: Feature flags backed by Redis. No new server. No monthly bill.
5
+ License: MIT
6
+ Keywords: feature-flags,feature-toggles,redis,rollout
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: redis>=4.0.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: black; extra == 'dev'
17
+ Requires-Dist: fakeredis>=2.0; extra == 'dev'
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # redis-feature-flags
25
+
26
+ Feature flags backed by Redis.
27
+ No new server. No new database. No monthly bill.
28
+
29
+ ## Install
30
+
31
+ # Python
32
+ pip install redis-feature-flags
@@ -0,0 +1,9 @@
1
+ # redis-feature-flags
2
+
3
+ Feature flags backed by Redis.
4
+ No new server. No new database. No monthly bill.
5
+
6
+ ## Install
7
+
8
+ # Python
9
+ pip install redis-feature-flags
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "redis-feature-flags"
7
+ version = "0.1.0"
8
+ description = "Feature flags backed by Redis. No new server. No monthly bill."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["redis", "feature-flags", "feature-toggles", "rollout"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+ dependencies = [
22
+ "redis>=4.0.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.0",
28
+ "pytest-cov>=4.0",
29
+ "fakeredis>=2.0",
30
+ "black",
31
+ "mypy",
32
+ "ruff",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "--cov=redis_feature_flags --cov-report=term-missing --cov-fail-under=90"
38
+
39
+ [tool.coverage.run]
40
+ source = ["redis_feature_flags"]
41
+ omit = ["tests/*"]
42
+
43
+ [tool.mypy]
44
+ python_version = "3.9"
45
+ strict = true
46
+
47
+ [tool.ruff]
48
+ line-length = 88
49
+ target-version = "py39"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["redis_feature_flags"]
@@ -0,0 +1,20 @@
1
+ from .client import FeatureFlags
2
+ from .exceptions import (
3
+ RedisFlagError,
4
+ FlagNotFoundError,
5
+ CohortNotFoundError,
6
+ InvalidRolloutError,
7
+ RedisConnectionError,
8
+ SchemaVersionError,
9
+ )
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "FeatureFlags",
14
+ "RedisFlagError",
15
+ "FlagNotFoundError",
16
+ "CohortNotFoundError",
17
+ "InvalidRolloutError",
18
+ "RedisConnectionError",
19
+ "SchemaVersionError",
20
+ ]
@@ -0,0 +1,121 @@
1
+ # redis_feature_flags/cache.py
2
+
3
+ import threading
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ @dataclass
10
+ class CacheEntry:
11
+ """
12
+ A single cached flag object.
13
+ Stores the raw flag data and when it was cached.
14
+ """
15
+ data: Dict[str, Any]
16
+ cached_at: float = field(default_factory=time.monotonic)
17
+
18
+ def is_expired(self, ttl_seconds: int) -> bool:
19
+ """Check if this entry is older than the TTL."""
20
+ age = time.monotonic() - self.cached_at
21
+ return age > ttl_seconds
22
+
23
+
24
+ class LocalCache:
25
+ """
26
+ In-process cache for feature flag data.
27
+
28
+ Serves flag data from memory to avoid a Redis call
29
+ on every is_enabled() evaluation. Falls back to
30
+ serving stale data if Redis becomes unreachable.
31
+
32
+ Thread-safe — safe to use across multiple threads.
33
+ """
34
+
35
+ def __init__(self, ttl_seconds: int = 30):
36
+ """
37
+ Args:
38
+ ttl_seconds: How long a cached flag is considered
39
+ fresh. Default 30 seconds.
40
+ After TTL expires, next read fetches
41
+ from Redis and refreshes the cache.
42
+ """
43
+ self._ttl = ttl_seconds
44
+ self._store: Dict[str, CacheEntry] = {}
45
+ self._lock = threading.Lock()
46
+
47
+ def get(self, key: str) -> Optional[Dict[str, Any]]:
48
+ """
49
+ Get a cached flag if it exists and is not expired.
50
+
51
+ Returns:
52
+ Flag data dict if cached and fresh.
53
+ None if not cached or expired.
54
+ """
55
+ with self._lock:
56
+ entry = self._store.get(key)
57
+ if entry is None:
58
+ return None
59
+ if entry.is_expired(self._ttl):
60
+ return None
61
+ return entry.data
62
+
63
+ def get_stale(self, key: str) -> Optional[Dict[str, Any]]:
64
+ """
65
+ Get a cached flag even if expired.
66
+ Used as fallback when Redis is unreachable.
67
+
68
+ Returns:
69
+ Flag data dict if it exists in cache at all.
70
+ None if never cached.
71
+ """
72
+ with self._lock:
73
+ entry = self._store.get(key)
74
+ if entry is None:
75
+ return None
76
+ return entry.data
77
+
78
+ def set(self, key: str, data: Dict[str, Any]) -> None:
79
+ """
80
+ Store flag data in cache.
81
+
82
+ Args:
83
+ key: Cache key — typically the Redis flag key
84
+ data: Flag data dict from Redis HGETALL
85
+ """
86
+ with self._lock:
87
+ self._store[key] = CacheEntry(data=data)
88
+
89
+ def delete(self, key: str) -> None:
90
+ """
91
+ Remove a flag from cache.
92
+ Called when a flag is updated or deleted
93
+ so the next read fetches fresh data from Redis.
94
+ """
95
+ with self._lock:
96
+ self._store.pop(key, None)
97
+
98
+ def clear(self) -> None:
99
+ """
100
+ Remove all entries from cache.
101
+ Useful for testing and for forcing a full refresh.
102
+ """
103
+ with self._lock:
104
+ self._store.clear()
105
+
106
+ def size(self) -> int:
107
+ """Number of entries currently in cache."""
108
+ with self._lock:
109
+ return len(self._store)
110
+
111
+ def preload(self, flags: Dict[str, Dict[str, Any]]) -> None:
112
+ """
113
+ Load multiple flags into cache at once.
114
+ Called on SDK startup to pre-warm the cache.
115
+
116
+ Args:
117
+ flags: Dict of {cache_key: flag_data}
118
+ """
119
+ with self._lock:
120
+ for key, data in flags.items():
121
+ self._store[key] = CacheEntry(data=data)
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional, Any
4
+
5
+ import redis
6
+
7
+ from .cache import LocalCache
8
+ from .cohorts import CohortManager
9
+ from .evaluator import Evaluator
10
+ from .exceptions import FlagNotFoundError, InvalidRolloutError
11
+ from .schema import SchemaKeys
12
+ from .utils import now_unix
13
+
14
+
15
+ class FeatureFlags:
16
+ """
17
+ Main entry point for redis-feature-flags.
18
+
19
+ Usage:
20
+ import redis
21
+ from redis_feature_flags import FeatureFlags
22
+
23
+ r = redis.Redis()
24
+ flags = FeatureFlags(r)
25
+
26
+ flags.create("dark_mode", rollout=10)
27
+ flags.is_enabled("dark_mode", user_id="alice")
28
+ """
29
+
30
+ SCHEMA_VERSION = "1"
31
+
32
+ def __init__(
33
+ self,
34
+ redis_client: redis.Redis,
35
+ env: str = "prod",
36
+ cache_ttl: int = 30,
37
+ ):
38
+ self._redis = redis_client
39
+ self._schema = SchemaKeys(env=env)
40
+ self._cache = LocalCache(ttl_seconds=cache_ttl)
41
+ self._evaluator = Evaluator(redis_client, self._schema, self._cache)
42
+ self._cohorts = CohortManager(redis_client, self._schema)
43
+
44
+ # ── Core evaluation ────────────────────────────────────────
45
+
46
+ def is_enabled(
47
+ self,
48
+ flag_name: str,
49
+ user_id: str,
50
+ default: bool = False,
51
+ ) -> bool:
52
+ return self._evaluator.is_enabled(flag_name, user_id, default)
53
+
54
+ # ── Flag management ────────────────────────────────────────
55
+
56
+ def create(
57
+ self,
58
+ flag_name: str,
59
+ rollout: int = 0,
60
+ created_by: str = "unknown",
61
+ ) -> None:
62
+ if not 0 <= rollout <= 100:
63
+ raise InvalidRolloutError(rollout)
64
+ ts = str(now_unix())
65
+ self._redis.hset(
66
+ self._schema.flag(flag_name),
67
+ mapping={
68
+ "enabled": "0",
69
+ "rollout": str(rollout),
70
+ "expires_at": "0",
71
+ "created_at": ts,
72
+ "updated_at": ts,
73
+ "created_by": created_by,
74
+ "updated_by": created_by,
75
+ "flag_version": "1",
76
+ },
77
+ )
78
+ self._redis.sadd(self._schema.flags_index(), flag_name)
79
+
80
+ def enable(self, flag_name: str, updated_by: str = "unknown") -> None:
81
+ self._assert_exists(flag_name)
82
+ self._redis.hset(
83
+ self._schema.flag(flag_name),
84
+ mapping={
85
+ "enabled": "1",
86
+ "updated_at": str(now_unix()),
87
+ "updated_by": updated_by,
88
+ },
89
+ )
90
+ self._cache.delete(self._schema.flag(flag_name))
91
+
92
+ def disable(self, flag_name: str, updated_by: str = "unknown") -> None:
93
+ self._assert_exists(flag_name)
94
+ self._redis.hset(
95
+ self._schema.flag(flag_name),
96
+ mapping={
97
+ "enabled": "0",
98
+ "updated_at": str(now_unix()),
99
+ "updated_by": updated_by,
100
+ },
101
+ )
102
+ self._cache.delete(self._schema.flag(flag_name))
103
+
104
+ def set_rollout(
105
+ self,
106
+ flag_name: str,
107
+ percent: int,
108
+ updated_by: str = "unknown",
109
+ ) -> None:
110
+ if not 0 <= percent <= 100:
111
+ raise InvalidRolloutError(percent)
112
+ self._assert_exists(flag_name)
113
+ self._redis.hset(
114
+ self._schema.flag(flag_name),
115
+ mapping={
116
+ "rollout": str(percent),
117
+ "updated_at": str(now_unix()),
118
+ "updated_by": updated_by,
119
+ },
120
+ )
121
+ self._cache.delete(self._schema.flag(flag_name))
122
+
123
+ def delete(self, flag_name: str) -> None:
124
+ self._redis.delete(self._schema.flag(flag_name))
125
+ self._redis.delete(self._schema.flag_users(flag_name))
126
+ self._redis.delete(self._schema.flag_cohorts(flag_name))
127
+ self._redis.delete(self._schema.flag_history(flag_name))
128
+ self._redis.srem(self._schema.flags_index(), flag_name)
129
+ self._cache.delete(self._schema.flag(flag_name))
130
+
131
+ def list_flags(self) -> List[str]:
132
+ flags = self._redis.smembers(self._schema.flags_index())
133
+ return sorted([f.decode() for f in flags])
134
+
135
+ def get(self, flag_name: str) -> Dict[str, Any]:
136
+ self._assert_exists(flag_name)
137
+ data = self._redis.hgetall(self._schema.flag(flag_name))
138
+ return {k.decode(): v.decode() for k, v in data.items()}
139
+
140
+ # ── User targeting ─────────────────────────────────────────
141
+
142
+ def add_user(self, flag_name: str, user_id: str) -> None:
143
+ self._assert_exists(flag_name)
144
+ self._redis.sadd(self._schema.flag_users(flag_name), user_id)
145
+
146
+ def remove_user(self, flag_name: str, user_id: str) -> None:
147
+ self._redis.srem(self._schema.flag_users(flag_name), user_id)
148
+
149
+ # ── Cohort targeting ───────────────────────────────────────
150
+
151
+ def create_cohort(self, cohort_name: str) -> None:
152
+ self._cohorts.create(cohort_name)
153
+
154
+ def add_to_cohort(self, cohort_name: str, user_id: str) -> None:
155
+ self._cohorts.add_user(cohort_name, user_id)
156
+
157
+ def remove_from_cohort(self, cohort_name: str, user_id: str) -> None:
158
+ self._cohorts.remove_user(cohort_name, user_id)
159
+
160
+ def add_cohort_to_flag(self, flag_name: str, cohort_name: str) -> None:
161
+ self._assert_exists(flag_name)
162
+ self._redis.sadd(self._schema.flag_cohorts(flag_name), cohort_name)
163
+
164
+ def remove_cohort_from_flag(
165
+ self, flag_name: str, cohort_name: str
166
+ ) -> None:
167
+ self._redis.srem(self._schema.flag_cohorts(flag_name), cohort_name)
168
+
169
+ # ── Private helpers ────────────────────────────────────────
170
+
171
+ def _assert_exists(self, flag_name: str) -> None:
172
+ if not self._redis.exists(self._schema.flag(flag_name)):
173
+ raise FlagNotFoundError(flag_name)
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import redis
4
+
5
+ from .exceptions import CohortNotFoundError
6
+ from .schema import SchemaKeys
7
+
8
+
9
+ class CohortManager:
10
+ """
11
+ Manages cohorts in Redis.
12
+
13
+ A cohort is a named group of users — e.g. beta-testers, premium-users.
14
+ Flags can target entire cohorts instead of individual users.
15
+
16
+ Uses a bidirectional index:
17
+ Direction 1 — ff:{env}:cohort:{name} → Set of user_ids
18
+ Direction 2 — ff:{env}:user:{id}:cohorts → Set of cohort names
19
+
20
+ Direction 1 answers: "who is in this cohort?" (management)
21
+ Direction 2 answers: "which cohorts does this user belong to?" (evaluation)
22
+ Both directions must always stay in sync.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ redis_client: redis.Redis,
28
+ schema: SchemaKeys,
29
+ ):
30
+ self._redis = redis_client
31
+ self._schema = schema
32
+
33
+ def create(self, cohort_name: str) -> None:
34
+ """
35
+ Register a cohort name in the cohorts index.
36
+
37
+ Adds cohort_name to ff:{env}:cohorts:__index__ so it
38
+ appears in list_cohorts() without needing a KEYS * scan.
39
+ Does not add any members — cohort starts empty.
40
+ """
41
+ self._redis.sadd(self._schema.cohorts_index(), cohort_name)
42
+
43
+ def delete(self, cohort_name: str) -> None:
44
+ """
45
+ Fully remove a cohort from Redis — cleans up all three locations.
46
+
47
+ Step 1 — get all members before deleting
48
+ so we can clean their reverse index keys.
49
+
50
+ Step 2 — pipeline all removals atomically:
51
+ a. Remove cohort name from every member's reverse index
52
+ ff:{env}:user:{user_id}:cohorts
53
+ b. Delete the cohort members Set
54
+ ff:{env}:cohort:{name}
55
+ c. Remove cohort name from the cohorts index
56
+ ff:{env}:cohorts:__index__
57
+
58
+ Note: does NOT clean up flag cohort Sets that reference this cohort.
59
+ ff:{env}:flag:{name}:cohorts may still contain this cohort name.
60
+ Evaluation is unaffected — SINTER finds no match once the cohort
61
+ is gone from the user reverse index.
62
+ Full flag cleanup is planned for v2.
63
+ """
64
+ members = self._redis.smembers(self._schema.cohort(cohort_name))
65
+
66
+ pipe = self._redis.pipeline()
67
+ for member in members:
68
+ pipe.srem(self._schema.user_cohorts(member.decode()), cohort_name)
69
+ pipe.delete(self._schema.cohort(cohort_name))
70
+ pipe.srem(self._schema.cohorts_index(), cohort_name)
71
+ pipe.execute()
72
+
73
+ def add_user(self, cohort_name: str, user_id: str) -> None:
74
+ """
75
+ Add a user to a cohort — writes both directions atomically.
76
+
77
+ Uses a Redis pipeline to send both commands in one round trip
78
+ and execute them together — either both succeed or both fail.
79
+
80
+ Line 1 — direction 1 (cohort → members):
81
+ SADD ff:{env}:cohort:{name} {user_id}
82
+
83
+ Line 2 — direction 2 (user → cohorts) reverse index:
84
+ SADD ff:{env}:user:{user_id}:cohorts {cohort_name}
85
+
86
+ Direction 2 is what makes evaluation fast — SINTER can find
87
+ cohort matches in one Redis call instead of checking each cohort.
88
+ """
89
+ pipe = self._redis.pipeline()
90
+ pipe.sadd(self._schema.cohort(cohort_name), user_id)
91
+ pipe.sadd(self._schema.user_cohorts(user_id), cohort_name)
92
+ pipe.execute()
93
+
94
+ def remove_user(self, cohort_name: str, user_id: str) -> None:
95
+ """
96
+ Remove a user from a cohort — removes both directions atomically.
97
+
98
+ Uses a Redis pipeline — both removals happen together.
99
+
100
+ Line 1 — removes user from cohort members Set:
101
+ SREM ff:{env}:cohort:{name} {user_id}
102
+
103
+ Line 2 — removes cohort from user's reverse index:
104
+ SREM ff:{env}:user:{user_id}:cohorts {cohort_name}
105
+
106
+ Both directions must be removed to keep the index consistent.
107
+ """
108
+ pipe = self._redis.pipeline()
109
+ pipe.srem(self._schema.cohort(cohort_name), user_id)
110
+ pipe.srem(self._schema.user_cohorts(user_id), cohort_name)
111
+ pipe.execute()
112
+
113
+ def get_members(self, cohort_name: str) -> set:
114
+ """
115
+ Return all user_ids in a cohort as a Python set of strings.
116
+
117
+ Reads from direction 1 — ff:{env}:cohort:{name}.
118
+ Redis returns bytes — decoded to strings before returning.
119
+ Returns empty set if cohort does not exist.
120
+
121
+ Used for management — e.g. CLI command "show me who is in beta-testers".
122
+ Not used during flag evaluation — direction 2 is used there instead.
123
+ """
124
+ members = self._redis.smembers(self._schema.cohort(cohort_name))
125
+ return {m.decode() for m in members}
126
+
127
+ def list_cohorts(self) -> list:
128
+ """
129
+ Return all cohort names as a Python list of strings.
130
+
131
+ Reads from ff:{env}:cohorts:__index__ — the cohort name registry.
132
+ Redis returns bytes — decoded to strings before returning.
133
+
134
+ Safe to call in production — reads one Set key, no KEYS * scan.
135
+ """
136
+ cohorts = self._redis.smembers(self._schema.cohorts_index())
137
+ return [c.decode() for c in cohorts]
138
+
139
+ def exists(self, cohort_name: str) -> bool:
140
+ """
141
+ Check if a cohort name is registered in the index.
142
+
143
+ Uses SISMEMBER on ff:{env}:cohorts:__index__ — O(1) lookup.
144
+ Returns True if cohort exists, False otherwise.
145
+
146
+ Note: a cohort can exist in the index but have zero members.
147
+ exists() only checks registration — not whether it has members.
148
+ """
149
+ return bool(
150
+ self._redis.sismember(
151
+ self._schema.cohorts_index(), cohort_name
152
+ )
153
+ )