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,100 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import multiprocessing
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
class TaskType(Enum):
|
|
9
|
+
"""Types of tasks that can be sent to the worker process."""
|
|
10
|
+
MATCH = "match"
|
|
11
|
+
SHUTDOWN = "shutdown"
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class WorkerTask:
|
|
15
|
+
"""Task sent to the worker process."""
|
|
16
|
+
task_type: TaskType
|
|
17
|
+
patterns: Optional[List[str]] = None
|
|
18
|
+
input_value: Optional[str] = None
|
|
19
|
+
use_fullmatch: Optional[bool] = None
|
|
20
|
+
task_id: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WorkerResult:
|
|
24
|
+
"""Result returned from the worker process."""
|
|
25
|
+
success: bool
|
|
26
|
+
result: Optional[bool] = None
|
|
27
|
+
task_id: Optional[str] = None
|
|
28
|
+
error: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
def persistent_regex_worker(task_queue: multiprocessing.Queue, result_queue: multiprocessing.Queue):
|
|
31
|
+
"""
|
|
32
|
+
Persistent worker function that processes regex matching tasks in a loop.
|
|
33
|
+
|
|
34
|
+
This worker runs continuously, processing tasks from the task queue until
|
|
35
|
+
it receives a shutdown signal or encounters an error.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
task_queue: Queue to receive WorkerTask objects
|
|
39
|
+
result_queue: Queue to send WorkerResult objects back to main process
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
task = task_queue.get(timeout=30.0)
|
|
45
|
+
|
|
46
|
+
if task.task_type == TaskType.SHUTDOWN:
|
|
47
|
+
result_queue.put(WorkerResult(success=True, task_id=task.task_id))
|
|
48
|
+
break
|
|
49
|
+
if task.task_type == TaskType.MATCH:
|
|
50
|
+
result = _process_match_task(task)
|
|
51
|
+
result_queue.put(result)
|
|
52
|
+
|
|
53
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
54
|
+
# Timeout or other error getting task, continue
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
58
|
+
# Worker process error, exit
|
|
59
|
+
result_queue.put(WorkerResult(success=False, error="Worker process error"))
|
|
60
|
+
|
|
61
|
+
def _process_match_task(task: WorkerTask) -> WorkerResult:
|
|
62
|
+
"""
|
|
63
|
+
Process a regex matching task.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
task: WorkerTask containing the matching parameters
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
WorkerResult with the matching result
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
if not task.patterns or not task.input_value:
|
|
73
|
+
return WorkerResult(
|
|
74
|
+
success=False,
|
|
75
|
+
error="Invalid task parameters",
|
|
76
|
+
task_id=task.task_id
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
match_result = False
|
|
80
|
+
for pattern in task.patterns:
|
|
81
|
+
if task.use_fullmatch:
|
|
82
|
+
if re.fullmatch(pattern, task.input_value):
|
|
83
|
+
match_result = True
|
|
84
|
+
break
|
|
85
|
+
else:
|
|
86
|
+
if re.search(pattern, task.input_value):
|
|
87
|
+
match_result = True
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
return WorkerResult(
|
|
91
|
+
success=True,
|
|
92
|
+
result=match_result,
|
|
93
|
+
task_id=task.task_id
|
|
94
|
+
)
|
|
95
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
96
|
+
return WorkerResult(
|
|
97
|
+
success=False,
|
|
98
|
+
error=str(e),
|
|
99
|
+
task_id=task.task_id
|
|
100
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from switcher_client.lib.globals.global_context import Context
|
|
6
|
+
from switcher_client.lib.globals.global_snapshot import GlobalSnapshot
|
|
7
|
+
from switcher_client.lib.remote_auth import RemoteAuth
|
|
8
|
+
from switcher_client.lib.globals import GlobalAuth
|
|
9
|
+
from switcher_client.lib.remote import Remote
|
|
10
|
+
from switcher_client.lib.resolver import Resolver
|
|
11
|
+
from switcher_client.lib.types import ResultDetail
|
|
12
|
+
from switcher_client.lib.utils.execution_logger import ExecutionLogger
|
|
13
|
+
from switcher_client.switcher_data import SwitcherData
|
|
14
|
+
|
|
15
|
+
class Switcher(SwitcherData):
|
|
16
|
+
"""
|
|
17
|
+
Switcher handles criteria execution and validations.
|
|
18
|
+
|
|
19
|
+
The class provides methods to execute criteria with both boolean and detailed results,
|
|
20
|
+
and supports both synchronous and asynchronous execution modes.
|
|
21
|
+
|
|
22
|
+
Example usage:
|
|
23
|
+
switcher = Client.get_switcher('FEATURE_LOGIN_V2')
|
|
24
|
+
switcher.is_on() # returns boolean result
|
|
25
|
+
switcher.is_on_with_details() # returns detailed result
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
def __init__(self, context: Context, key: Optional[str] = None):
|
|
29
|
+
super().__init__(context, key)
|
|
30
|
+
self._context = context
|
|
31
|
+
self._show_details: bool = False
|
|
32
|
+
self._next_refresh_time: int = 0
|
|
33
|
+
self._init_worker(context)
|
|
34
|
+
self._validate_args(key)
|
|
35
|
+
|
|
36
|
+
def _init_worker(self, context: Context):
|
|
37
|
+
self._background_executor = ThreadPoolExecutor(
|
|
38
|
+
max_workers=context.options.throttle_max_workers,
|
|
39
|
+
thread_name_prefix="SwitcherBackgroundRefresh"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def prepare(self, key: Optional[str] = None):
|
|
43
|
+
""" Checks API credentials and connectivity """
|
|
44
|
+
self._validate_args(key)
|
|
45
|
+
|
|
46
|
+
if not self._context.options.local or self._force_remote:
|
|
47
|
+
RemoteAuth.auth()
|
|
48
|
+
|
|
49
|
+
def is_on(self, key: Optional[str] = None) -> bool:
|
|
50
|
+
""" Execute criteria """
|
|
51
|
+
self._show_details = False
|
|
52
|
+
self._validate_args(key, details=False)
|
|
53
|
+
|
|
54
|
+
# try get cached result
|
|
55
|
+
cached_result = self._try_cached_result()
|
|
56
|
+
if cached_result is not None:
|
|
57
|
+
return cached_result.result
|
|
58
|
+
|
|
59
|
+
return self._submit().result
|
|
60
|
+
|
|
61
|
+
def is_on_with_details(self, key: Optional[str] = None) -> ResultDetail:
|
|
62
|
+
""" Execute criteria with details """
|
|
63
|
+
self._validate_args(key, details=True)
|
|
64
|
+
|
|
65
|
+
# try get cached result
|
|
66
|
+
cached_result = self._try_cached_result()
|
|
67
|
+
if cached_result is not None:
|
|
68
|
+
return cached_result
|
|
69
|
+
|
|
70
|
+
return self._submit()
|
|
71
|
+
|
|
72
|
+
def schedule_background_refresh(self):
|
|
73
|
+
""" Schedules background refresh of the last criteria request """
|
|
74
|
+
now = int(datetime.now().timestamp() * 1000)
|
|
75
|
+
|
|
76
|
+
if now > self._next_refresh_time:
|
|
77
|
+
self._next_refresh_time = now + self._throttle_period
|
|
78
|
+
self._background_executor.submit(self._submit)
|
|
79
|
+
|
|
80
|
+
def _submit(self) -> ResultDetail:
|
|
81
|
+
""" Submit criteria for execution (local or remote) """
|
|
82
|
+
# verify if query from snapshot
|
|
83
|
+
if (self._context.options.local and not self._force_remote):
|
|
84
|
+
return self._execute_local_criteria()
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
self.validate()
|
|
88
|
+
if GlobalAuth.get_token() == 'SILENT':
|
|
89
|
+
return self._execute_local_criteria()
|
|
90
|
+
|
|
91
|
+
return self._execute_remote_criteria()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
self._notify_error(e)
|
|
94
|
+
|
|
95
|
+
if self._context.options.silent_mode:
|
|
96
|
+
RemoteAuth.update_silent_token()
|
|
97
|
+
return self._execute_local_criteria()
|
|
98
|
+
|
|
99
|
+
raise e
|
|
100
|
+
|
|
101
|
+
def validate(self) -> 'Switcher':
|
|
102
|
+
""" Validates client settings for remote API calls """
|
|
103
|
+
errors = []
|
|
104
|
+
|
|
105
|
+
RemoteAuth.is_valid()
|
|
106
|
+
|
|
107
|
+
if not self._key:
|
|
108
|
+
errors.append('Missing key field')
|
|
109
|
+
|
|
110
|
+
self._execute_api_checks()
|
|
111
|
+
if not GlobalAuth.get_token():
|
|
112
|
+
errors.append('Missing token field')
|
|
113
|
+
|
|
114
|
+
if errors:
|
|
115
|
+
raise ValueError(f"Something went wrong: {', '.join(errors)}")
|
|
116
|
+
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def _validate_args(self, key: Optional[str] = None, details: Optional[bool] = None):
|
|
120
|
+
if key is not None:
|
|
121
|
+
self._key = key
|
|
122
|
+
|
|
123
|
+
if details is not None:
|
|
124
|
+
self._show_details = details
|
|
125
|
+
|
|
126
|
+
def _execute_api_checks(self):
|
|
127
|
+
""" Assure API is available and token is valid """
|
|
128
|
+
RemoteAuth.check_health()
|
|
129
|
+
if RemoteAuth.is_token_expired():
|
|
130
|
+
self.prepare(self._key)
|
|
131
|
+
|
|
132
|
+
def _try_cached_result(self) -> Optional[ResultDetail]:
|
|
133
|
+
""" Try get cached result if throttle is enabled and criteria was recently executed """
|
|
134
|
+
if self._has_throttle():
|
|
135
|
+
if not self._context.options.freeze:
|
|
136
|
+
self.schedule_background_refresh()
|
|
137
|
+
|
|
138
|
+
cached_result_logger = ExecutionLogger.get_execution(self._key, self._input)
|
|
139
|
+
if cached_result_logger.key is not None:
|
|
140
|
+
return cached_result_logger.response
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _notify_error(self, error: Exception):
|
|
145
|
+
""" Notify asynchronous error to the subscribed callback """
|
|
146
|
+
callback = ExecutionLogger.callback_error()
|
|
147
|
+
if callback:
|
|
148
|
+
callback(error)
|
|
149
|
+
|
|
150
|
+
def _execute_remote_criteria(self):
|
|
151
|
+
""" Execute remote criteria """
|
|
152
|
+
try:
|
|
153
|
+
token = GlobalAuth.get_token()
|
|
154
|
+
response = Remote.check_criteria(token, self._context, self)
|
|
155
|
+
|
|
156
|
+
if self._can_log():
|
|
157
|
+
ExecutionLogger.add(response, self._key, self._input)
|
|
158
|
+
|
|
159
|
+
return response
|
|
160
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
161
|
+
return self._get_default_result_or_raise(e)
|
|
162
|
+
|
|
163
|
+
def _execute_local_criteria(self):
|
|
164
|
+
""" Execute local criteria """
|
|
165
|
+
try:
|
|
166
|
+
response = Resolver.check_criteria(GlobalSnapshot.snapshot(), self)
|
|
167
|
+
if self._can_log():
|
|
168
|
+
ExecutionLogger.add(response, self._key, self._input)
|
|
169
|
+
|
|
170
|
+
return response
|
|
171
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
172
|
+
return self._get_default_result_or_raise(e)
|
|
173
|
+
|
|
174
|
+
def _can_log(self) -> bool:
|
|
175
|
+
""" Check if logging is enabled """
|
|
176
|
+
return self._context.options.logger and self._key is not None
|
|
177
|
+
|
|
178
|
+
def _has_throttle(self) -> bool:
|
|
179
|
+
""" Check if throttle is enabled and criteria was recently executed """
|
|
180
|
+
return self._throttle_period != 0
|
|
181
|
+
|
|
182
|
+
def _get_default_result_or_raise(self, e) -> ResultDetail:
|
|
183
|
+
""" Get default result if set, otherwise raise the error """
|
|
184
|
+
if self._default_result is None:
|
|
185
|
+
raise e
|
|
186
|
+
|
|
187
|
+
self._notify_error(e)
|
|
188
|
+
return ResultDetail.create(result=self._default_result, reason="Default result")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# pylint: disable=redefined-builtin
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from abc import ABCMeta
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from switcher_client.lib.compat import Self
|
|
10
|
+
from switcher_client.lib.utils import get
|
|
11
|
+
from switcher_client.lib.globals.global_context import Context
|
|
12
|
+
from switcher_client.lib.snapshot import StrategiesType
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SwitcherData(metaclass=ABCMeta):
|
|
16
|
+
"""
|
|
17
|
+
SwitcherData holds the data for a switcher execution.
|
|
18
|
+
It is used by the Switcher class to execute criteria and return results.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# pylint: disable=too-many-instance-attributes
|
|
22
|
+
def __init__(self, context: Context,key: Optional[str] = None):
|
|
23
|
+
self._context = context
|
|
24
|
+
self._key = key
|
|
25
|
+
self._input = []
|
|
26
|
+
self._show_details = False
|
|
27
|
+
self._throttle_period = 0
|
|
28
|
+
self._next_refresh_time = 0 # timestamp
|
|
29
|
+
self._default_result = None
|
|
30
|
+
self._force_remote = False
|
|
31
|
+
self._restrict_relay = True
|
|
32
|
+
|
|
33
|
+
def check(self, strategy_type: str, input: str)-> Self:
|
|
34
|
+
""" Adds a strategy for validation """
|
|
35
|
+
self._input = [item for item in self._input if item[0] != strategy_type]
|
|
36
|
+
self._input.append([strategy_type, input])
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def check_value(self, input: str) -> Self:
|
|
40
|
+
""" Adds VALUE_VALIDATION input for strategy validation """
|
|
41
|
+
return self.check(StrategiesType.VALUE.value, input)
|
|
42
|
+
|
|
43
|
+
def check_network(self, input: str) -> Self:
|
|
44
|
+
""" Adds NETWORK_VALIDATION input for strategy validation """
|
|
45
|
+
return self.check(StrategiesType.NETWORK.value, input)
|
|
46
|
+
|
|
47
|
+
def check_regex(self, input: str) -> Self:
|
|
48
|
+
""" Adds REGEX_VALIDATION input for strategy validation """
|
|
49
|
+
return self.check(StrategiesType.REGEX.value, input)
|
|
50
|
+
|
|
51
|
+
def check_payload(self, input: Union[str, dict]) -> Self:
|
|
52
|
+
""" Adds PAYLOAD_VALIDATION input for strategy validation """
|
|
53
|
+
if isinstance(input, dict):
|
|
54
|
+
payload_str = json.dumps(input)
|
|
55
|
+
else:
|
|
56
|
+
payload_str = input
|
|
57
|
+
|
|
58
|
+
return self.check(StrategiesType.PAYLOAD.value, payload_str)
|
|
59
|
+
|
|
60
|
+
def throttle(self, period: int) -> Self:
|
|
61
|
+
""" Sets throttle period in milliseconds """
|
|
62
|
+
self._throttle_period = period
|
|
63
|
+
|
|
64
|
+
if self._next_refresh_time == 0:
|
|
65
|
+
self._next_refresh_time = int(datetime.now().timestamp() * 1000) + period
|
|
66
|
+
|
|
67
|
+
if self._throttle_period > 0:
|
|
68
|
+
self._context.options.logger = True
|
|
69
|
+
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def remote(self, force_remote: bool = True) -> Self:
|
|
73
|
+
""" Force the use of the remote API when local is enabled """
|
|
74
|
+
if not self._context.options.local:
|
|
75
|
+
raise ValueError("Local mode is not enabled")
|
|
76
|
+
|
|
77
|
+
self._force_remote = force_remote
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def default_result(self, result: bool) -> Self:
|
|
81
|
+
""" Sets the default result for the switcher """
|
|
82
|
+
self._default_result = result
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def restrict_relay(self, restrict: bool = True) -> Self:
|
|
86
|
+
""" Allow local snapshots to ignore or require Relay verification """
|
|
87
|
+
self._restrict_relay = restrict
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def reset_inputs(self) -> Self:
|
|
91
|
+
""" Resets all strategy inputs """
|
|
92
|
+
self._input = []
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def inputs(self) -> list[list[str]]:
|
|
97
|
+
""" Gets all strategy inputs """
|
|
98
|
+
return self._input
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def key(self) -> str:
|
|
102
|
+
""" Gets the switcher key """
|
|
103
|
+
return get(self._key, '')
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def show_details(self) -> bool:
|
|
107
|
+
""" Gets the show_details setting """
|
|
108
|
+
return self._show_details
|
|
109
|
+
|
|
110
|
+
def is_restrict_relay(self) -> bool:
|
|
111
|
+
""" Gets the restrict_relay setting """
|
|
112
|
+
return self._restrict_relay
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|