redis-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.
- redis_flags-0.1.0/.coverage +0 -0
- redis_flags-0.1.0/.gitignore +1 -0
- redis_flags-0.1.0/PKG-INFO +18 -0
- redis_flags-0.1.0/README.md +0 -0
- redis_flags-0.1.0/pyproject.toml +42 -0
- redis_flags-0.1.0/redis_flags/__init__.py +0 -0
- redis_flags-0.1.0/redis_flags/commands/__init__.py +1 -0
- redis_flags-0.1.0/redis_flags/commands/cohort.py +133 -0
- redis_flags-0.1.0/redis_flags/commands/flag.py +213 -0
- redis_flags-0.1.0/redis_flags/commands/history.py +40 -0
- redis_flags-0.1.0/redis_flags/commands/user.py +60 -0
- redis_flags-0.1.0/redis_flags/config.py +95 -0
- redis_flags-0.1.0/redis_flags/connection.py +40 -0
- redis_flags-0.1.0/redis_flags/main.py +78 -0
- redis_flags-0.1.0/redis_flags/output.py +161 -0
- redis_flags-0.1.0/tests/__init__.py +0 -0
- redis_flags-0.1.0/tests/test_cohort.py +197 -0
- redis_flags-0.1.0/tests/test_config.py +223 -0
- redis_flags-0.1.0/tests/test_connection.py +79 -0
- redis_flags-0.1.0/tests/test_flag.py +364 -0
- redis_flags-0.1.0/tests/test_main.py +109 -0
- redis_flags-0.1.0/tests/test_user.py +93 -0
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
~/.redis-flags.toml
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redis-flags
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for redis-feature-flags — manage feature flags from your terminal.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: cli,feature-flags,redis
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: redis-feature-flags>=0.1.0
|
|
9
|
+
Requires-Dist: redis>=4.0.0
|
|
10
|
+
Requires-Dist: rich>=13.0.0
|
|
11
|
+
Requires-Dist: tomli-w
|
|
12
|
+
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
|
|
13
|
+
Requires-Dist: typer>=0.9.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: fakeredis>=2.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: typer[testing]>=0.9.0; extra == 'dev'
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "redis-flags"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI for redis-feature-flags — manage feature flags from your terminal."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["redis", "feature-flags", "cli"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"redis>=4.0.0",
|
|
15
|
+
"typer>=0.9.0",
|
|
16
|
+
"rich>=13.0.0",
|
|
17
|
+
"tomli>=2.0.0; python_version < '3.11'",
|
|
18
|
+
"tomli-w",
|
|
19
|
+
"redis-feature-flags>=0.1.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
redis-flags = "redis_flags.main:app"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["redis_flags"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=7.0",
|
|
31
|
+
"pytest-cov>=4.0",
|
|
32
|
+
"fakeredis>=2.0",
|
|
33
|
+
"typer[testing]>=0.9.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
addopts = "--cov=redis_flags --cov-report=term-missing --cov-fail-under=90"
|
|
39
|
+
|
|
40
|
+
[tool.coverage.run]
|
|
41
|
+
source = ["redis_flags"]
|
|
42
|
+
omit = ["tests/*"]
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import flag, user, cohort, history
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..config import get_env, get_redis_url
|
|
8
|
+
from ..connection import get_client
|
|
9
|
+
from ..output import (
|
|
10
|
+
print_cohorts_table, print_cohort_panel,
|
|
11
|
+
print_success, print_error
|
|
12
|
+
)
|
|
13
|
+
from redis_feature_flags import FeatureFlags
|
|
14
|
+
from redis_feature_flags.exceptions import FlagNotFoundError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_flags(env: Optional[str], redis_url: Optional[str]) -> FeatureFlags:
|
|
20
|
+
resolved_env = get_env(env)
|
|
21
|
+
resolved_url = get_redis_url(redis_url)
|
|
22
|
+
client = get_client(resolved_url)
|
|
23
|
+
return FeatureFlags(client, env=resolved_env)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("create-cohort")
|
|
27
|
+
def create_cohort(
|
|
28
|
+
cohort_name: str = typer.Argument(..., help="Cohort name"),
|
|
29
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
30
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Create a new cohort.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
redis-flags create-cohort beta-testers
|
|
37
|
+
"""
|
|
38
|
+
flags = get_flags(env, redis_url)
|
|
39
|
+
flags.create_cohort(cohort_name)
|
|
40
|
+
print_success(f"Created cohort [bold]{cohort_name}[/bold]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("delete-cohort")
|
|
44
|
+
def delete_cohort(
|
|
45
|
+
cohort_name: str = typer.Argument(..., help="Cohort name"),
|
|
46
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
47
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
48
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Delete a cohort and clean up all member reverse index entries.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
redis-flags delete-cohort beta-testers
|
|
55
|
+
redis-flags delete-cohort beta-testers --yes
|
|
56
|
+
"""
|
|
57
|
+
if not confirm:
|
|
58
|
+
typer.confirm(
|
|
59
|
+
f"Delete cohort '{cohort_name}'? This cannot be undone.",
|
|
60
|
+
abort=True,
|
|
61
|
+
)
|
|
62
|
+
flags = get_flags(env, redis_url)
|
|
63
|
+
flags._cohorts.delete(cohort_name)
|
|
64
|
+
print_success(f"Deleted cohort [bold]{cohort_name}[/bold]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("add-to-cohort")
|
|
68
|
+
def add_to_cohort(
|
|
69
|
+
cohort_name: str = typer.Argument(..., help="Cohort name"),
|
|
70
|
+
user_id: str = typer.Argument(..., help="User ID to add"),
|
|
71
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
72
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
73
|
+
):
|
|
74
|
+
"""
|
|
75
|
+
Add a user to a cohort.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
redis-flags add-to-cohort beta-testers alice
|
|
79
|
+
"""
|
|
80
|
+
flags = get_flags(env, redis_url)
|
|
81
|
+
flags.add_to_cohort(cohort_name, user_id)
|
|
82
|
+
print_success(f"Added [bold]{user_id}[/bold] to cohort [bold]{cohort_name}[/bold]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("remove-from-cohort")
|
|
86
|
+
def remove_from_cohort(
|
|
87
|
+
cohort_name: str = typer.Argument(..., help="Cohort name"),
|
|
88
|
+
user_id: str = typer.Argument(..., help="User ID to remove"),
|
|
89
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
90
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
91
|
+
):
|
|
92
|
+
"""
|
|
93
|
+
Remove a user from a cohort.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
redis-flags remove-from-cohort beta-testers alice
|
|
97
|
+
"""
|
|
98
|
+
flags = get_flags(env, redis_url)
|
|
99
|
+
flags.remove_from_cohort(cohort_name, user_id)
|
|
100
|
+
print_success(f"Removed [bold]{user_id}[/bold] from cohort [bold]{cohort_name}[/bold]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("list-cohorts")
|
|
104
|
+
def list_cohorts(
|
|
105
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
106
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
107
|
+
):
|
|
108
|
+
"""
|
|
109
|
+
List all cohorts.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
redis-flags list-cohorts
|
|
113
|
+
"""
|
|
114
|
+
flags = get_flags(env, redis_url)
|
|
115
|
+
cohorts = flags._cohorts.list_cohorts()
|
|
116
|
+
print_cohorts_table(cohorts)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command("inspect-cohort")
|
|
120
|
+
def inspect_cohort(
|
|
121
|
+
cohort_name: str = typer.Argument(..., help="Cohort name"),
|
|
122
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
123
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Show all members of a cohort.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
redis-flags inspect-cohort beta-testers
|
|
130
|
+
"""
|
|
131
|
+
flags = get_flags(env, redis_url)
|
|
132
|
+
members = flags._cohorts.get_members(cohort_name)
|
|
133
|
+
print_cohort_panel(cohort_name, list(members))
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from ..config import get_env, get_redis_url
|
|
9
|
+
from ..connection import get_client
|
|
10
|
+
from ..output import (
|
|
11
|
+
print_flags_table, print_flag_panel,
|
|
12
|
+
print_success, print_error
|
|
13
|
+
)
|
|
14
|
+
from redis_feature_flags import FeatureFlags
|
|
15
|
+
from redis_feature_flags.exceptions import (
|
|
16
|
+
FlagNotFoundError, InvalidRolloutError
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import getpass
|
|
20
|
+
|
|
21
|
+
app = typer.Typer()
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_flags(env: Optional[str], redis_url: Optional[str]) -> FeatureFlags:
|
|
26
|
+
"""Build FeatureFlags instance from CLI options."""
|
|
27
|
+
resolved_env = get_env(env)
|
|
28
|
+
resolved_url = get_redis_url(redis_url)
|
|
29
|
+
client = get_client(resolved_url)
|
|
30
|
+
return FeatureFlags(client, env=resolved_env)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("create")
|
|
34
|
+
def create(
|
|
35
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
36
|
+
rollout: int = typer.Option(0, "--rollout", "-r", help="Rollout percentage 0-100"),
|
|
37
|
+
created_by: str = typer.Option(getpass.getuser(), "--created-by", help="Who is creating this flag"),
|
|
38
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
39
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Create a new feature flag.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
redis-flags create dark_mode
|
|
46
|
+
redis-flags create dark_mode --rollout 10
|
|
47
|
+
redis-flags create dark_mode --rollout 10 --created-by alice
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
flags = get_flags(env, redis_url)
|
|
51
|
+
flags.create(flag_name, rollout=rollout, created_by=created_by)
|
|
52
|
+
print_success(f"Created flag [bold]{flag_name}[/bold] (rollout: {rollout}%)")
|
|
53
|
+
except InvalidRolloutError as e:
|
|
54
|
+
print_error(str(e))
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("enable")
|
|
59
|
+
def enable(
|
|
60
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
61
|
+
updated_by: str = typer.Option(getpass.getuser(), "--updated-by", help="Who is enabling this flag"),
|
|
62
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
63
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
64
|
+
):
|
|
65
|
+
"""
|
|
66
|
+
Enable a feature flag.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
redis-flags enable dark_mode
|
|
70
|
+
redis-flags enable dark_mode --updated-by alice
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
flags = get_flags(env, redis_url)
|
|
74
|
+
flags.enable(flag_name, updated_by=updated_by)
|
|
75
|
+
print_success(f"Enabled [bold]{flag_name}[/bold]")
|
|
76
|
+
except FlagNotFoundError as e:
|
|
77
|
+
print_error(str(e))
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("disable")
|
|
82
|
+
def disable(
|
|
83
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
84
|
+
updated_by: str = typer.Option(getpass.getuser(), "--updated-by", help="Who is disabling this flag"),
|
|
85
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
86
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
87
|
+
):
|
|
88
|
+
"""
|
|
89
|
+
Disable a feature flag — instant kill switch.
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
redis-flags disable dark_mode
|
|
93
|
+
redis-flags disable dark_mode --updated-by alice
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
flags = get_flags(env, redis_url)
|
|
97
|
+
flags.disable(flag_name, updated_by=updated_by)
|
|
98
|
+
print_success(f"Disabled [bold]{flag_name}[/bold]")
|
|
99
|
+
except FlagNotFoundError as e:
|
|
100
|
+
print_error(str(e))
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("set-rollout")
|
|
105
|
+
def set_rollout(
|
|
106
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
107
|
+
percent: int = typer.Argument(..., help="Rollout percentage 0-100"),
|
|
108
|
+
updated_by: str = typer.Option(getpass.getuser(), "--updated-by", help="Who is updating this flag"),
|
|
109
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
110
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
111
|
+
):
|
|
112
|
+
"""
|
|
113
|
+
Set the rollout percentage for a flag.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
redis-flags set-rollout dark_mode 50
|
|
117
|
+
redis-flags set-rollout dark_mode 100
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
flags = get_flags(env, redis_url)
|
|
121
|
+
flags.set_rollout(flag_name, percent, updated_by=updated_by)
|
|
122
|
+
print_success(f"Rollout for [bold]{flag_name}[/bold] set to {percent}%")
|
|
123
|
+
except (FlagNotFoundError, InvalidRolloutError) as e:
|
|
124
|
+
print_error(str(e))
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command("delete")
|
|
129
|
+
def delete(
|
|
130
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
131
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
132
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
133
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
134
|
+
):
|
|
135
|
+
"""
|
|
136
|
+
Delete a feature flag permanently.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
redis-flags delete dark_mode
|
|
140
|
+
redis-flags delete dark_mode --yes
|
|
141
|
+
"""
|
|
142
|
+
if not confirm:
|
|
143
|
+
typer.confirm(
|
|
144
|
+
f"Delete flag '{flag_name}'? This cannot be undone.",
|
|
145
|
+
abort=True,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
flags = get_flags(env, redis_url)
|
|
149
|
+
flags.delete(flag_name)
|
|
150
|
+
print_success(f"Deleted flag [bold]{flag_name}[/bold]")
|
|
151
|
+
except FlagNotFoundError as e:
|
|
152
|
+
print_error(str(e))
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command("list")
|
|
157
|
+
def list_flags(
|
|
158
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
159
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
160
|
+
):
|
|
161
|
+
"""
|
|
162
|
+
List all feature flags.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
redis-flags list
|
|
166
|
+
redis-flags --env prod list
|
|
167
|
+
"""
|
|
168
|
+
flags = get_flags(env, redis_url)
|
|
169
|
+
flag_names = flags.list_flags()
|
|
170
|
+
flag_data = []
|
|
171
|
+
for name in flag_names:
|
|
172
|
+
data = flags.get(name)
|
|
173
|
+
data["name"] = name
|
|
174
|
+
flag_data.append(data)
|
|
175
|
+
print_flags_table(flag_data)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command("inspect")
|
|
179
|
+
def inspect(
|
|
180
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
181
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
182
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
Show detailed information about a flag including users and cohorts.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
redis-flags inspect dark_mode
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
flags = get_flags(env, redis_url)
|
|
192
|
+
resolved_env = get_env(env)
|
|
193
|
+
resolved_url = get_redis_url(redis_url)
|
|
194
|
+
client = get_client(resolved_url)
|
|
195
|
+
|
|
196
|
+
from redis_feature_flags.schema import SchemaKeys
|
|
197
|
+
schema = SchemaKeys(env=resolved_env)
|
|
198
|
+
|
|
199
|
+
data = flags.get(flag_name)
|
|
200
|
+
data["name"] = flag_name
|
|
201
|
+
|
|
202
|
+
users = [
|
|
203
|
+
u.decode() for u in
|
|
204
|
+
client.smembers(schema.flag_users(flag_name))
|
|
205
|
+
]
|
|
206
|
+
cohorts = [
|
|
207
|
+
c.decode() for c in
|
|
208
|
+
client.smembers(schema.flag_cohorts(flag_name))
|
|
209
|
+
]
|
|
210
|
+
print_flag_panel(data, users, cohorts)
|
|
211
|
+
except FlagNotFoundError as e:
|
|
212
|
+
print_error(str(e))
|
|
213
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
app = typer.Typer()
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("history")
|
|
13
|
+
def history(
|
|
14
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
15
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
16
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Show version history for a flag.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
redis-flags history dark_mode
|
|
23
|
+
"""
|
|
24
|
+
console.print("[yellow]History coming in v1.1[/yellow]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("rollback")
|
|
28
|
+
def rollback(
|
|
29
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
30
|
+
version: int = typer.Option(..., "--version", "-v", help="Version to roll back to"),
|
|
31
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
32
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Roll back a flag to a previous version.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
redis-flags rollback dark_mode --version 2
|
|
39
|
+
"""
|
|
40
|
+
console.print("[yellow]Rollback coming in v1.1[/yellow]")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..config import get_env, get_redis_url
|
|
8
|
+
from ..connection import get_client
|
|
9
|
+
from ..output import print_success, print_error
|
|
10
|
+
from redis_feature_flags import FeatureFlags
|
|
11
|
+
from redis_feature_flags.exceptions import FlagNotFoundError
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_flags(env: Optional[str], redis_url: Optional[str]) -> FeatureFlags:
|
|
17
|
+
resolved_env = get_env(env)
|
|
18
|
+
resolved_url = get_redis_url(redis_url)
|
|
19
|
+
client = get_client(resolved_url)
|
|
20
|
+
return FeatureFlags(client, env=resolved_env)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("add-user")
|
|
24
|
+
def add_user(
|
|
25
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
26
|
+
user_id: str = typer.Argument(..., help="User ID to add"),
|
|
27
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
28
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Add a user to a flag's allowlist.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
redis-flags add-user dark_mode alice
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
flags = get_flags(env, redis_url)
|
|
38
|
+
flags.add_user(flag_name, user_id)
|
|
39
|
+
print_success(f"Added [bold]{user_id}[/bold] to flag [bold]{flag_name}[/bold]")
|
|
40
|
+
except FlagNotFoundError as e:
|
|
41
|
+
print_error(str(e))
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("remove-user")
|
|
46
|
+
def remove_user(
|
|
47
|
+
flag_name: str = typer.Argument(..., help="Flag name"),
|
|
48
|
+
user_id: str = typer.Argument(..., help="User ID to remove"),
|
|
49
|
+
env: Optional[str] = typer.Option(None, "--env", help="Environment override"),
|
|
50
|
+
redis_url: Optional[str] = typer.Option(None, "--redis-url", help="Redis URL override"),
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Remove a user from a flag's allowlist.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
redis-flags remove-user dark_mode alice
|
|
57
|
+
"""
|
|
58
|
+
flags = get_flags(env, redis_url)
|
|
59
|
+
flags.remove_user(flag_name, user_id)
|
|
60
|
+
print_success(f"Removed [bold]{user_id}[/bold] from flag [bold]{flag_name}[/bold]")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
import tomli_w
|
|
13
|
+
|
|
14
|
+
CONFIG_PATH = Path.home() / ".redis-flags.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_config() -> dict:
|
|
18
|
+
"""
|
|
19
|
+
Read config from ~/.redis-flags.toml.
|
|
20
|
+
Returns empty dict if file does not exist.
|
|
21
|
+
"""
|
|
22
|
+
if not CONFIG_PATH.exists():
|
|
23
|
+
return {}
|
|
24
|
+
with open(CONFIG_PATH, "rb") as f:
|
|
25
|
+
return tomllib.load(f)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_config(data: dict) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Write config to ~/.redis-flags.toml.
|
|
31
|
+
Creates the file if it does not exist.
|
|
32
|
+
"""
|
|
33
|
+
with open(CONFIG_PATH, "wb") as f:
|
|
34
|
+
tomli_w.dump(data, f)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_env(env_override: Optional[str] = None) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Resolve the active environment.
|
|
40
|
+
|
|
41
|
+
Priority:
|
|
42
|
+
1. --env flag passed directly to command
|
|
43
|
+
2. env saved in ~/.redis-flags.toml
|
|
44
|
+
3. Neither set → raise error with helpful message
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
env_override: value from --env flag. None if not passed.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The active environment string e.g. "prod", "staging", "dev"
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
SystemExit: if no environment is set anywhere.
|
|
54
|
+
"""
|
|
55
|
+
if env_override:
|
|
56
|
+
return env_override
|
|
57
|
+
|
|
58
|
+
config = read_config()
|
|
59
|
+
env = config.get("env")
|
|
60
|
+
|
|
61
|
+
if not env:
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
console = Console()
|
|
64
|
+
console.print("\n[red]Error:[/red] No environment set.\n")
|
|
65
|
+
console.print(" Set a default environment:")
|
|
66
|
+
console.print(" [cyan]redis-flags use prod[/cyan]")
|
|
67
|
+
console.print(" [cyan]redis-flags use staging[/cyan]")
|
|
68
|
+
console.print(" [cyan]redis-flags use dev[/cyan]\n")
|
|
69
|
+
console.print(" Or specify for this command:")
|
|
70
|
+
console.print(" [cyan]redis-flags --env prod list[/cyan]\n")
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
return env
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_redis_url(url_override: Optional[str] = None) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Resolve the Redis URL.
|
|
79
|
+
|
|
80
|
+
Priority:
|
|
81
|
+
1. --redis-url flag passed directly to command
|
|
82
|
+
2. redis_url saved in ~/.redis-flags.toml
|
|
83
|
+
3. Default: redis://localhost:6379
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
url_override: value from --redis-url flag. None if not passed.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Redis URL string.
|
|
90
|
+
"""
|
|
91
|
+
if url_override:
|
|
92
|
+
return url_override
|
|
93
|
+
|
|
94
|
+
config = read_config()
|
|
95
|
+
return config.get("redis_url", "redis://localhost:6379")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import redis
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_client(redis_url: str) -> redis.Redis:
|
|
10
|
+
"""
|
|
11
|
+
Create and verify a Redis client connection.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
redis_url: Full Redis URL e.g. redis://localhost:6379
|
|
15
|
+
Supports auth: redis://:password@host:6379
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Connected Redis client.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
SystemExit: if Redis is unreachable.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
client = redis.Redis.from_url(redis_url, decode_responses=False)
|
|
25
|
+
client.ping()
|
|
26
|
+
return client
|
|
27
|
+
except redis.ConnectionError:
|
|
28
|
+
console.print(f"\n[red]Error:[/red] Cannot connect to Redis at {redis_url}\n")
|
|
29
|
+
console.print(" Start Redis locally:")
|
|
30
|
+
console.print(" [cyan]brew services start redis[/cyan] (macOS)")
|
|
31
|
+
console.print(" [cyan]sudo systemctl start redis[/cyan] (Linux)")
|
|
32
|
+
console.print(" [cyan]docker run -p 6379:6379 redis[/cyan] (Docker)\n")
|
|
33
|
+
console.print(" Or set a custom Redis URL:")
|
|
34
|
+
console.print(" [cyan]redis-flags --redis-url redis://your-host:6379 list[/cyan]\n")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
except redis.AuthenticationError:
|
|
37
|
+
console.print(f"\n[red]Error:[/red] Redis authentication failed.\n")
|
|
38
|
+
console.print(" Provide credentials in the URL:")
|
|
39
|
+
console.print(" [cyan]redis-flags --redis-url redis://:password@host:6379 list[/cyan]\n")
|
|
40
|
+
raise SystemExit(1)
|