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