toggly-flask 0.0.1__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.
- toggly_flask-0.0.1/PKG-INFO +34 -0
- toggly_flask-0.0.1/pyproject.toml +79 -0
- toggly_flask-0.0.1/setup.cfg +4 -0
- toggly_flask-0.0.1/src/toggly_flask/__init__.py +42 -0
- toggly_flask-0.0.1/src/toggly_flask/decorators.py +294 -0
- toggly_flask-0.0.1/src/toggly_flask/extension.py +492 -0
- toggly_flask-0.0.1/src/toggly_flask/py.typed +0 -0
- toggly_flask-0.0.1/src/toggly_flask.egg-info/PKG-INFO +34 -0
- toggly_flask-0.0.1/src/toggly_flask.egg-info/SOURCES.txt +12 -0
- toggly_flask-0.0.1/src/toggly_flask.egg-info/dependency_links.txt +1 -0
- toggly_flask-0.0.1/src/toggly_flask.egg-info/requires.txt +8 -0
- toggly_flask-0.0.1/src/toggly_flask.egg-info/top_level.txt +1 -0
- toggly_flask-0.0.1/tests/test_decorators.py +272 -0
- toggly_flask-0.0.1/tests/test_extension.py +332 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toggly-flask
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Flask integration for Toggly feature flag management
|
|
5
|
+
Author-email: Toggly <support@toggly.io>
|
|
6
|
+
Maintainer-email: Toggly <support@toggly.io>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://toggly.io
|
|
9
|
+
Project-URL: Documentation, https://docs.toggly.io/sdks/python/flask
|
|
10
|
+
Project-URL: Repository, https://github.com/ops-ai/toggly-sdks
|
|
11
|
+
Keywords: feature-flags,feature-toggles,flask,toggly,experimentation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Flask
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: toggly>=0.1.0
|
|
29
|
+
Requires-Dist: flask>=2.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toggly-flask"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Flask integration for Toggly feature flag management"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Toggly", email = "support@toggly.io"}
|
|
13
|
+
]
|
|
14
|
+
maintainers = [
|
|
15
|
+
{name = "Toggly", email = "support@toggly.io"}
|
|
16
|
+
]
|
|
17
|
+
keywords = [
|
|
18
|
+
"feature-flags",
|
|
19
|
+
"feature-toggles",
|
|
20
|
+
"flask",
|
|
21
|
+
"toggly",
|
|
22
|
+
"experimentation"
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Framework :: Flask",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.8",
|
|
32
|
+
"Programming Language :: Python :: 3.9",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Typing :: Typed"
|
|
39
|
+
]
|
|
40
|
+
requires-python = ">=3.8"
|
|
41
|
+
dependencies = [
|
|
42
|
+
"toggly>=0.1.0",
|
|
43
|
+
"flask>=2.0"
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://toggly.io"
|
|
48
|
+
Documentation = "https://docs.toggly.io/sdks/python/flask"
|
|
49
|
+
Repository = "https://github.com/ops-ai/toggly-sdks"
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=7.0.0",
|
|
54
|
+
"pytest-cov>=4.0.0",
|
|
55
|
+
"mypy>=1.0.0",
|
|
56
|
+
"ruff>=0.1.0"
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.package-data]
|
|
63
|
+
toggly_flask = ["py.typed"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
addopts = "-v --cov=src/toggly_flask --cov-report=xml --cov-report=term-missing"
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
python_version = "3.8"
|
|
71
|
+
strict = true
|
|
72
|
+
|
|
73
|
+
[tool.ruff]
|
|
74
|
+
target-version = "py38"
|
|
75
|
+
line-length = 100
|
|
76
|
+
|
|
77
|
+
[tool.coverage.run]
|
|
78
|
+
source = ["src/toggly_flask"]
|
|
79
|
+
branch = true
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Toggly Flask - Flask integration for Toggly feature flag management.
|
|
3
|
+
|
|
4
|
+
Provides a Flask extension, view decorators, and template context
|
|
5
|
+
for seamless Flask integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from toggly_flask.decorators import (
|
|
9
|
+
FeatureFlagBlueprint,
|
|
10
|
+
feature_flag_required,
|
|
11
|
+
feature_flag_switch,
|
|
12
|
+
feature_gate_required,
|
|
13
|
+
feature_variant,
|
|
14
|
+
)
|
|
15
|
+
from toggly_flask.extension import (
|
|
16
|
+
FeatureFlagChecker,
|
|
17
|
+
TemplateToggly,
|
|
18
|
+
Toggly,
|
|
19
|
+
TogglyRequestHelper,
|
|
20
|
+
get_client,
|
|
21
|
+
get_context_from_request,
|
|
22
|
+
get_toggly,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Extension
|
|
28
|
+
"Toggly",
|
|
29
|
+
"TogglyRequestHelper",
|
|
30
|
+
"TemplateToggly",
|
|
31
|
+
"FeatureFlagChecker",
|
|
32
|
+
# Decorators
|
|
33
|
+
"feature_flag_required",
|
|
34
|
+
"feature_gate_required",
|
|
35
|
+
"feature_flag_switch",
|
|
36
|
+
"feature_variant",
|
|
37
|
+
"FeatureFlagBlueprint",
|
|
38
|
+
# Utilities
|
|
39
|
+
"get_toggly",
|
|
40
|
+
"get_client",
|
|
41
|
+
"get_context_from_request",
|
|
42
|
+
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Flask view decorators for Toggly feature flags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
from flask import Response, abort, redirect
|
|
9
|
+
from toggly import FeatureRequirement
|
|
10
|
+
|
|
11
|
+
from toggly_flask.extension import get_client, get_context_from_request
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def feature_flag_required(
|
|
17
|
+
feature_key: str,
|
|
18
|
+
*,
|
|
19
|
+
redirect_url: str | None = None,
|
|
20
|
+
status_code: int = 403,
|
|
21
|
+
fallback_view: Callable[..., Response] | None = None,
|
|
22
|
+
) -> Callable[[F], F]:
|
|
23
|
+
"""Decorator to require a feature flag for a view.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
feature_key: The feature key that must be enabled.
|
|
27
|
+
redirect_url: URL to redirect to if feature is disabled.
|
|
28
|
+
status_code: HTTP status code to return if feature is disabled (default 403).
|
|
29
|
+
fallback_view: Alternative view to call if feature is disabled.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Decorated view function.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
@app.route('/new-dashboard')
|
|
36
|
+
@feature_flag_required('new-dashboard')
|
|
37
|
+
def new_dashboard():
|
|
38
|
+
return render_template('new_dashboard.html')
|
|
39
|
+
|
|
40
|
+
@app.route('/beta')
|
|
41
|
+
@feature_flag_required('beta-feature', redirect_url='/coming-soon/')
|
|
42
|
+
def beta_view():
|
|
43
|
+
return render_template('beta.html')
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def decorator(view_func: F) -> F:
|
|
47
|
+
@functools.wraps(view_func)
|
|
48
|
+
def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
49
|
+
client = get_client()
|
|
50
|
+
|
|
51
|
+
if client is not None:
|
|
52
|
+
context = get_context_from_request()
|
|
53
|
+
if client.is_enabled(feature_key, context):
|
|
54
|
+
return view_func(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
# Feature is disabled or client unavailable
|
|
57
|
+
if redirect_url:
|
|
58
|
+
return redirect(redirect_url)
|
|
59
|
+
|
|
60
|
+
if fallback_view:
|
|
61
|
+
return fallback_view(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
abort(status_code, description=f"Feature '{feature_key}' is not available")
|
|
64
|
+
|
|
65
|
+
return wrapper # type: ignore
|
|
66
|
+
|
|
67
|
+
return decorator
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def feature_gate_required(
|
|
71
|
+
feature_keys: list[str],
|
|
72
|
+
*,
|
|
73
|
+
requirement: FeatureRequirement = FeatureRequirement.ALL,
|
|
74
|
+
negate: bool = False,
|
|
75
|
+
redirect_url: str | None = None,
|
|
76
|
+
status_code: int = 403,
|
|
77
|
+
fallback_view: Callable[..., Response] | None = None,
|
|
78
|
+
) -> Callable[[F], F]:
|
|
79
|
+
"""Decorator to require multiple feature flags for a view.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
feature_keys: List of feature keys to check.
|
|
83
|
+
requirement: Whether ALL or ANY features must be enabled.
|
|
84
|
+
negate: Whether to negate the result.
|
|
85
|
+
redirect_url: URL to redirect to if gate fails.
|
|
86
|
+
status_code: HTTP status code to return if gate fails (default 403).
|
|
87
|
+
fallback_view: Alternative view to call if gate fails.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Decorated view function.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
@app.route('/admin-reports')
|
|
94
|
+
@feature_gate_required(['admin-tools', 'advanced-reporting'])
|
|
95
|
+
def admin_reports():
|
|
96
|
+
return render_template('admin_reports.html')
|
|
97
|
+
|
|
98
|
+
@app.route('/exclusive')
|
|
99
|
+
@feature_gate_required(
|
|
100
|
+
['beta', 'premium'],
|
|
101
|
+
requirement=FeatureRequirement.ANY
|
|
102
|
+
)
|
|
103
|
+
def exclusive_content():
|
|
104
|
+
return render_template('exclusive.html')
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def decorator(view_func: F) -> F:
|
|
108
|
+
@functools.wraps(view_func)
|
|
109
|
+
def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
110
|
+
client = get_client()
|
|
111
|
+
|
|
112
|
+
if client is not None:
|
|
113
|
+
context = get_context_from_request()
|
|
114
|
+
if client.evaluate_gate(feature_keys, requirement, context, negate):
|
|
115
|
+
return view_func(*args, **kwargs)
|
|
116
|
+
|
|
117
|
+
# Gate failed or client unavailable
|
|
118
|
+
if redirect_url:
|
|
119
|
+
return redirect(redirect_url)
|
|
120
|
+
|
|
121
|
+
if fallback_view:
|
|
122
|
+
return fallback_view(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
abort(status_code, description="Required features are not available")
|
|
125
|
+
|
|
126
|
+
return wrapper # type: ignore
|
|
127
|
+
|
|
128
|
+
return decorator
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def feature_flag_switch(
|
|
132
|
+
feature_key: str,
|
|
133
|
+
enabled_view: Callable[..., Response],
|
|
134
|
+
disabled_view: Callable[..., Response],
|
|
135
|
+
) -> Callable[..., Response]:
|
|
136
|
+
"""Create a view that switches between two implementations.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
feature_key: The feature key to check.
|
|
140
|
+
enabled_view: View to use when feature is enabled.
|
|
141
|
+
disabled_view: View to use when feature is disabled.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A view function that delegates based on feature state.
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
app.add_url_rule(
|
|
148
|
+
'/checkout',
|
|
149
|
+
'checkout',
|
|
150
|
+
feature_flag_switch(
|
|
151
|
+
'new-checkout',
|
|
152
|
+
new_checkout_view,
|
|
153
|
+
old_checkout_view
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def view(*args: Any, **kwargs: Any) -> Response:
|
|
159
|
+
client = get_client()
|
|
160
|
+
|
|
161
|
+
if client is not None:
|
|
162
|
+
context = get_context_from_request()
|
|
163
|
+
if client.is_enabled(feature_key, context):
|
|
164
|
+
return enabled_view(*args, **kwargs)
|
|
165
|
+
|
|
166
|
+
return disabled_view(*args, **kwargs)
|
|
167
|
+
|
|
168
|
+
return view
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def feature_variant(
|
|
172
|
+
feature_key: str,
|
|
173
|
+
default_view: Callable[..., Response],
|
|
174
|
+
variants: dict[str, Callable[..., Response]] | None = None,
|
|
175
|
+
) -> Callable[[F], F]:
|
|
176
|
+
"""Decorator to select view based on feature variant value.
|
|
177
|
+
|
|
178
|
+
This decorator allows routing to different views based on the
|
|
179
|
+
feature's variant/experiment value.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
feature_key: The feature key to check.
|
|
183
|
+
default_view: View to use when feature is disabled or no variant matches.
|
|
184
|
+
variants: Dictionary mapping variant values to view functions.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Decorated view function.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
@app.route('/checkout')
|
|
191
|
+
@feature_variant(
|
|
192
|
+
'checkout-experiment',
|
|
193
|
+
default_view=original_checkout,
|
|
194
|
+
variants={
|
|
195
|
+
'variant-a': checkout_variant_a,
|
|
196
|
+
'variant-b': checkout_variant_b,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
def checkout():
|
|
200
|
+
# This function is never called; it's replaced by the variant
|
|
201
|
+
pass
|
|
202
|
+
"""
|
|
203
|
+
variants = variants or {}
|
|
204
|
+
|
|
205
|
+
def decorator(view_func: F) -> F:
|
|
206
|
+
@functools.wraps(view_func)
|
|
207
|
+
def wrapper(*args: Any, **kwargs: Any) -> Response:
|
|
208
|
+
client = get_client()
|
|
209
|
+
|
|
210
|
+
if client is not None:
|
|
211
|
+
context = get_context_from_request()
|
|
212
|
+
if client.is_enabled(feature_key, context):
|
|
213
|
+
# Get the feature state for variant info
|
|
214
|
+
state = client.get_feature_state(feature_key, context)
|
|
215
|
+
variant = state.metadata.get("variant")
|
|
216
|
+
|
|
217
|
+
if variant and variant in variants:
|
|
218
|
+
return variants[variant](*args, **kwargs)
|
|
219
|
+
|
|
220
|
+
# Feature is enabled but no matching variant
|
|
221
|
+
return view_func(*args, **kwargs)
|
|
222
|
+
|
|
223
|
+
return default_view(*args, **kwargs)
|
|
224
|
+
|
|
225
|
+
return wrapper # type: ignore
|
|
226
|
+
|
|
227
|
+
return decorator
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class FeatureFlagBlueprint:
|
|
231
|
+
"""Utility class for creating feature-gated blueprints.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
from flask import Blueprint
|
|
235
|
+
from toggly_flask import FeatureFlagBlueprint
|
|
236
|
+
|
|
237
|
+
bp = Blueprint('beta', __name__)
|
|
238
|
+
feature_bp = FeatureFlagBlueprint(bp, 'beta-features')
|
|
239
|
+
|
|
240
|
+
@feature_bp.route('/new-page')
|
|
241
|
+
def new_page():
|
|
242
|
+
return 'New page!'
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
blueprint: Any,
|
|
248
|
+
feature_key: str,
|
|
249
|
+
*,
|
|
250
|
+
redirect_url: str | None = None,
|
|
251
|
+
status_code: int = 403,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Initialize the feature-gated blueprint.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
blueprint: The Flask Blueprint to wrap.
|
|
257
|
+
feature_key: The feature key required for all routes.
|
|
258
|
+
redirect_url: URL to redirect to if feature is disabled.
|
|
259
|
+
status_code: HTTP status code to return if feature is disabled.
|
|
260
|
+
"""
|
|
261
|
+
self._blueprint = blueprint
|
|
262
|
+
self._feature_key = feature_key
|
|
263
|
+
self._redirect_url = redirect_url
|
|
264
|
+
self._status_code = status_code
|
|
265
|
+
|
|
266
|
+
def route(
|
|
267
|
+
self,
|
|
268
|
+
rule: str,
|
|
269
|
+
**options: Any,
|
|
270
|
+
) -> Callable[[F], F]:
|
|
271
|
+
"""Register a route with automatic feature flag checking.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
rule: The URL rule.
|
|
275
|
+
**options: Additional options for Blueprint.route().
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Decorator function.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def decorator(f: F) -> F:
|
|
282
|
+
@self._blueprint.route(rule, **options)
|
|
283
|
+
@feature_flag_required(
|
|
284
|
+
self._feature_key,
|
|
285
|
+
redirect_url=self._redirect_url,
|
|
286
|
+
status_code=self._status_code,
|
|
287
|
+
)
|
|
288
|
+
@functools.wraps(f)
|
|
289
|
+
def wrapped(*args: Any, **kwargs: Any) -> Response:
|
|
290
|
+
return f(*args, **kwargs)
|
|
291
|
+
|
|
292
|
+
return wrapped # type: ignore
|
|
293
|
+
|
|
294
|
+
return decorator
|