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.
Files changed (34) hide show
  1. switcher_client/__init__.py +11 -0
  2. switcher_client/client.py +262 -0
  3. switcher_client/errors/__init__.py +44 -0
  4. switcher_client/lib/__init__.py +7 -0
  5. switcher_client/lib/compat.py +8 -0
  6. switcher_client/lib/globals/__init__.py +10 -0
  7. switcher_client/lib/globals/global_auth.py +28 -0
  8. switcher_client/lib/globals/global_context.py +95 -0
  9. switcher_client/lib/globals/global_retry.py +16 -0
  10. switcher_client/lib/globals/global_snapshot.py +33 -0
  11. switcher_client/lib/remote.py +153 -0
  12. switcher_client/lib/remote_auth.py +74 -0
  13. switcher_client/lib/resolver.py +99 -0
  14. switcher_client/lib/snapshot.py +214 -0
  15. switcher_client/lib/snapshot_auto_updater.py +63 -0
  16. switcher_client/lib/snapshot_loader.py +77 -0
  17. switcher_client/lib/snapshot_watcher.py +67 -0
  18. switcher_client/lib/types.py +169 -0
  19. switcher_client/lib/utils/__init__.py +21 -0
  20. switcher_client/lib/utils/date_moment.py +26 -0
  21. switcher_client/lib/utils/execution_logger.py +96 -0
  22. switcher_client/lib/utils/ipcidr.py +46 -0
  23. switcher_client/lib/utils/payload_reader.py +43 -0
  24. switcher_client/lib/utils/timed_match/__init__.py +3 -0
  25. switcher_client/lib/utils/timed_match/timed_match.py +246 -0
  26. switcher_client/lib/utils/timed_match/worker.py +100 -0
  27. switcher_client/switcher.py +188 -0
  28. switcher_client/switcher_data.py +112 -0
  29. switcher_client/version.py +1 -0
  30. switcher_client-0.1.2.dist-info/METADATA +402 -0
  31. switcher_client-0.1.2.dist-info/RECORD +34 -0
  32. switcher_client-0.1.2.dist-info/WHEEL +5 -0
  33. switcher_client-0.1.2.dist-info/licenses/LICENSE +21 -0
  34. 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,7 @@
1
+ from .remote_auth import RemoteAuth
2
+ from .types import ResultDetail
3
+
4
+ __all__ = [
5
+ 'RemoteAuth',
6
+ 'ResultDetail',
7
+ ]
@@ -0,0 +1,8 @@
1
+ import sys
2
+
3
+ if sys.version_info >= (3, 11):
4
+ from typing import Self
5
+ else:
6
+ from typing_extensions import Self
7
+
8
+ __all__ = ['Self']
@@ -0,0 +1,10 @@
1
+ from .global_auth import GlobalAuth
2
+ from .global_context import Context, ContextOptions
3
+ from .global_retry import RetryOptions
4
+
5
+ __all__ = [
6
+ "GlobalAuth",
7
+ "Context",
8
+ "ContextOptions",
9
+ "RetryOptions",
10
+ ]
@@ -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