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,74 @@
|
|
|
1
|
+
from time import time
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from switcher_client.lib.remote import Remote
|
|
5
|
+
from switcher_client.lib.globals.global_context import Context
|
|
6
|
+
from switcher_client.lib.globals import GlobalAuth, RetryOptions
|
|
7
|
+
from switcher_client.lib.utils.date_moment import DateMoment
|
|
8
|
+
|
|
9
|
+
class RemoteAuth:
|
|
10
|
+
"""
|
|
11
|
+
RemoteAuth handles authentication with the remote switcher service.
|
|
12
|
+
It manages the authentication token, checks for token expiration, and handles silent mode token updates.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__context: Context = Context.empty()
|
|
16
|
+
__retry_options: RetryOptions
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def init(context: Context):
|
|
20
|
+
RemoteAuth.__context = context
|
|
21
|
+
GlobalAuth.init()
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def set_retry_options(silent_mode: str):
|
|
25
|
+
RemoteAuth.__retry_options = RetryOptions(
|
|
26
|
+
retry_time=int(silent_mode[:-1]),
|
|
27
|
+
retry_duration_in=silent_mode[-1]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def auth():
|
|
32
|
+
token, exp = Remote.auth(RemoteAuth.__context)
|
|
33
|
+
GlobalAuth.set_token(token)
|
|
34
|
+
GlobalAuth.set_exp(exp)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def check_health():
|
|
38
|
+
if GlobalAuth.get_token() != 'SILENT':
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
if RemoteAuth.is_token_expired():
|
|
42
|
+
RemoteAuth.update_silent_token()
|
|
43
|
+
if Remote.check_api_health(RemoteAuth.__context):
|
|
44
|
+
RemoteAuth.auth()
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def is_token_expired() -> bool:
|
|
48
|
+
exp = GlobalAuth.get_exp()
|
|
49
|
+
if exp is None:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
return float(exp) < time()
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def update_silent_token():
|
|
56
|
+
expiration_time = DateMoment(datetime.now()).add(
|
|
57
|
+
RemoteAuth.__retry_options.retry_time,
|
|
58
|
+
RemoteAuth.__retry_options.retry_duration_in
|
|
59
|
+
).get_date().timestamp()
|
|
60
|
+
|
|
61
|
+
GlobalAuth.set_token('SILENT')
|
|
62
|
+
GlobalAuth.set_exp(str(round(expiration_time)))
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def is_valid():
|
|
66
|
+
required_fields = [
|
|
67
|
+
('url', RemoteAuth.__context.url),
|
|
68
|
+
('component', RemoteAuth.__context.component),
|
|
69
|
+
('api_key', RemoteAuth.__context.api_key),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
errors = [name for name, value in required_fields if not value]
|
|
73
|
+
if errors:
|
|
74
|
+
raise ValueError(f"Something went wrong: Missing or empty required fields ({', '.join(errors)})")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from switcher_client.lib.types import Config, Domain, Entry, Group, ResultDetail, Snapshot, StrategyConfig
|
|
4
|
+
from switcher_client.lib.snapshot import process_operation
|
|
5
|
+
from switcher_client.lib.utils import get, get_entry
|
|
6
|
+
from switcher_client.errors import LocalCriteriaError
|
|
7
|
+
from switcher_client.switcher_data import SwitcherData
|
|
8
|
+
|
|
9
|
+
# pylint: disable=too-few-public-methods
|
|
10
|
+
class Resolver:
|
|
11
|
+
"""
|
|
12
|
+
Resolver is responsible for resolving the criteria against the snapshot domain.
|
|
13
|
+
It provides methods to check the criteria for a given switcher request and return the result.
|
|
14
|
+
The resolution process involves checking the domain, groups, configs, and strategies in a hierarchical manner,
|
|
15
|
+
ensuring that all conditions are evaluated correctly to determine the final result.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def check_criteria(snapshot: Snapshot | None, switcher: SwitcherData) -> ResultDetail:
|
|
20
|
+
""" Resolves the criteria for a given switcher request against the snapshot domain. """
|
|
21
|
+
if not snapshot:
|
|
22
|
+
raise LocalCriteriaError("Snapshot not loaded. Try to use 'Client.load_snapshot()'")
|
|
23
|
+
|
|
24
|
+
return Resolver._check_domain(snapshot.domain, switcher)
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _check_domain(domain: Domain, switcher: SwitcherData) -> ResultDetail:
|
|
28
|
+
""" Checks if the domain is activated and proceeds to check groups. """
|
|
29
|
+
if domain.activated is False:
|
|
30
|
+
return ResultDetail.disabled("Domain is disabled")
|
|
31
|
+
|
|
32
|
+
return Resolver._check_group(domain.group, switcher)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _check_group(groups: list[Group], switcher: SwitcherData) -> ResultDetail:
|
|
36
|
+
""" Finds the correct config in the groups and checks it. """
|
|
37
|
+
key = switcher.key
|
|
38
|
+
|
|
39
|
+
for group in groups:
|
|
40
|
+
config_found = next((c for c in group.config if c.key == key), None)
|
|
41
|
+
|
|
42
|
+
if config_found is not None:
|
|
43
|
+
if group.activated is False:
|
|
44
|
+
return ResultDetail.disabled("Group disabled")
|
|
45
|
+
|
|
46
|
+
return Resolver._check_config(config_found, switcher)
|
|
47
|
+
|
|
48
|
+
raise LocalCriteriaError(f"Config with key '{key}' not found in the snapshot")
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _check_config(config: Config, switcher: SwitcherData) -> ResultDetail:
|
|
52
|
+
""" Checks if the config is activated and proceeds to check strategies. """
|
|
53
|
+
if config.activated is False:
|
|
54
|
+
return ResultDetail.disabled("Config disabled")
|
|
55
|
+
|
|
56
|
+
if Resolver._has_relay_enabled(config) and switcher.is_restrict_relay():
|
|
57
|
+
return ResultDetail.disabled("Config has relay enabled")
|
|
58
|
+
|
|
59
|
+
if config.strategies is not None and len(config.strategies) > 0:
|
|
60
|
+
return Resolver._check_strategy(config.strategies, switcher.inputs)
|
|
61
|
+
|
|
62
|
+
return ResultDetail.success()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _check_strategy(strategy_configs: list[StrategyConfig], inputs: list[list[str]]) -> ResultDetail:
|
|
66
|
+
""" Checks each strategy configuration against the provided inputs. """
|
|
67
|
+
entry = get_entry(get(inputs, []))
|
|
68
|
+
|
|
69
|
+
for strategy_config in strategy_configs:
|
|
70
|
+
if not strategy_config.activated:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
strategy_result = Resolver._check_strategy_config(strategy_config, entry)
|
|
74
|
+
if strategy_result is not None:
|
|
75
|
+
return strategy_result
|
|
76
|
+
|
|
77
|
+
return ResultDetail.success()
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _check_strategy_config(strategy_config: StrategyConfig, entry: list[Entry]) -> Optional[ResultDetail]:
|
|
81
|
+
""" Checks a single strategy configuration against the provided entries. """
|
|
82
|
+
if len(entry) == 0:
|
|
83
|
+
return ResultDetail.disabled(f"Strategy '{strategy_config.strategy}' did not receive any input")
|
|
84
|
+
|
|
85
|
+
strategy_entry = [e for e in entry if e.strategy == strategy_config.strategy]
|
|
86
|
+
if not Resolver._is_strategy_fulfilled(strategy_entry, strategy_config):
|
|
87
|
+
return ResultDetail.disabled(f"Strategy '{strategy_config.strategy}' does not agree")
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _is_strategy_fulfilled(strategy_entry: list[Entry], strategy_config: StrategyConfig) -> bool:
|
|
93
|
+
""" Determines if the strategy conditions are fulfilled based on the entries and configuration. """
|
|
94
|
+
return len(strategy_entry) > 0 and process_operation(strategy_config, strategy_entry[0].input) is True
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _has_relay_enabled(config: Config) -> bool:
|
|
98
|
+
""" Checks if the config has relay enabled. """
|
|
99
|
+
return config.relay.activated if config.relay else False
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from switcher_client.lib.types import StrategyConfig
|
|
8
|
+
from switcher_client.lib.utils.payload_reader import parse_json, payload_reader
|
|
9
|
+
from switcher_client.lib.utils.ipcidr import IPCIDR
|
|
10
|
+
from switcher_client.lib.utils.timed_match import TimedMatch
|
|
11
|
+
|
|
12
|
+
class StrategiesType(Enum):
|
|
13
|
+
""" Enum for strategy types used in criteria evaluation. """
|
|
14
|
+
VALUE = "VALUE_VALIDATION"
|
|
15
|
+
NUMERIC = "NUMERIC_VALIDATION"
|
|
16
|
+
DATE = "DATE_VALIDATION"
|
|
17
|
+
TIME = "TIME_VALIDATION"
|
|
18
|
+
PAYLOAD = "PAYLOAD_VALIDATION"
|
|
19
|
+
NETWORK = "NETWORK_VALIDATION"
|
|
20
|
+
REGEX = "REGEX_VALIDATION"
|
|
21
|
+
|
|
22
|
+
class OperationsType(Enum):
|
|
23
|
+
""" Enum for operation types used in strategy evaluation. """
|
|
24
|
+
EXIST = "EXIST"
|
|
25
|
+
NOT_EXIST = "NOT_EXIST"
|
|
26
|
+
EQUAL = "EQUAL"
|
|
27
|
+
NOT_EQUAL = "NOT_EQUAL"
|
|
28
|
+
GREATER = "GREATER"
|
|
29
|
+
LOWER = "LOWER"
|
|
30
|
+
BETWEEN = "BETWEEN"
|
|
31
|
+
HAS_ONE = "HAS_ONE"
|
|
32
|
+
HAS_ALL = "HAS_ALL"
|
|
33
|
+
|
|
34
|
+
# pylint: disable=too-many-return-statements
|
|
35
|
+
def process_operation(strategy_config: StrategyConfig, input_value: str) -> Optional[bool]:
|
|
36
|
+
"""Process the operation based on strategy configuration and input value."""
|
|
37
|
+
|
|
38
|
+
strategy = strategy_config.strategy
|
|
39
|
+
operation = strategy_config.operation
|
|
40
|
+
values = strategy_config.values
|
|
41
|
+
|
|
42
|
+
match strategy:
|
|
43
|
+
case StrategiesType.VALUE.value:
|
|
44
|
+
return _process_value(operation, values, input_value)
|
|
45
|
+
case StrategiesType.NUMERIC.value:
|
|
46
|
+
return _process_numeric(operation, values, input_value)
|
|
47
|
+
case StrategiesType.DATE.value:
|
|
48
|
+
return _process_date(operation, values, input_value)
|
|
49
|
+
case StrategiesType.TIME.value:
|
|
50
|
+
return _process_time(operation, values, input_value)
|
|
51
|
+
case StrategiesType.PAYLOAD.value:
|
|
52
|
+
return _process_payload(operation, values, input_value)
|
|
53
|
+
case StrategiesType.NETWORK.value:
|
|
54
|
+
return _process_network(operation, values, input_value)
|
|
55
|
+
case StrategiesType.REGEX.value:
|
|
56
|
+
return _process_regex(operation, values, input_value)
|
|
57
|
+
|
|
58
|
+
def _process_value(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
59
|
+
""" Process VALUE strategy operations."""
|
|
60
|
+
|
|
61
|
+
match operation:
|
|
62
|
+
case OperationsType.EXIST.value:
|
|
63
|
+
return input_value in values
|
|
64
|
+
case OperationsType.NOT_EXIST.value:
|
|
65
|
+
return input_value not in values
|
|
66
|
+
case OperationsType.EQUAL.value:
|
|
67
|
+
return input_value in values
|
|
68
|
+
case OperationsType.NOT_EQUAL.value:
|
|
69
|
+
return input_value not in values
|
|
70
|
+
|
|
71
|
+
# pylint: disable=too-many-return-statements
|
|
72
|
+
def _process_numeric(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
73
|
+
""" Process NUMERIC strategy operations."""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
numeric_input = float(input_value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
numeric_values = [float(v) for v in values]
|
|
81
|
+
|
|
82
|
+
match operation:
|
|
83
|
+
case OperationsType.EXIST.value:
|
|
84
|
+
return numeric_input in numeric_values
|
|
85
|
+
case OperationsType.NOT_EXIST.value:
|
|
86
|
+
return numeric_input not in numeric_values
|
|
87
|
+
case OperationsType.EQUAL.value:
|
|
88
|
+
return numeric_input in numeric_values
|
|
89
|
+
case OperationsType.NOT_EQUAL.value:
|
|
90
|
+
return numeric_input not in numeric_values
|
|
91
|
+
case OperationsType.GREATER.value:
|
|
92
|
+
return any(numeric_input > v for v in numeric_values)
|
|
93
|
+
case OperationsType.LOWER.value:
|
|
94
|
+
return any(numeric_input < v for v in numeric_values)
|
|
95
|
+
case OperationsType.BETWEEN.value:
|
|
96
|
+
return numeric_values[0] <= numeric_input <= numeric_values[1]
|
|
97
|
+
|
|
98
|
+
def _process_date(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
99
|
+
""" Process DATE strategy operations."""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
date_input = _parse_datetime(input_value)
|
|
103
|
+
date_values = [_parse_datetime(v) for v in values]
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
match operation:
|
|
108
|
+
case OperationsType.LOWER.value:
|
|
109
|
+
return any(date_input <= v for v in date_values)
|
|
110
|
+
case OperationsType.GREATER.value:
|
|
111
|
+
return any(date_input >= v for v in date_values)
|
|
112
|
+
case OperationsType.BETWEEN.value:
|
|
113
|
+
return date_values[0] <= date_input <= date_values[1]
|
|
114
|
+
|
|
115
|
+
def _process_time(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
116
|
+
""" Process TIME strategy operations."""
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
time_input = datetime.strptime(input_value, '%H:%M').time()
|
|
120
|
+
time_values = [datetime.strptime(v, '%H:%M').time() for v in values]
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
match operation:
|
|
125
|
+
case OperationsType.LOWER.value:
|
|
126
|
+
return any(time_input <= v for v in time_values)
|
|
127
|
+
case OperationsType.GREATER.value:
|
|
128
|
+
return any(time_input >= v for v in time_values)
|
|
129
|
+
case OperationsType.BETWEEN.value:
|
|
130
|
+
return time_values[0] <= time_input <= time_values[1]
|
|
131
|
+
|
|
132
|
+
def _process_payload(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
133
|
+
""" Process PAYLOAD strategy operations."""
|
|
134
|
+
|
|
135
|
+
input_json = parse_json(input_value)
|
|
136
|
+
if input_json is None:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
keys = payload_reader(input_json)
|
|
140
|
+
|
|
141
|
+
match operation:
|
|
142
|
+
case OperationsType.HAS_ONE.value:
|
|
143
|
+
return any(value in keys for value in values)
|
|
144
|
+
case OperationsType.HAS_ALL.value:
|
|
145
|
+
return all(value in keys for value in values)
|
|
146
|
+
|
|
147
|
+
def _process_network(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
148
|
+
"""Process NETWORK strategy operations."""
|
|
149
|
+
|
|
150
|
+
cidr_regex = re.compile(r'^(\d{1,3}\.){3}\d{1,3}(\/(\d|[1-2]\d|3[0-2]))$')
|
|
151
|
+
|
|
152
|
+
match operation:
|
|
153
|
+
case OperationsType.EXIST.value:
|
|
154
|
+
return _process_network_exist(input_value, values, cidr_regex)
|
|
155
|
+
case OperationsType.NOT_EXIST.value:
|
|
156
|
+
return _process_network_not_exist(input_value, values, cidr_regex)
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def _process_network_exist(input_value: str, values: list, cidr_regex) -> bool:
|
|
161
|
+
"""Check if input IP exists in any of the network ranges/IPs."""
|
|
162
|
+
|
|
163
|
+
for value in values:
|
|
164
|
+
if cidr_regex.match(value):
|
|
165
|
+
cidr = IPCIDR(value)
|
|
166
|
+
if cidr.is_ip4_in_cidr(input_value):
|
|
167
|
+
return True
|
|
168
|
+
else:
|
|
169
|
+
if input_value in values:
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def _process_network_not_exist(input_value: str, values: list, cidr_regex) -> bool:
|
|
175
|
+
"""Check if input IP does not exist in any of the network ranges/IPs."""
|
|
176
|
+
|
|
177
|
+
result = []
|
|
178
|
+
for element in values:
|
|
179
|
+
if cidr_regex.match(element):
|
|
180
|
+
cidr = IPCIDR(element)
|
|
181
|
+
if cidr.is_ip4_in_cidr(input_value):
|
|
182
|
+
result.append(element)
|
|
183
|
+
else:
|
|
184
|
+
if input_value in values:
|
|
185
|
+
result.append(element)
|
|
186
|
+
|
|
187
|
+
return len(result) == 0
|
|
188
|
+
|
|
189
|
+
def _process_regex(operation: str, values: list, input_value: str) -> Optional[bool]:
|
|
190
|
+
""" Process REGEX strategy operations with timeout protection."""
|
|
191
|
+
|
|
192
|
+
match operation:
|
|
193
|
+
case OperationsType.EXIST.value:
|
|
194
|
+
return TimedMatch.try_match(values, input_value, use_fullmatch=False)
|
|
195
|
+
case OperationsType.NOT_EXIST.value:
|
|
196
|
+
result = TimedMatch.try_match(values, input_value, use_fullmatch=False)
|
|
197
|
+
return not result
|
|
198
|
+
case OperationsType.EQUAL.value:
|
|
199
|
+
return TimedMatch.try_match(values, input_value, use_fullmatch=True)
|
|
200
|
+
case OperationsType.NOT_EQUAL.value:
|
|
201
|
+
result = TimedMatch.try_match(values, input_value, use_fullmatch=True)
|
|
202
|
+
return not result
|
|
203
|
+
|
|
204
|
+
def _parse_datetime(date_str: str):
|
|
205
|
+
"""Parse datetime string that can be either date-only or datetime format."""
|
|
206
|
+
|
|
207
|
+
formats = ['%Y-%m-%dT%H:%M', '%Y-%m-%d']
|
|
208
|
+
for fmt in formats:
|
|
209
|
+
try:
|
|
210
|
+
return datetime.strptime(date_str, fmt)
|
|
211
|
+
except ValueError:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
raise ValueError(f"Unable to parse date: {date_str}")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
class SnapshotAutoUpdater:
|
|
7
|
+
""" Schedules periodic snapshot updates in a background thread """
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._timer_thread: Optional[threading.Thread] = None
|
|
11
|
+
self._stop_event: Optional[threading.Event] = None
|
|
12
|
+
|
|
13
|
+
def schedule(
|
|
14
|
+
self, interval: int,
|
|
15
|
+
check_snapshot: Callable[[], bool],
|
|
16
|
+
callback: Callable[[Optional[Exception], bool], None]) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Schedule periodic snapshot updates in a background thread.
|
|
19
|
+
|
|
20
|
+
:param interval: Update interval in seconds
|
|
21
|
+
:param check_snapshot: Function that checks and updates snapshot, returns True if updated
|
|
22
|
+
:param callback: Callback function called with (error, updated) after each check
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
self.terminate()
|
|
26
|
+
self._stop_event = threading.Event()
|
|
27
|
+
|
|
28
|
+
self._timer_thread = threading.Thread(
|
|
29
|
+
target=self._update_worker,
|
|
30
|
+
args=(interval, check_snapshot, callback),
|
|
31
|
+
daemon=True,
|
|
32
|
+
name="SnapshotAutoUpdater"
|
|
33
|
+
)
|
|
34
|
+
self._timer_thread.start()
|
|
35
|
+
|
|
36
|
+
def terminate(self) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Terminate the scheduled snapshot auto-update thread gracefully.
|
|
39
|
+
"""
|
|
40
|
+
if self._stop_event is not None:
|
|
41
|
+
self._stop_event.set()
|
|
42
|
+
|
|
43
|
+
if self._timer_thread is not None and self._timer_thread.is_alive():
|
|
44
|
+
self._timer_thread.join(timeout=5.0)
|
|
45
|
+
|
|
46
|
+
self._timer_thread = None
|
|
47
|
+
self._stop_event = None
|
|
48
|
+
|
|
49
|
+
def _update_worker(
|
|
50
|
+
self, interval: int,
|
|
51
|
+
check_snapshot: Callable[[], bool],
|
|
52
|
+
callback: Callable[[Optional[Exception], bool], None]) -> None:
|
|
53
|
+
stop_event = self._stop_event
|
|
54
|
+
|
|
55
|
+
time.sleep(interval) # delay start
|
|
56
|
+
while stop_event is not None and not stop_event.is_set():
|
|
57
|
+
try:
|
|
58
|
+
updated = check_snapshot()
|
|
59
|
+
callback(None, updated)
|
|
60
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
61
|
+
callback(error, False)
|
|
62
|
+
|
|
63
|
+
stop_event.wait(interval)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from switcher_client.lib.globals.global_auth import GlobalAuth
|
|
5
|
+
from switcher_client.lib.globals.global_context import Context
|
|
6
|
+
from switcher_client.lib.remote import Remote
|
|
7
|
+
from switcher_client.lib.types import Snapshot
|
|
8
|
+
from switcher_client.errors import LocalSwitcherError
|
|
9
|
+
|
|
10
|
+
def load_domain(snapshot_location: str, environment: str):
|
|
11
|
+
""" Load Domain from snapshot file """
|
|
12
|
+
|
|
13
|
+
snapshot_file = f"{snapshot_location}/{environment}.json"
|
|
14
|
+
json_data = {}
|
|
15
|
+
|
|
16
|
+
if not os.path.exists(snapshot_file):
|
|
17
|
+
json_data = {
|
|
18
|
+
'domain': {
|
|
19
|
+
'version': 0,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if snapshot_location:
|
|
24
|
+
os.makedirs(snapshot_location, exist_ok=True)
|
|
25
|
+
with open(snapshot_file, 'w', encoding='utf-8') as file:
|
|
26
|
+
json.dump(json_data, file, indent=4)
|
|
27
|
+
|
|
28
|
+
elif os.path.exists(snapshot_file):
|
|
29
|
+
with open(snapshot_file, 'r', encoding='utf-8') as file:
|
|
30
|
+
json_data = json.load(file)
|
|
31
|
+
|
|
32
|
+
snapshot = Snapshot(json_data.get('domain', {}))
|
|
33
|
+
|
|
34
|
+
return snapshot
|
|
35
|
+
|
|
36
|
+
def validate_snapshot(
|
|
37
|
+
context: Context,
|
|
38
|
+
snapshot_version: int
|
|
39
|
+
) -> Snapshot | None:
|
|
40
|
+
""" Validate the snapshot data """
|
|
41
|
+
|
|
42
|
+
status = Remote.check_snapshot_version(
|
|
43
|
+
token=GlobalAuth.get_token(),
|
|
44
|
+
context=context,
|
|
45
|
+
snapshot_version=snapshot_version)
|
|
46
|
+
|
|
47
|
+
if not status:
|
|
48
|
+
snapshot_str = Remote.resolve_snapshot(GlobalAuth.get_token(), context)
|
|
49
|
+
graphql_response = json.loads(snapshot_str or '{}')
|
|
50
|
+
return Snapshot(graphql_response.get('domain', '{}'))
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def save_snapshot(snapshot: Snapshot, snapshot_location: str, environment: str):
|
|
55
|
+
""" Save snapshot to file """
|
|
56
|
+
|
|
57
|
+
os.makedirs(snapshot_location, exist_ok=True)
|
|
58
|
+
snapshot_file = f"{snapshot_location}/{environment}.json"
|
|
59
|
+
with open(snapshot_file, 'w', encoding='utf-8') as file:
|
|
60
|
+
json.dump(snapshot.to_dict(), file, indent=4)
|
|
61
|
+
|
|
62
|
+
def check_switchers(snapshot: Snapshot | None, switcher_keys: list[str]) -> None:
|
|
63
|
+
""" Check if switchers are properly configured in snapshot """
|
|
64
|
+
groups = getattr(snapshot.domain, 'group', []) if snapshot else []
|
|
65
|
+
not_found = []
|
|
66
|
+
|
|
67
|
+
for switcher in switcher_keys:
|
|
68
|
+
found = False
|
|
69
|
+
for group in groups:
|
|
70
|
+
if any(c.key == switcher for c in getattr(group, 'config', [])):
|
|
71
|
+
found = True
|
|
72
|
+
break
|
|
73
|
+
if not found:
|
|
74
|
+
not_found.append(switcher)
|
|
75
|
+
|
|
76
|
+
if not_found:
|
|
77
|
+
raise LocalSwitcherError(not_found)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from switcher_client.lib.snapshot_loader import load_domain
|
|
8
|
+
from switcher_client.lib.globals.global_snapshot import GlobalSnapshot
|
|
9
|
+
|
|
10
|
+
_POLL_INTERVAL = 1 # seconds between file stat checks
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WatchSnapshotCallback:
|
|
14
|
+
""" Typed callback contract for Client.watch_snapshot """
|
|
15
|
+
success: Callable[[], None] = field(default_factory=lambda: (lambda: None))
|
|
16
|
+
reject: Callable[[Exception], None] = field(default_factory=lambda: (lambda _: None))
|
|
17
|
+
|
|
18
|
+
class SnapshotWatcher:
|
|
19
|
+
""" Watches the snapshot file for changes and updates the switcher accordingly """
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._stop_event: threading.Event = threading.Event()
|
|
23
|
+
self._ready_event: threading.Event = threading.Event()
|
|
24
|
+
self._thread: threading.Thread | None = None
|
|
25
|
+
|
|
26
|
+
def watch_snapshot(self, snapshot_location: str, environment: str, callback: WatchSnapshotCallback) -> None:
|
|
27
|
+
""" Watch snapshot file for changes and invoke callbacks on result """
|
|
28
|
+
self._stop_event.clear()
|
|
29
|
+
self._ready_event.clear()
|
|
30
|
+
self._thread = threading.Thread(
|
|
31
|
+
target=self._watch,
|
|
32
|
+
args=(snapshot_location, environment, callback),
|
|
33
|
+
daemon=True,
|
|
34
|
+
name="SnapshotWatcher"
|
|
35
|
+
)
|
|
36
|
+
self._thread.start()
|
|
37
|
+
self._ready_event.wait()
|
|
38
|
+
|
|
39
|
+
def unwatch_snapshot(self) -> None:
|
|
40
|
+
""" Stop watching the snapshot file """
|
|
41
|
+
self._stop_event.set()
|
|
42
|
+
if self._thread is not None:
|
|
43
|
+
self._thread.join(timeout=5.0)
|
|
44
|
+
self._thread = None
|
|
45
|
+
|
|
46
|
+
def _watch(self, snapshot_location: str, environment: str, callback: WatchSnapshotCallback) -> None:
|
|
47
|
+
snapshot_file = f"{snapshot_location}/{environment}.json"
|
|
48
|
+
last_mtime = self._get_mtime(snapshot_file)
|
|
49
|
+
self._ready_event.set()
|
|
50
|
+
|
|
51
|
+
while not self._stop_event.is_set():
|
|
52
|
+
self._stop_event.wait(_POLL_INTERVAL)
|
|
53
|
+
current_mtime = self._get_mtime(snapshot_file)
|
|
54
|
+
if current_mtime != last_mtime:
|
|
55
|
+
last_mtime = current_mtime
|
|
56
|
+
self._on_modify_snapshot(snapshot_location, environment, callback)
|
|
57
|
+
|
|
58
|
+
def _get_mtime(self, snapshot_file: str) -> float:
|
|
59
|
+
return os.stat(snapshot_file).st_mtime
|
|
60
|
+
|
|
61
|
+
def _on_modify_snapshot(self, snapshot_location: str, environment: str, callback: WatchSnapshotCallback) -> None:
|
|
62
|
+
try:
|
|
63
|
+
snapshot = load_domain(snapshot_location, environment)
|
|
64
|
+
GlobalSnapshot.init(snapshot)
|
|
65
|
+
callback.success()
|
|
66
|
+
except Exception as error: # pylint: disable=broad-exception-caught
|
|
67
|
+
callback.reject(error)
|