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,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"