feature_gate 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.
- feature_gate-0.0.1/LICENSE +21 -0
- feature_gate-0.0.1/PKG-INFO +125 -0
- feature_gate-0.0.1/README.md +102 -0
- feature_gate-0.0.1/feature_gate/__init__.py +0 -0
- feature_gate-0.0.1/feature_gate/adapters/memory.py +79 -0
- feature_gate-0.0.1/feature_gate/adapters/posthog.py +108 -0
- feature_gate-0.0.1/feature_gate/client.py +102 -0
- feature_gate-0.0.1/feature_gate/clients/posthog_api_client.py +235 -0
- feature_gate-0.0.1/feature_gate/feature.py +17 -0
- feature_gate-0.0.1/pyproject.toml +25 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Deft Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: feature_gate
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: An extensible feature flagging library for Python
|
|
5
|
+
Home-page: https://github.com/deftinc/feature_gate
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: feature flags,feature gate
|
|
8
|
+
Author: Patrick Wiseman
|
|
9
|
+
Author-email: patrick@deft.services
|
|
10
|
+
Maintainer: Patrick Wiseman
|
|
11
|
+
Maintainer-email: patrick@deft.services
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
19
|
+
Requires-Dist: structlog (>=24.1.0,<25.0.0)
|
|
20
|
+
Project-URL: Repository, https://github.com/deftinc/feature_gate
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# FeatureGate
|
|
24
|
+
|
|
25
|
+
Featuregate is a simple feature flag API similar to [Flipper](https://github.com/flippercloud/flipper).
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
### PosthogAdapter
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export POSTHOG_API_KEY="someapikey"
|
|
33
|
+
export POSTHOG_PROJECT_ID="someprojectid"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from feature_gate.client import Client
|
|
40
|
+
from feature_gate.adapters import PosthogAdapter
|
|
41
|
+
from feature_gate.feature import Feature
|
|
42
|
+
|
|
43
|
+
adapter = PosthogAdapter() # Or MemoryAdapter for testing
|
|
44
|
+
client = Client(adapter)
|
|
45
|
+
feature = Feature("Test flag", "test_flag", "This is a test flag")
|
|
46
|
+
|
|
47
|
+
client.features()
|
|
48
|
+
# => []
|
|
49
|
+
|
|
50
|
+
client.add(feature)
|
|
51
|
+
# => True
|
|
52
|
+
|
|
53
|
+
client.features()
|
|
54
|
+
# => ["test_flag"]
|
|
55
|
+
|
|
56
|
+
client.is_enabled("test_flag")
|
|
57
|
+
# => False
|
|
58
|
+
|
|
59
|
+
client.enable("test_flag")
|
|
60
|
+
# => True
|
|
61
|
+
|
|
62
|
+
client.is_enabled("test_flag")
|
|
63
|
+
# => True
|
|
64
|
+
|
|
65
|
+
client.disable("test_flag")
|
|
66
|
+
# => True
|
|
67
|
+
|
|
68
|
+
client.is_enabled("test_flag")
|
|
69
|
+
# => False
|
|
70
|
+
|
|
71
|
+
client.remove("test_flag")
|
|
72
|
+
# => True
|
|
73
|
+
|
|
74
|
+
client.features()
|
|
75
|
+
# => []
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
### FeatureNotFound
|
|
81
|
+
If you try to run something that presumes the existence of a feature and it can't find it it'll throw a `FeatureNotFound`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from feature_gate.client import FeatureNotFound
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
client.remove("some_feature_that_definitely_does_not_exist")
|
|
88
|
+
except FeatureNotFound as err:
|
|
89
|
+
# Do what we want to do when the feature doesn't exist
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### PostApiClientError
|
|
93
|
+
For the `PosthogAdapter` in particular it will raise error if it was unable to reach the Posthog API. These get bubbled up as `PosthogAPIClientError`.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from feature_gate.clients.posthog_api_client import PosthogAPIClientError
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
client.features() #disable network connection
|
|
100
|
+
except PosthogAPIClientError as err:
|
|
101
|
+
# Handle the error -- define default behavior in outage
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Testing
|
|
105
|
+
|
|
106
|
+
The Memory Adapter can be used for writing tests. This creates an ephemeral memory only implementation of the feature_gate client API. This is non-suitable for production only for tests.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from feature_gate.client import Client
|
|
110
|
+
from feature_gate.adapters.memory import MemoryAdapter
|
|
111
|
+
|
|
112
|
+
client = Client(MemoryAdapter())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Developing this library
|
|
116
|
+
|
|
117
|
+
### Hot-reload the REPL
|
|
118
|
+
|
|
119
|
+
Hot-reload the client in the repl for development:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
$ repl
|
|
123
|
+
>>> exec(open('reload.py').read())
|
|
124
|
+
```
|
|
125
|
+
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# FeatureGate
|
|
2
|
+
|
|
3
|
+
Featuregate is a simple feature flag API similar to [Flipper](https://github.com/flippercloud/flipper).
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
### PosthogAdapter
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
export POSTHOG_API_KEY="someapikey"
|
|
11
|
+
export POSTHOG_PROJECT_ID="someprojectid"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from feature_gate.client import Client
|
|
18
|
+
from feature_gate.adapters import PosthogAdapter
|
|
19
|
+
from feature_gate.feature import Feature
|
|
20
|
+
|
|
21
|
+
adapter = PosthogAdapter() # Or MemoryAdapter for testing
|
|
22
|
+
client = Client(adapter)
|
|
23
|
+
feature = Feature("Test flag", "test_flag", "This is a test flag")
|
|
24
|
+
|
|
25
|
+
client.features()
|
|
26
|
+
# => []
|
|
27
|
+
|
|
28
|
+
client.add(feature)
|
|
29
|
+
# => True
|
|
30
|
+
|
|
31
|
+
client.features()
|
|
32
|
+
# => ["test_flag"]
|
|
33
|
+
|
|
34
|
+
client.is_enabled("test_flag")
|
|
35
|
+
# => False
|
|
36
|
+
|
|
37
|
+
client.enable("test_flag")
|
|
38
|
+
# => True
|
|
39
|
+
|
|
40
|
+
client.is_enabled("test_flag")
|
|
41
|
+
# => True
|
|
42
|
+
|
|
43
|
+
client.disable("test_flag")
|
|
44
|
+
# => True
|
|
45
|
+
|
|
46
|
+
client.is_enabled("test_flag")
|
|
47
|
+
# => False
|
|
48
|
+
|
|
49
|
+
client.remove("test_flag")
|
|
50
|
+
# => True
|
|
51
|
+
|
|
52
|
+
client.features()
|
|
53
|
+
# => []
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Errors
|
|
57
|
+
|
|
58
|
+
### FeatureNotFound
|
|
59
|
+
If you try to run something that presumes the existence of a feature and it can't find it it'll throw a `FeatureNotFound`.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from feature_gate.client import FeatureNotFound
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
client.remove("some_feature_that_definitely_does_not_exist")
|
|
66
|
+
except FeatureNotFound as err:
|
|
67
|
+
# Do what we want to do when the feature doesn't exist
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### PostApiClientError
|
|
71
|
+
For the `PosthogAdapter` in particular it will raise error if it was unable to reach the Posthog API. These get bubbled up as `PosthogAPIClientError`.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from feature_gate.clients.posthog_api_client import PosthogAPIClientError
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
client.features() #disable network connection
|
|
78
|
+
except PosthogAPIClientError as err:
|
|
79
|
+
# Handle the error -- define default behavior in outage
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Testing
|
|
83
|
+
|
|
84
|
+
The Memory Adapter can be used for writing tests. This creates an ephemeral memory only implementation of the feature_gate client API. This is non-suitable for production only for tests.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from feature_gate.client import Client
|
|
88
|
+
from feature_gate.adapters.memory import MemoryAdapter
|
|
89
|
+
|
|
90
|
+
client = Client(MemoryAdapter())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Developing this library
|
|
94
|
+
|
|
95
|
+
### Hot-reload the REPL
|
|
96
|
+
|
|
97
|
+
Hot-reload the client in the repl for development:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
$ repl
|
|
101
|
+
>>> exec(open('reload.py').read())
|
|
102
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import structlog
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from feature_gate.client import FeatureNotFound
|
|
4
|
+
|
|
5
|
+
from structlog.contextvars import (
|
|
6
|
+
bind_contextvars,
|
|
7
|
+
merge_contextvars,
|
|
8
|
+
bound_contextvars,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
class MemoryAdapter:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._features = []
|
|
14
|
+
bind_contextvars(klass="MemoryAdapter")
|
|
15
|
+
structlog.configure(
|
|
16
|
+
processors=[
|
|
17
|
+
merge_contextvars,
|
|
18
|
+
structlog.processors.add_log_level,
|
|
19
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
20
|
+
structlog.processors.dict_tracebacks,
|
|
21
|
+
structlog.processors.JSONRenderer(),
|
|
22
|
+
],
|
|
23
|
+
logger_factory=structlog.WriteLoggerFactory(
|
|
24
|
+
file=Path("development").with_suffix(".log").open("wt")
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
self._logger = structlog.get_logger()
|
|
28
|
+
|
|
29
|
+
def add(self, feature):
|
|
30
|
+
self._logger.info("Add feature key={feature.key}")
|
|
31
|
+
if feature.key not in self._features:
|
|
32
|
+
self._features.append({
|
|
33
|
+
"name": feature.name,
|
|
34
|
+
"key": feature.key,
|
|
35
|
+
"description": feature.description,
|
|
36
|
+
"gates": {
|
|
37
|
+
"boolean": {
|
|
38
|
+
"enabled": False
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def remove(self, feature_key):
|
|
45
|
+
self._logger.info("Remove feature key={feature.key}")
|
|
46
|
+
for feature in self._features:
|
|
47
|
+
if feature["key"] == feature_key:
|
|
48
|
+
self._features.remove(feature)
|
|
49
|
+
return True
|
|
50
|
+
raise FeatureNotFound("Feature {feature_key} not found.")
|
|
51
|
+
|
|
52
|
+
def features(self):
|
|
53
|
+
features = [feature["key"] for feature in self._features]
|
|
54
|
+
self._logger.info("Lists features {features}")
|
|
55
|
+
return features
|
|
56
|
+
|
|
57
|
+
def is_enabled(self, feature_key):
|
|
58
|
+
for feature in self._features:
|
|
59
|
+
if feature["key"] == feature_key:
|
|
60
|
+
is_enabled = feature["gates"]["boolean"]["enabled"]
|
|
61
|
+
self._logger.info("Check feature key={feature.key} enabled={is_enabled}")
|
|
62
|
+
return is_enabled
|
|
63
|
+
raise FeatureNotFound("Feature {feature_key} not found.")
|
|
64
|
+
|
|
65
|
+
def enable(self, feature_key):
|
|
66
|
+
self._logger.info("Enable feature key={feature.key}")
|
|
67
|
+
for feature in self._features:
|
|
68
|
+
if feature["key"] == feature_key:
|
|
69
|
+
feature["gates"]["boolean"]["enabled"] = True
|
|
70
|
+
return True
|
|
71
|
+
raise FeatureNotFound("Feature {feature_key} not found.")
|
|
72
|
+
|
|
73
|
+
def disable(self, feature_key):
|
|
74
|
+
self._logger.info("Disable feature key={feature.key}")
|
|
75
|
+
for feature in self._features:
|
|
76
|
+
if feature["key"] == feature_key:
|
|
77
|
+
feature["gates"]["boolean"]["enabled"] = False
|
|
78
|
+
return True
|
|
79
|
+
raise FeatureNotFound("Feature {feature_key} not found.")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from feature_gate.clients.posthog_api_client import PosthogAPIClient
|
|
2
|
+
class PosthogAdapter:
|
|
3
|
+
def __init__(self, api_key=None, project_id=None):
|
|
4
|
+
self.client = PosthogAPIClient(api_key=api_key, project_id=project_id)
|
|
5
|
+
|
|
6
|
+
def client(self):
|
|
7
|
+
return self.client
|
|
8
|
+
|
|
9
|
+
def add(self, feature):
|
|
10
|
+
if self.client.fetch_feature(feature.key) is None:
|
|
11
|
+
self.client.create_feature(feature.key, feature.description)
|
|
12
|
+
return True
|
|
13
|
+
|
|
14
|
+
def remove(self, feature_key):
|
|
15
|
+
if not self.client.fetch_feature(feature_key) is None:
|
|
16
|
+
self.client.delete_feature(feature_key)
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
def features(self):
|
|
20
|
+
resp = self.client.list_features()
|
|
21
|
+
key='key'
|
|
22
|
+
return [item[key] for item in resp["data"] if key in item]
|
|
23
|
+
|
|
24
|
+
def is_enabled(self, feature_key):
|
|
25
|
+
return self.client.is_enabled(feature_key)
|
|
26
|
+
|
|
27
|
+
def enable(self, feature_key):
|
|
28
|
+
resp = self.client.enable_feature(feature_key)
|
|
29
|
+
return resp["data"]["active"]
|
|
30
|
+
|
|
31
|
+
def disable(self, feature_key):
|
|
32
|
+
resp = self.client.disable_feature(feature_key)
|
|
33
|
+
return resp["data"]["active"] == False
|
|
34
|
+
|
|
35
|
+
# def enable_expression(self, expression):
|
|
36
|
+
# raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
# def disable_expression(self, expression):
|
|
39
|
+
# raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
# def expression(self):
|
|
42
|
+
# raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
# def add_expression(self, expression):
|
|
45
|
+
# raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
# def remove_expression(self, expression):
|
|
48
|
+
# raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
# def enable_actor(self, feature, actor):
|
|
51
|
+
# raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
# def disable_actor(self, feature, actor):
|
|
54
|
+
# raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
# def enable_group(self, feature, group):
|
|
57
|
+
# raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
# def disable_group(self, feature, group):
|
|
60
|
+
# raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
# def enable_percentage_of_actors(self, feature, percentage):
|
|
63
|
+
# raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
# def disable_percentage_of_actors(self, feature, percentage):
|
|
66
|
+
# raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
# def enable_percentage_of_time(self, feature, percentage):
|
|
69
|
+
# raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
# def disable_percentage_of_time(self, feature, percentage):
|
|
72
|
+
# raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
# def feature(self):
|
|
75
|
+
# raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
# # def [](self):
|
|
78
|
+
# # raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
# def preload(self):
|
|
81
|
+
# raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
# def preload_all(self):
|
|
84
|
+
# raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
# def adapter(self):
|
|
87
|
+
# raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
# def do_import(self):
|
|
90
|
+
# raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
# def do_export(self):
|
|
93
|
+
# raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
# def memoize(self):
|
|
96
|
+
# raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
# def is_memorizing(self):
|
|
99
|
+
# raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
# def is_read_only(self):
|
|
102
|
+
# raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
# def sync(self):
|
|
105
|
+
# raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
# def sync_secret(self):
|
|
108
|
+
# raise NotImplementedError
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
class FeatureNotFound(ValueError):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
class Client:
|
|
5
|
+
def __init__(self, adapter):
|
|
6
|
+
self.adapter = adapter
|
|
7
|
+
|
|
8
|
+
def adapter(self):
|
|
9
|
+
return self.adapter
|
|
10
|
+
|
|
11
|
+
def add(self, feature):
|
|
12
|
+
return self.adapter.add(feature)
|
|
13
|
+
|
|
14
|
+
def remove(self, feature):
|
|
15
|
+
return self.adapter.remove(feature)
|
|
16
|
+
|
|
17
|
+
def features(self):
|
|
18
|
+
return self.adapter.features()
|
|
19
|
+
|
|
20
|
+
def is_enabled(self, feature):
|
|
21
|
+
return self.adapter.is_enabled(feature)
|
|
22
|
+
|
|
23
|
+
def enable(self, feature):
|
|
24
|
+
return self.adapter.enable(feature)
|
|
25
|
+
|
|
26
|
+
def disable(self, feature):
|
|
27
|
+
return self.adapter.disable(feature)
|
|
28
|
+
|
|
29
|
+
# def enable_expression(self, expression):
|
|
30
|
+
# raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
# def disable_expression(self, expression):
|
|
33
|
+
# raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
# def expression(self):
|
|
36
|
+
# raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
# def add_expression(self, expression):
|
|
39
|
+
# raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
# def remove_expression(self, expression):
|
|
42
|
+
# raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
# def enable_actor(self, feature, actor):
|
|
45
|
+
# raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
# def disable_actor(self, feature, actor):
|
|
48
|
+
# raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
# def enable_group(self, feature, group):
|
|
51
|
+
# raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
# def disable_group(self, feature, group):
|
|
54
|
+
# raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
# def enable_percentage_of_actors(self, feature, percentage):
|
|
57
|
+
# raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
# def disable_percentage_of_actors(self, feature, percentage):
|
|
60
|
+
# raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
# def enable_percentage_of_time(self, feature, percentage):
|
|
63
|
+
# raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
# def disable_percentage_of_time(self, feature, percentage):
|
|
66
|
+
# raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
# def feature(self):
|
|
69
|
+
# raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
# # def [](self):
|
|
72
|
+
# # raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
# def preload(self):
|
|
75
|
+
# raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
# def preload_all(self):
|
|
78
|
+
# raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
# def adapter(self):
|
|
81
|
+
# raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
# def do_import(self):
|
|
84
|
+
# raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
# def do_export(self):
|
|
87
|
+
# raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
# def memoize(self):
|
|
90
|
+
# raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
# def is_memorizing(self):
|
|
93
|
+
# raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
# def is_read_only(self):
|
|
96
|
+
# raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
# def sync(self):
|
|
99
|
+
# raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
# def sync_secret(self):
|
|
102
|
+
# raise NotImplementedError
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import requests
|
|
4
|
+
import structlog
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from feature_gate.client import FeatureNotFound
|
|
8
|
+
|
|
9
|
+
from structlog.contextvars import (
|
|
10
|
+
bind_contextvars,
|
|
11
|
+
merge_contextvars,
|
|
12
|
+
bound_contextvars,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class PosthogAPIClientError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class PosthogAPIClient:
|
|
19
|
+
def __init__(self, api_base=None, api_key=None, project_id=None):
|
|
20
|
+
if api_base is None:
|
|
21
|
+
self.api_base = os.environ.get("POSTHOG_API_BASE", "https://app.posthog.com")
|
|
22
|
+
else:
|
|
23
|
+
self.api_base = api_base
|
|
24
|
+
|
|
25
|
+
if api_key is None:
|
|
26
|
+
self.api_key = os.environ.get('POSTHOG_API_KEY')
|
|
27
|
+
else:
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
|
|
30
|
+
if project_id is None:
|
|
31
|
+
self.project_id = os.environ.get('POSTHOG_PROJECT_ID')
|
|
32
|
+
else:
|
|
33
|
+
self.project_id = project_id
|
|
34
|
+
|
|
35
|
+
bind_contextvars(klass="PosthogAPIClient", project_id=project_id)
|
|
36
|
+
structlog.configure(
|
|
37
|
+
processors=[
|
|
38
|
+
merge_contextvars,
|
|
39
|
+
structlog.processors.add_log_level,
|
|
40
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
41
|
+
structlog.processors.dict_tracebacks,
|
|
42
|
+
structlog.processors.JSONRenderer(),
|
|
43
|
+
],
|
|
44
|
+
logger_factory=structlog.WriteLoggerFactory(
|
|
45
|
+
file=Path("development").with_suffix(".log").open("wt")
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
self.logger = structlog.get_logger()
|
|
49
|
+
|
|
50
|
+
def api_base(self):
|
|
51
|
+
return self.api_base
|
|
52
|
+
|
|
53
|
+
def api_key(self):
|
|
54
|
+
return self.api_key
|
|
55
|
+
|
|
56
|
+
def project_id(self):
|
|
57
|
+
return self.project_id
|
|
58
|
+
|
|
59
|
+
def list_features(self):
|
|
60
|
+
path = f'/api/projects/{self.project_id}/feature_flags'
|
|
61
|
+
with bound_contextvars(method="list_features"):
|
|
62
|
+
response = self._get(path)
|
|
63
|
+
return self._map_list_response("GET", path, response)
|
|
64
|
+
|
|
65
|
+
def create_feature(self, name, description, deleted=False, active=False):
|
|
66
|
+
with bound_contextvars(method="create_feature"):
|
|
67
|
+
path = f'/api/projects/{self.project_id}/feature_flags'
|
|
68
|
+
payload = {
|
|
69
|
+
'name': description,
|
|
70
|
+
'key': name,
|
|
71
|
+
'deleted': deleted,
|
|
72
|
+
'active': active
|
|
73
|
+
}
|
|
74
|
+
response = self._post(path, payload)
|
|
75
|
+
return self._map_single_response("POST", path, response)
|
|
76
|
+
|
|
77
|
+
def fetch_feature(self, key):
|
|
78
|
+
features = self.list_features()["data"]
|
|
79
|
+
for entry in features:
|
|
80
|
+
if "key" in entry and entry["key"] == key:
|
|
81
|
+
return entry
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def delete_feature(self, key):
|
|
85
|
+
feature = self.fetch_feature(key)
|
|
86
|
+
if feature == None:
|
|
87
|
+
raise FeatureNotFound(f"Feature {key} not found")
|
|
88
|
+
else:
|
|
89
|
+
path = f'/api/projects/{self.project_id}/feature_flags/{feature["id"]}'
|
|
90
|
+
with bound_contextvars(method="delete_feature"):
|
|
91
|
+
payload = {
|
|
92
|
+
'deleted': True
|
|
93
|
+
}
|
|
94
|
+
response = self._patch(path, payload)
|
|
95
|
+
return self._map_single_response("PATCH", path, response)
|
|
96
|
+
|
|
97
|
+
def is_enabled(self, key):
|
|
98
|
+
feature = self.fetch_feature(key)
|
|
99
|
+
if feature == None:
|
|
100
|
+
raise FeatureNotFound(f"Feature {key} not found")
|
|
101
|
+
else:
|
|
102
|
+
return feature["active"]
|
|
103
|
+
|
|
104
|
+
def enable_feature(self, key):
|
|
105
|
+
feature = self.fetch_feature(key)
|
|
106
|
+
if feature == None:
|
|
107
|
+
raise FeatureNotFound(f"Feature {key} not found")
|
|
108
|
+
else:
|
|
109
|
+
path = f'/api/projects/{self.project_id}/feature_flags/{feature["id"]}'
|
|
110
|
+
with bound_contextvars(method="enable_feature"):
|
|
111
|
+
payload = {
|
|
112
|
+
'active': True
|
|
113
|
+
}
|
|
114
|
+
response = self._patch(path, payload)
|
|
115
|
+
return self._map_single_response("PATCH", path, response)
|
|
116
|
+
|
|
117
|
+
def disable_feature(self, key):
|
|
118
|
+
feature = self.fetch_feature(key)
|
|
119
|
+
if feature == None:
|
|
120
|
+
raise FeatureNotFound(f"Feature {key} not found")
|
|
121
|
+
else:
|
|
122
|
+
path = f'/api/projects/{self.project_id}/feature_flags/{feature["id"]}'
|
|
123
|
+
with bound_contextvars(method="disable_feature"):
|
|
124
|
+
payload = {
|
|
125
|
+
'active': False
|
|
126
|
+
}
|
|
127
|
+
response = self._patch(path, payload)
|
|
128
|
+
return self._map_single_response("PATCH", path, response)
|
|
129
|
+
|
|
130
|
+
def _get(self, path):
|
|
131
|
+
try:
|
|
132
|
+
return self.__get(path)
|
|
133
|
+
except requests.ConnectionError as err:
|
|
134
|
+
self._log_posthog_connection_error(err)
|
|
135
|
+
|
|
136
|
+
def __get(self, path):
|
|
137
|
+
with bound_contextvars(method="get"):
|
|
138
|
+
url = f"{self.api_base}{path}"
|
|
139
|
+
headers = {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
142
|
+
}
|
|
143
|
+
response = requests.get(url, headers=headers)
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
def _post(self, path, payload):
|
|
147
|
+
try:
|
|
148
|
+
return self.__post(path, payload)
|
|
149
|
+
except requests.ConnectionError as err:
|
|
150
|
+
self._log_posthog_connection_error(err)
|
|
151
|
+
|
|
152
|
+
def __post(self, path, payload):
|
|
153
|
+
url = f"{self.api_base}{path}"
|
|
154
|
+
with bound_contextvars(method="post", url=url):
|
|
155
|
+
json_payload = json.dumps(payload)
|
|
156
|
+
headers = self._get_headers()
|
|
157
|
+
response = requests.post(url, data=json_payload, headers=headers)
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
def _patch(self, path, payload):
|
|
161
|
+
try:
|
|
162
|
+
return self.__patch(path, payload)
|
|
163
|
+
except requests.ConnectionError as err:
|
|
164
|
+
self._log_posthog_connection_error(err)
|
|
165
|
+
|
|
166
|
+
def __patch(self, path, payload):
|
|
167
|
+
url = f"{self.api_base}{path}"
|
|
168
|
+
with bound_contextvars(method="patch", url=url):
|
|
169
|
+
json_payload = json.dumps(payload)
|
|
170
|
+
headers = self._get_headers()
|
|
171
|
+
response = requests.patch(url, data=json_payload, headers=headers)
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
def _get_headers(self):
|
|
175
|
+
return {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _check_status_ok(self, code):
|
|
181
|
+
return code == 200 or code == 201
|
|
182
|
+
|
|
183
|
+
def _map_single_response(self, method, path, response):
|
|
184
|
+
ret = None
|
|
185
|
+
if self._check_status_ok(response.status_code):
|
|
186
|
+
data = response.json()
|
|
187
|
+
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
|
|
188
|
+
ret = self._map_single_response_success(data)
|
|
189
|
+
else:
|
|
190
|
+
data = response.json()
|
|
191
|
+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
|
|
192
|
+
ret = self._map_error_response(response.status_code, data)
|
|
193
|
+
return ret
|
|
194
|
+
|
|
195
|
+
def _map_list_response(self, method, path, response):
|
|
196
|
+
ret = None
|
|
197
|
+
if self._check_status_ok(response.status_code):
|
|
198
|
+
data = response.json()
|
|
199
|
+
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
|
|
200
|
+
ret = self._map_list_response_success(data)
|
|
201
|
+
else:
|
|
202
|
+
data = response.json()
|
|
203
|
+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
|
|
204
|
+
ret = self._map_error_response(response.status_code, data)
|
|
205
|
+
return ret
|
|
206
|
+
|
|
207
|
+
def _map_error_response(self, code, data):
|
|
208
|
+
return {
|
|
209
|
+
"errors": [
|
|
210
|
+
{
|
|
211
|
+
"status": code,
|
|
212
|
+
"detail": data.get("detail"),
|
|
213
|
+
"code": data.get("code"),
|
|
214
|
+
"type": data.get("type")
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def _map_single_response_success(self, data):
|
|
220
|
+
return {
|
|
221
|
+
"data": data
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
def _map_list_response_success(self, data):
|
|
225
|
+
return {
|
|
226
|
+
"data": data.get("results"),
|
|
227
|
+
"pagination": {
|
|
228
|
+
"next": data.get("next"),
|
|
229
|
+
"previous": data.get("previous")
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def _log_posthog_connection_error(self, error):
|
|
234
|
+
self.logger.error(f"Posthog connection error - {error}")
|
|
235
|
+
raise PosthogAPIClientError(f"Posthog connection error - {error}")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Feature:
|
|
2
|
+
def __init__(self, name, key, description):
|
|
3
|
+
self._name = name
|
|
4
|
+
self._key = key
|
|
5
|
+
self._description = description
|
|
6
|
+
|
|
7
|
+
@property
|
|
8
|
+
def name(self):
|
|
9
|
+
return self._name
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def key(self):
|
|
13
|
+
return self._key
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def description(self):
|
|
17
|
+
return self._description
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
[tool.poetry]
|
|
3
|
+
name = "feature_gate"
|
|
4
|
+
version = "0.0.1"
|
|
5
|
+
description = "An extensible feature flagging library for Python"
|
|
6
|
+
authors = ["Patrick Wiseman <patrick@deft.services>"]
|
|
7
|
+
maintainers = ["Patrick Wiseman <patrick@deft.services>"]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
homepage = "https://github.com/deftinc/feature_gate"
|
|
11
|
+
repository = "https://github.com/deftinc/feature_gate"
|
|
12
|
+
keywords = ["feature flags", "feature gate"]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = ">= 3.10"
|
|
16
|
+
structlog = "^24.1.0"
|
|
17
|
+
requests = "^2.31.0"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.group.test.dependencies]
|
|
20
|
+
pytest = "^8.0.0"
|
|
21
|
+
pytest-mock = "^3.12.0"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|