switcher-client 0.1.2__py3-none-any.whl
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.
- switcher_client/__init__.py +11 -0
- switcher_client/client.py +262 -0
- switcher_client/errors/__init__.py +44 -0
- switcher_client/lib/__init__.py +7 -0
- switcher_client/lib/compat.py +8 -0
- switcher_client/lib/globals/__init__.py +10 -0
- switcher_client/lib/globals/global_auth.py +28 -0
- switcher_client/lib/globals/global_context.py +95 -0
- switcher_client/lib/globals/global_retry.py +16 -0
- switcher_client/lib/globals/global_snapshot.py +33 -0
- switcher_client/lib/remote.py +153 -0
- switcher_client/lib/remote_auth.py +74 -0
- switcher_client/lib/resolver.py +99 -0
- switcher_client/lib/snapshot.py +214 -0
- switcher_client/lib/snapshot_auto_updater.py +63 -0
- switcher_client/lib/snapshot_loader.py +77 -0
- switcher_client/lib/snapshot_watcher.py +67 -0
- switcher_client/lib/types.py +169 -0
- switcher_client/lib/utils/__init__.py +21 -0
- switcher_client/lib/utils/date_moment.py +26 -0
- switcher_client/lib/utils/execution_logger.py +96 -0
- switcher_client/lib/utils/ipcidr.py +46 -0
- switcher_client/lib/utils/payload_reader.py +43 -0
- switcher_client/lib/utils/timed_match/__init__.py +3 -0
- switcher_client/lib/utils/timed_match/timed_match.py +246 -0
- switcher_client/lib/utils/timed_match/worker.py +100 -0
- switcher_client/switcher.py +188 -0
- switcher_client/switcher_data.py +112 -0
- switcher_client/version.py +1 -0
- switcher_client-0.1.2.dist-info/METADATA +402 -0
- switcher_client-0.1.2.dist-info/RECORD +34 -0
- switcher_client-0.1.2.dist-info/WHEEL +5 -0
- switcher_client-0.1.2.dist-info/licenses/LICENSE +21 -0
- switcher_client-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from switcher_client.client import Client
|
|
2
|
+
from switcher_client.switcher import Switcher
|
|
3
|
+
from switcher_client.lib.globals.global_context import ContextOptions
|
|
4
|
+
from switcher_client.lib.snapshot_watcher import WatchSnapshotCallback
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'Client',
|
|
8
|
+
'Switcher',
|
|
9
|
+
'ContextOptions',
|
|
10
|
+
'WatchSnapshotCallback',
|
|
11
|
+
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from typing import Optional, Callable
|
|
2
|
+
|
|
3
|
+
from switcher_client.lib.globals.global_auth import GlobalAuth
|
|
4
|
+
from switcher_client.lib.globals.global_snapshot import GlobalSnapshot, LoadSnapshotOptions
|
|
5
|
+
from switcher_client.lib.globals.global_context import Context, ContextOptions, DEFAULT_ENVIRONMENT
|
|
6
|
+
from switcher_client.lib.remote_auth import RemoteAuth
|
|
7
|
+
from switcher_client.lib.remote import Remote
|
|
8
|
+
from switcher_client.lib.snapshot_auto_updater import SnapshotAutoUpdater
|
|
9
|
+
from switcher_client.lib.snapshot_loader import check_switchers, load_domain, validate_snapshot, save_snapshot
|
|
10
|
+
from switcher_client.lib.snapshot_watcher import SnapshotWatcher, WatchSnapshotCallback
|
|
11
|
+
from switcher_client.lib.utils.execution_logger import ExecutionLogger
|
|
12
|
+
from switcher_client.lib.utils.timed_match.timed_match import TimedMatch
|
|
13
|
+
from switcher_client.lib.utils import get
|
|
14
|
+
from switcher_client.errors import SnapshotNotFoundError
|
|
15
|
+
from switcher_client.switcher import Switcher
|
|
16
|
+
|
|
17
|
+
REGEX_MAX_BLACK_LIST = 'regex_max_black_list'
|
|
18
|
+
REGEX_MAX_TIME_LIMIT = 'regex_max_time_limit'
|
|
19
|
+
SNAPSHOT_AUTO_UPDATE_INTERVAL = 'snapshot_auto_update_interval'
|
|
20
|
+
SILENT_MODE = 'silent_mode'
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
"""
|
|
24
|
+
Quick start with the following steps:
|
|
25
|
+
|
|
26
|
+
1. Use `Client.build_context()` to build the context for the client.
|
|
27
|
+
2. Use `Client.get_switcher()` to create a new instance of Switcher.
|
|
28
|
+
3. Use the instance created to evaluate criteria.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
Client.build_context(
|
|
32
|
+
domain='My Domain', # Your Switcher domain name
|
|
33
|
+
url='https://api.switcherapi.com', # Switcher-API endpoint (optional)
|
|
34
|
+
api_key='[YOUR_API_KEY]', # Your component's API key (optional)
|
|
35
|
+
component='MyApp', # Your application name (optional)
|
|
36
|
+
environment='default' # Environment ('default' for production)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
switcher = Client.get_switcher()
|
|
40
|
+
result = switcher.is_on('my_criteria')
|
|
41
|
+
"""
|
|
42
|
+
_context: Context = Context.empty()
|
|
43
|
+
_switcher: dict[str, Switcher] = {}
|
|
44
|
+
_snapshot_auto_updater: SnapshotAutoUpdater = SnapshotAutoUpdater()
|
|
45
|
+
_snapshot_watcher: SnapshotWatcher = SnapshotWatcher()
|
|
46
|
+
|
|
47
|
+
# pylint: disable=too-many-arguments
|
|
48
|
+
@staticmethod
|
|
49
|
+
def build_context(*,
|
|
50
|
+
domain: str,
|
|
51
|
+
url: Optional[str] = None,
|
|
52
|
+
api_key: Optional[str] = None,
|
|
53
|
+
component: Optional[str] = None,
|
|
54
|
+
environment: Optional[str] = DEFAULT_ENVIRONMENT,
|
|
55
|
+
options = ContextOptions()):
|
|
56
|
+
"""
|
|
57
|
+
Build the context for the client
|
|
58
|
+
|
|
59
|
+
:param domain: Domain name
|
|
60
|
+
:param url:Switcher-API URL
|
|
61
|
+
:param api_key: Switcher-API key generated for the application/component
|
|
62
|
+
:param component: Application/component name
|
|
63
|
+
:param environment: Environment name
|
|
64
|
+
:param options: Optional parameters
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
Client._context = Context(
|
|
68
|
+
domain=domain, url=url,
|
|
69
|
+
api_key=api_key,
|
|
70
|
+
component=component,
|
|
71
|
+
environment=environment,
|
|
72
|
+
options=options)
|
|
73
|
+
|
|
74
|
+
# Default values
|
|
75
|
+
GlobalSnapshot.clear()
|
|
76
|
+
|
|
77
|
+
# Build Options
|
|
78
|
+
if options is not None:
|
|
79
|
+
Client._build_options(options)
|
|
80
|
+
|
|
81
|
+
# Initialize Auth
|
|
82
|
+
RemoteAuth.init(Client._context)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _build_options(options: ContextOptions):
|
|
86
|
+
options_handler = {
|
|
87
|
+
SNAPSHOT_AUTO_UPDATE_INTERVAL: Client.schedule_snapshot_auto_update,
|
|
88
|
+
SILENT_MODE: lambda: Client._init_silent_mode(get(options.silent_mode, '')),
|
|
89
|
+
REGEX_MAX_BLACK_LIST: lambda: TimedMatch.set_max_blacklisted(options.regex_max_black_list),
|
|
90
|
+
REGEX_MAX_TIME_LIMIT: lambda: TimedMatch.set_max_time_limit(options.regex_max_time_limit)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for option_key, handler in options_handler.items():
|
|
94
|
+
if hasattr(options, option_key) and getattr(options, option_key) is not None:
|
|
95
|
+
handler()
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _init_silent_mode(silent_mode: str):
|
|
99
|
+
if silent_mode != '':
|
|
100
|
+
RemoteAuth.set_retry_options(silent_mode)
|
|
101
|
+
Client._context.options.silent_mode = silent_mode
|
|
102
|
+
Client.load_snapshot()
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def get_switcher(key: Optional[str] = None) -> Switcher:
|
|
106
|
+
"""
|
|
107
|
+
Creates a new instance of Switcher.
|
|
108
|
+
Provide a key if you want to persist the instance.
|
|
109
|
+
"""
|
|
110
|
+
key_value = get(key, '')
|
|
111
|
+
persisted_switcher = Client._switcher.get(key_value)
|
|
112
|
+
|
|
113
|
+
if persisted_switcher is not None:
|
|
114
|
+
return persisted_switcher
|
|
115
|
+
|
|
116
|
+
switcher = Switcher(Client._context, key_value) \
|
|
117
|
+
.restrict_relay(Client._context.options.restrict_relay)
|
|
118
|
+
|
|
119
|
+
if key_value != '':
|
|
120
|
+
Client._switcher[key_value] = switcher
|
|
121
|
+
|
|
122
|
+
return switcher
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def load_snapshot(options: Optional[LoadSnapshotOptions] = None) -> int:
|
|
127
|
+
""" Load Domain from snapshot """
|
|
128
|
+
snapshot_options = get(options, LoadSnapshotOptions())
|
|
129
|
+
|
|
130
|
+
GlobalSnapshot.init(load_domain(
|
|
131
|
+
get(Client._context.options.snapshot_location, ''),
|
|
132
|
+
get(Client._context.environment, DEFAULT_ENVIRONMENT)
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
if Client._is_check_snapshot_available(snapshot_options.fetch_remote):
|
|
136
|
+
Client.check_snapshot()
|
|
137
|
+
|
|
138
|
+
return Client.snapshot_version()
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def check_snapshot():
|
|
142
|
+
""" Verifies if the current snapshot file is updated
|
|
143
|
+
Return true if an update has been made
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
if RemoteAuth.is_token_expired():
|
|
147
|
+
RemoteAuth.auth()
|
|
148
|
+
|
|
149
|
+
snapshot = validate_snapshot(
|
|
150
|
+
context=Client._context,
|
|
151
|
+
snapshot_version=Client.snapshot_version(),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if snapshot is not None:
|
|
155
|
+
if Client._context.options.snapshot_location is not None:
|
|
156
|
+
save_snapshot(
|
|
157
|
+
snapshot=snapshot,
|
|
158
|
+
snapshot_location=get(Client._context.options.snapshot_location, ''),
|
|
159
|
+
environment=get(Client._context.environment, DEFAULT_ENVIRONMENT)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
GlobalSnapshot.init(snapshot)
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def schedule_snapshot_auto_update(interval: Optional[int] = None,
|
|
169
|
+
callback: Optional[Callable[[Optional[Exception], bool], None]] = None):
|
|
170
|
+
""" Schedule Snapshot auto update """
|
|
171
|
+
callback = get(callback, lambda *_: None)
|
|
172
|
+
|
|
173
|
+
if interval is not None:
|
|
174
|
+
Client._context.options.snapshot_auto_update_interval = interval
|
|
175
|
+
|
|
176
|
+
if Client._context.options.snapshot_auto_update_interval is not None and \
|
|
177
|
+
Client._context.options.snapshot_auto_update_interval > 0:
|
|
178
|
+
Client._snapshot_auto_updater.schedule(
|
|
179
|
+
interval=Client._context.options.snapshot_auto_update_interval,
|
|
180
|
+
check_snapshot=Client.check_snapshot,
|
|
181
|
+
callback=callback
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def terminate_snapshot_auto_update():
|
|
186
|
+
""" Terminate Snapshot auto update """
|
|
187
|
+
Client._snapshot_auto_updater.terminate()
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def watch_snapshot(callback: Optional[WatchSnapshotCallback] = None) -> None:
|
|
191
|
+
""" Watch snapshot file for changes and invoke callbacks on result """
|
|
192
|
+
callback = get(callback, WatchSnapshotCallback())
|
|
193
|
+
snapshot_location = Client._context.options.snapshot_location
|
|
194
|
+
|
|
195
|
+
if snapshot_location is None:
|
|
196
|
+
callback.reject(SnapshotNotFoundError("Snapshot location is not defined in the context options"))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
environment = get(Client._context.environment, DEFAULT_ENVIRONMENT)
|
|
200
|
+
Client._snapshot_watcher.watch_snapshot(snapshot_location, environment, callback)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def unwatch_snapshot() -> None:
|
|
204
|
+
""" Stop watching the snapshot file """
|
|
205
|
+
Client._snapshot_watcher.unwatch_snapshot()
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def snapshot_version() -> int:
|
|
209
|
+
""" Get the version of the snapshot """
|
|
210
|
+
snapshot = GlobalSnapshot.snapshot()
|
|
211
|
+
|
|
212
|
+
if snapshot is None:
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
return snapshot.domain.version
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def check_switchers(switcher_keys: list[str]) -> None:
|
|
219
|
+
""" Verifies if switchers are properly configured """
|
|
220
|
+
if Client._context.options.local:
|
|
221
|
+
check_switchers(GlobalSnapshot.snapshot(), switcher_keys)
|
|
222
|
+
else:
|
|
223
|
+
Client._check_switchers_remote(switcher_keys)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def get_execution(switcher: Switcher) -> ExecutionLogger:
|
|
227
|
+
"""Retrieve execution log given a switcher"""
|
|
228
|
+
return ExecutionLogger.get_execution(switcher.key, switcher.inputs)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def clear_logger() -> None:
|
|
232
|
+
"""Clear all logged executions"""
|
|
233
|
+
ExecutionLogger.clear_logger()
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def clear_resources() -> None:
|
|
237
|
+
""" Clear all resources used by the Client """
|
|
238
|
+
Client.terminate_snapshot_auto_update()
|
|
239
|
+
Client.unwatch_snapshot()
|
|
240
|
+
ExecutionLogger.clear_logger()
|
|
241
|
+
GlobalSnapshot.clear()
|
|
242
|
+
TimedMatch.terminate_worker()
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def subscribe_notify_error(callback: Callable[[Exception], None]) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Subscribe to notify when an asynchronous error is thrown.
|
|
248
|
+
It is usually used when throttle and silent mode are enabled.
|
|
249
|
+
"""
|
|
250
|
+
ExecutionLogger.subscribe_notify_error(callback)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _is_check_snapshot_available(fetch_remote = False) -> bool:
|
|
254
|
+
return Client.snapshot_version() == 0 and (fetch_remote or not Client._context.options.local)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _check_switchers_remote(switcher_keys: list[str]) -> None:
|
|
258
|
+
RemoteAuth.auth()
|
|
259
|
+
Remote.check_switchers(
|
|
260
|
+
token=GlobalAuth.get_token(),
|
|
261
|
+
switcher_keys=switcher_keys,
|
|
262
|
+
context=Client._context)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class RemoteError(Exception):
|
|
2
|
+
""" Base class for remote errors """
|
|
3
|
+
def __init__(self, message):
|
|
4
|
+
self.message = message
|
|
5
|
+
super().__init__(self.message)
|
|
6
|
+
|
|
7
|
+
class RemoteAuthError(RemoteError):
|
|
8
|
+
""" Raised when there is an authentication error with the remote service """
|
|
9
|
+
|
|
10
|
+
class RemoteCriteriaError(RemoteError):
|
|
11
|
+
""" Raised when there is a criteria error with the remote service """
|
|
12
|
+
|
|
13
|
+
class RemoteSwitcherError(RemoteError):
|
|
14
|
+
""" Raised when there is a switcher error with the remote service """
|
|
15
|
+
def __init__(self, not_found: list):
|
|
16
|
+
super().__init__(f'{", ".join(not_found)} not found')
|
|
17
|
+
|
|
18
|
+
class LocalSwitcherError(Exception):
|
|
19
|
+
""" Raised when there is a switcher error with the local service """
|
|
20
|
+
def __init__(self, not_found: list):
|
|
21
|
+
self.message = f'{", ".join(not_found)} not found'
|
|
22
|
+
super().__init__(self.message)
|
|
23
|
+
|
|
24
|
+
class LocalCriteriaError(Exception):
|
|
25
|
+
""" Raised when there is a criteria error with the local service """
|
|
26
|
+
def __init__(self, message):
|
|
27
|
+
self.message = message
|
|
28
|
+
super().__init__(self.message)
|
|
29
|
+
|
|
30
|
+
class SnapshotNotFoundError(Exception):
|
|
31
|
+
""" Raised when a snapshot is not found """
|
|
32
|
+
def __init__(self, message):
|
|
33
|
+
self.message = message
|
|
34
|
+
super().__init__(self.message)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
'RemoteError',
|
|
38
|
+
'RemoteAuthError',
|
|
39
|
+
'RemoteCriteriaError',
|
|
40
|
+
'RemoteSwitcherError',
|
|
41
|
+
'LocalSwitcherError',
|
|
42
|
+
'LocalCriteriaError',
|
|
43
|
+
'SnapshotNotFoundError'
|
|
44
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class GlobalAuth:
|
|
5
|
+
""" GlobalAuth manages the global authentication state, including the token and its expiration time. """
|
|
6
|
+
__token = None
|
|
7
|
+
__exp = None
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def init():
|
|
11
|
+
GlobalAuth.__token = None
|
|
12
|
+
GlobalAuth.__exp = None
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def get_token():
|
|
16
|
+
return GlobalAuth.__token
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def get_exp():
|
|
20
|
+
return GlobalAuth.__exp
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def set_token(token: str):
|
|
24
|
+
GlobalAuth.__token = token
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def set_exp(exp: str):
|
|
28
|
+
GlobalAuth.__exp = exp
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
DEFAULT_ENVIRONMENT = 'default'
|
|
5
|
+
DEFAULT_LOCAL = False
|
|
6
|
+
DEFAULT_LOGGER = False
|
|
7
|
+
DEFAULT_FREEZE = False
|
|
8
|
+
DEFAULT_RESTRICT_RELAY = True
|
|
9
|
+
DEFAULT_REGEX_MAX_BLACKLISTED = 100
|
|
10
|
+
DEFAULT_REGEX_MAX_TIME_LIMIT = 3000
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ContextOptions:
|
|
14
|
+
"""
|
|
15
|
+
:param local: When enabled it will use the local snapshot (file or in-memory).
|
|
16
|
+
If not set, it will use the remote API
|
|
17
|
+
:param logger: When enabled it allows inspecting the result details with
|
|
18
|
+
Client.get_execution(switcher). If not set, it will not log the result details
|
|
19
|
+
:param freeze: This option prevents the execution of background cache update when
|
|
20
|
+
using throttle. Use Client.clear_logger() to reset the in-memory cache if
|
|
21
|
+
snapshot are renewed
|
|
22
|
+
:param snapshot_location: The location of the snapshot file.
|
|
23
|
+
If not set, it will use the in-memory snapshot
|
|
24
|
+
:param snapshot_auto_update_interval: The interval in milliseconds to auto-update
|
|
25
|
+
the snapshot. If not set, it will not auto-update the snapshot
|
|
26
|
+
:param silent_mode: When defined it will switch to local during the specified time
|
|
27
|
+
before it switches back to remote, e.g. 5s (s: seconds - m: minutes - h: hours)
|
|
28
|
+
:param throttle_max_workers: The maximum number of workers to use for background
|
|
29
|
+
refresh when throttle is enabled. If None, the default value is based on the
|
|
30
|
+
number of CPUs. Default is None
|
|
31
|
+
:param regex_max_black_list: The maximum number of blacklisted regex inputs.
|
|
32
|
+
If not set, it will use the default value of 100
|
|
33
|
+
:param regex_max_time_limit: The maximum time limit in milliseconds for regex
|
|
34
|
+
matching. If not set, it will use the default value of 3000 ms
|
|
35
|
+
:param restrict_relay: When enabled it will restrict the use of relay when local
|
|
36
|
+
is enabled. Default is True
|
|
37
|
+
:param cert_path: The path to the SSL certificate file for secure connections.
|
|
38
|
+
If not set, it will use the default system certificates
|
|
39
|
+
"""
|
|
40
|
+
# pylint: disable=too-many-arguments, too-many-instance-attributes
|
|
41
|
+
def __init__(self, *,
|
|
42
|
+
local: bool = DEFAULT_LOCAL,
|
|
43
|
+
logger: bool = DEFAULT_LOGGER,
|
|
44
|
+
freeze: bool = DEFAULT_FREEZE,
|
|
45
|
+
regex_max_black_list: int = DEFAULT_REGEX_MAX_BLACKLISTED,
|
|
46
|
+
regex_max_time_limit: int = DEFAULT_REGEX_MAX_TIME_LIMIT,
|
|
47
|
+
restrict_relay: bool = DEFAULT_RESTRICT_RELAY,
|
|
48
|
+
snapshot_location: Optional[str] = None,
|
|
49
|
+
snapshot_auto_update_interval: Optional[int] = None,
|
|
50
|
+
silent_mode: Optional[str] = None,
|
|
51
|
+
throttle_max_workers: Optional[int] = None,
|
|
52
|
+
cert_path: Optional[str] = None):
|
|
53
|
+
self.local = local
|
|
54
|
+
self.logger = logger
|
|
55
|
+
self.freeze = freeze
|
|
56
|
+
self.snapshot_location = snapshot_location
|
|
57
|
+
self.snapshot_auto_update_interval = snapshot_auto_update_interval
|
|
58
|
+
self.silent_mode = silent_mode
|
|
59
|
+
self.restrict_relay = restrict_relay
|
|
60
|
+
self.throttle_max_workers = throttle_max_workers
|
|
61
|
+
self.regex_max_black_list = regex_max_black_list
|
|
62
|
+
self.regex_max_time_limit = regex_max_time_limit
|
|
63
|
+
self.cert_path = cert_path
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Context:
|
|
67
|
+
"""
|
|
68
|
+
:param domain: Your Switcher domain name
|
|
69
|
+
:param url: Switcher-API endpoint URL.
|
|
70
|
+
:param api_key: The API key for authentication
|
|
71
|
+
:param component: Your application identifier
|
|
72
|
+
:param environment: Target environment name. If not set, it will use the default environment
|
|
73
|
+
:param options: The context options
|
|
74
|
+
"""
|
|
75
|
+
# pylint: disable=too-many-arguments
|
|
76
|
+
def __init__(self, *,
|
|
77
|
+
domain: Optional[str], url: Optional[str], api_key: Optional[str], component: Optional[str],
|
|
78
|
+
environment: Optional[str], options: ContextOptions = ContextOptions()):
|
|
79
|
+
self.domain = domain
|
|
80
|
+
self.url = url
|
|
81
|
+
self.api_key = api_key
|
|
82
|
+
self.component = component
|
|
83
|
+
self.environment = environment
|
|
84
|
+
self.options = options
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def empty(cls):
|
|
88
|
+
return cls(
|
|
89
|
+
domain='',
|
|
90
|
+
url='',
|
|
91
|
+
api_key='',
|
|
92
|
+
component='',
|
|
93
|
+
environment=DEFAULT_ENVIRONMENT,
|
|
94
|
+
options=ContextOptions()
|
|
95
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class RetryOptions:
|
|
5
|
+
"""
|
|
6
|
+
RetryOptions holds the configuration for retrying authentication in silent mode,
|
|
7
|
+
including the retry time and the duration to wait between retries.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, retry_time: int, retry_duration_in: str):
|
|
11
|
+
"""
|
|
12
|
+
:param retry_time: The maximum number of retries
|
|
13
|
+
:param retry_duration_in: The duration to wait between retries (e.g. '5s' (s: seconds - m: minutes - h: hours))
|
|
14
|
+
"""
|
|
15
|
+
self.retry_time = retry_time
|
|
16
|
+
self.retry_duration_in = retry_duration_in
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from switcher_client.lib.types import Snapshot
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class GlobalSnapshot:
|
|
7
|
+
"""
|
|
8
|
+
GlobalSnapshot manages the global snapshot state,
|
|
9
|
+
allowing it to be set, cleared, and retrieved across the application.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def init(snapshot: Snapshot | None):
|
|
14
|
+
GlobalSnapshot.snapshotStore = snapshot
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def clear():
|
|
18
|
+
GlobalSnapshot.snapshotStore = None
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def snapshot() -> Snapshot | None:
|
|
22
|
+
return GlobalSnapshot.snapshotStore
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LoadSnapshotOptions:
|
|
26
|
+
"""
|
|
27
|
+
LoadSnapshotOptions holds the options for loading a snapshot,
|
|
28
|
+
including whether to fetch it remotely and whether to watch for changes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, fetch_remote: bool = False, watch_snapshot: bool = False):
|
|
32
|
+
self.fetch_remote = fetch_remote
|
|
33
|
+
self.watch_snapshot = watch_snapshot
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import ssl
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from switcher_client.errors import RemoteAuthError, RemoteError, RemoteCriteriaError, RemoteSwitcherError
|
|
8
|
+
from switcher_client.lib.globals.global_context import DEFAULT_ENVIRONMENT, Context
|
|
9
|
+
from switcher_client.lib.types import ResultDetail
|
|
10
|
+
from switcher_client.lib.utils import get, get_entry
|
|
11
|
+
from switcher_client.switcher_data import SwitcherData
|
|
12
|
+
|
|
13
|
+
class Remote:
|
|
14
|
+
"""
|
|
15
|
+
Remote handles all interactions with the remote switcher service,
|
|
16
|
+
including authentication, criteria checks, and snapshot resolution.
|
|
17
|
+
"""
|
|
18
|
+
_client: Optional[httpx.Client] = None
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def auth(context: Context):
|
|
22
|
+
url = f'{context.url}/criteria/auth'
|
|
23
|
+
response = Remote._do_post(context, url, {
|
|
24
|
+
'domain': context.domain,
|
|
25
|
+
'component': context.component,
|
|
26
|
+
'environment': context.environment,
|
|
27
|
+
}, {
|
|
28
|
+
'switcher-api-key': context.api_key,
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
if response.status_code == 200:
|
|
33
|
+
return response.json()['token'], response.json()['exp']
|
|
34
|
+
|
|
35
|
+
raise RemoteAuthError('Invalid API key')
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def check_api_health(context: Context) -> bool:
|
|
39
|
+
url = f'{context.url}/check'
|
|
40
|
+
response = Remote._do_get(context, url)
|
|
41
|
+
|
|
42
|
+
return response.status_code == 200
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def check_criteria(token: Optional[str], context: Context, switcher: SwitcherData) -> ResultDetail:
|
|
46
|
+
url = f'{context.url}/criteria?showReason={str(switcher.show_details).lower()}&key={switcher.key}'
|
|
47
|
+
entry = get_entry(switcher.inputs)
|
|
48
|
+
response = Remote._do_post(context, url, { 'entry': [e.to_dict() for e in entry] }, Remote._get_header(token))
|
|
49
|
+
|
|
50
|
+
if response.status_code == 200:
|
|
51
|
+
json_response = response.json()
|
|
52
|
+
return ResultDetail(
|
|
53
|
+
result=json_response['result'],
|
|
54
|
+
reason=json_response.get('reason', None),
|
|
55
|
+
metadata=json_response.get('metadata', {})
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
raise RemoteCriteriaError(f'[check_criteria] failed with status: {response.status_code}')
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def check_switchers(token: Optional[str], switcher_keys: list[str], context: Context) -> None:
|
|
62
|
+
url = f'{context.url}/criteria/switchers_check'
|
|
63
|
+
response = Remote._do_post(context, url, { 'switchers': switcher_keys }, Remote._get_header(token))
|
|
64
|
+
|
|
65
|
+
if response.status_code != 200:
|
|
66
|
+
raise RemoteError(f'[check_switchers] failed with status: {response.status_code}')
|
|
67
|
+
|
|
68
|
+
not_found = response.json().get('not_found', [])
|
|
69
|
+
if len(not_found) > 0:
|
|
70
|
+
raise RemoteSwitcherError(not_found)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def check_snapshot_version(token: Optional[str], context: Context, snapshot_version: int) -> bool:
|
|
74
|
+
url = f'{context.url}/criteria/snapshot_check/{snapshot_version}'
|
|
75
|
+
response = Remote._do_get(context, url, Remote._get_header(token))
|
|
76
|
+
|
|
77
|
+
if response.status_code == 200:
|
|
78
|
+
return response.json().get('status', False)
|
|
79
|
+
|
|
80
|
+
raise RemoteError(f'[check_snapshot_version] failed with status: {response.status_code}')
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def resolve_snapshot(token: Optional[str], context: Context) -> str | None:
|
|
84
|
+
domain = get(context.domain, '')
|
|
85
|
+
environment = get(context.environment, DEFAULT_ENVIRONMENT)
|
|
86
|
+
component = get(context.component, '')
|
|
87
|
+
|
|
88
|
+
data = {
|
|
89
|
+
"query": f"""
|
|
90
|
+
query domain {{
|
|
91
|
+
domain(name: "{domain}", environment: "{environment}", _component: "{component}") {{
|
|
92
|
+
name version activated
|
|
93
|
+
group {{ name activated
|
|
94
|
+
config {{ key activated
|
|
95
|
+
strategies {{ strategy activated operation values }}
|
|
96
|
+
relay {{ type activated }}
|
|
97
|
+
components
|
|
98
|
+
}}
|
|
99
|
+
}}
|
|
100
|
+
}}
|
|
101
|
+
}}
|
|
102
|
+
"""
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
response = Remote._do_post(context, f'{context.url}/graphql', data, Remote._get_header(token))
|
|
106
|
+
|
|
107
|
+
if response.status_code == 200:
|
|
108
|
+
return json.dumps(response.json().get('data', {}), indent=4)
|
|
109
|
+
|
|
110
|
+
raise RemoteError(f'[resolve_snapshot] failed with status: {response.status_code}')
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def _get_client(cls, context: Context) -> httpx.Client:
|
|
114
|
+
if cls._client is None or cls._client.is_closed:
|
|
115
|
+
cls._client = httpx.Client(
|
|
116
|
+
timeout=30.0,
|
|
117
|
+
limits=httpx.Limits(
|
|
118
|
+
max_keepalive_connections=20,
|
|
119
|
+
max_connections=100,
|
|
120
|
+
keepalive_expiry=30.0
|
|
121
|
+
),
|
|
122
|
+
http2=True,
|
|
123
|
+
verify=cls._get_context(context)
|
|
124
|
+
)
|
|
125
|
+
return cls._client
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _do_post(context: Context, url: str, data: dict, headers: Optional[dict] = None) -> httpx.Response:
|
|
129
|
+
client = Remote._get_client(context)
|
|
130
|
+
return client.post(url, json=data, headers=headers)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _do_get(context: Context, url: str, headers: Optional[dict] = None) -> httpx.Response:
|
|
134
|
+
client = Remote._get_client(context)
|
|
135
|
+
return client.get(url, headers=headers)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _get_header(token: Optional[str]) -> dict:
|
|
139
|
+
return {
|
|
140
|
+
'Authorization': f'Bearer {token}',
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _get_context(context: Context) -> bool | ssl.SSLContext:
|
|
146
|
+
cert_path = context.options.cert_path
|
|
147
|
+
if cert_path is None:
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
ctx = ssl.create_default_context()
|
|
151
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
152
|
+
ctx.load_cert_chain(certfile=cert_path)
|
|
153
|
+
return ctx
|