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,169 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ class ResultDetail:
5
+ """
6
+ ResultDetail holds the result of a criteria check, including the boolean result,
7
+ reason for the result, and any additional metadata.
8
+ It provides static methods to create instances for success, disabled, or custom results.
9
+ """
10
+ def __init__(self, result: bool, reason: Optional[str], metadata: Optional[dict] = None):
11
+ self.result = result
12
+ self.reason = reason
13
+ self.metadata = metadata
14
+
15
+ @staticmethod
16
+ def create(result: bool, reason: Optional[str], metadata: Optional[dict] = None) -> 'ResultDetail':
17
+ return ResultDetail(result=result, reason=reason, metadata=metadata)
18
+
19
+ @staticmethod
20
+ def disabled(reason: str, metadata: Optional[dict] = None) -> 'ResultDetail':
21
+ return ResultDetail(result=False, reason=reason, metadata=metadata)
22
+
23
+ @staticmethod
24
+ def success(reason: str = "Success", metadata: Optional[dict] = None) -> 'ResultDetail':
25
+ return ResultDetail(result=True, reason=reason, metadata=metadata)
26
+
27
+ def to_dict(self) -> dict:
28
+ return {
29
+ 'result': self.result,
30
+ 'reason': self.reason,
31
+ 'metadata': self.metadata
32
+ }
33
+
34
+ @dataclass
35
+ class Domain:
36
+ """ Domain represents the top-level structure of the snapshot, containing groups and their configurations. """
37
+ def __init__(self):
38
+ self.name: str
39
+ self.version: int = 0
40
+ self.activated: bool
41
+ self.group: list[Group]
42
+
43
+ @dataclass
44
+ class Group:
45
+ """ Group represents a collection of configurations within a domain. """
46
+ def __init__(self):
47
+ self.name: str
48
+ self.activated: bool
49
+ self.config: list[Config]
50
+
51
+ @dataclass
52
+ class Config:
53
+ """ Config represents a specific configuration within a group. """
54
+ def __init__(self):
55
+ self.key: str
56
+ self.activated: bool
57
+ self.strategies: Optional[list[StrategyConfig]] = None
58
+ self.relay: Optional[Relay] = None
59
+
60
+ @dataclass
61
+ class StrategyConfig:
62
+ """ StrategyConfig represents a specific strategy within a configuration. """
63
+ def __init__(self):
64
+ self.strategy: str
65
+ self.activated: bool
66
+ self.operation: str
67
+ self.values: list[str]
68
+
69
+ @dataclass
70
+ class Entry:
71
+ """ Entry represents an input entry for strategy evaluation, containing the strategy type and input value. """
72
+ # pylint: disable=redefined-builtin
73
+ def __init__(self, strategy: str, input: str):
74
+ self.strategy = strategy
75
+ self.input = input
76
+
77
+ def to_dict(self) -> dict:
78
+ return {
79
+ 'strategy': self.strategy,
80
+ 'input': self.input
81
+ }
82
+
83
+ @dataclass
84
+ class Relay:
85
+ """ Relay represents a relay configuration within a configuration. """
86
+ def __init__(self):
87
+ self.type: str
88
+ self.activated: bool
89
+
90
+ @dataclass
91
+ class Snapshot:
92
+ """
93
+ Snapshot represents the entire snapshot data structure,
94
+ including the domain and its nested groups and configurations.
95
+ """
96
+ def __init__(self, json_data: dict):
97
+ self._original_data = json_data
98
+ self.domain = self._parse_domain(json_data)
99
+
100
+ def _parse_domain(self, domain_data: dict) -> Domain:
101
+ """ Parse domain data from JSON """
102
+
103
+ domain = Domain()
104
+ domain.name = domain_data.get('name', '')
105
+ domain.activated = domain_data.get('activated', False)
106
+ domain.version = domain_data.get('version', 0)
107
+
108
+ if 'group' in domain_data and domain_data['group']:
109
+ domain.group = []
110
+ for group_data in domain_data['group']:
111
+ domain.group.append(self._parse_group(group_data))
112
+
113
+ return domain
114
+
115
+ def _parse_group(self, group_data: dict) -> Group:
116
+ """ Parse group data from JSON """
117
+
118
+ group = Group()
119
+ group.name = group_data.get('name', '')
120
+ group.activated = group_data.get('activated', False)
121
+
122
+ if 'config' in group_data and group_data['config']:
123
+ group.config = []
124
+ for config_data in group_data['config']:
125
+ group.config.append(self._parse_config(config_data))
126
+
127
+ return group
128
+
129
+ def _parse_config(self, config_data: dict) -> Config:
130
+ """ Parse config data from JSON """
131
+
132
+ config = Config()
133
+ config.key = config_data.get('key', '')
134
+ config.activated = config_data.get('activated', False)
135
+
136
+ if 'strategies' in config_data and config_data['strategies']:
137
+ config.strategies = []
138
+ for strategy_data in config_data['strategies']:
139
+ config.strategies.append(self._parse_strategy(strategy_data))
140
+
141
+ if 'relay' in config_data and config_data['relay']:
142
+ config.relay = self._parse_relay(config_data['relay'])
143
+
144
+ return config
145
+
146
+ def _parse_strategy(self, strategy_data: dict) -> StrategyConfig:
147
+ """ Parse strategy data from JSON """
148
+
149
+ strategy = StrategyConfig()
150
+ strategy.strategy = strategy_data.get('strategy', '')
151
+ strategy.activated = strategy_data.get('activated', False)
152
+ strategy.operation = strategy_data.get('operation', '')
153
+ strategy.values = strategy_data.get('values', [])
154
+
155
+ return strategy
156
+
157
+ def _parse_relay(self, relay_data: dict) -> Relay:
158
+ """ Parse relay data from JSON """
159
+
160
+ relay = Relay()
161
+ relay.type = relay_data.get('type', '')
162
+ relay.activated = relay_data.get('activated', False)
163
+
164
+ return relay
165
+
166
+ def to_dict(self) -> dict:
167
+ """ Convert Snapshot back to dictionary format for JSON serialization """
168
+
169
+ return {'domain': self._original_data}
@@ -0,0 +1,21 @@
1
+ from switcher_client.lib.types import Entry
2
+ from switcher_client.lib.utils.execution_logger import ExecutionLogger
3
+
4
+ def get(value, default_value):
5
+ """ Return value if not None, otherwise return default_value """
6
+ return value if value is not None else default_value
7
+
8
+ # pylint: disable=redefined-builtin
9
+ def get_entry(input: list) -> list[Entry]:
10
+ """ Prepare entry dictionary from input strategy handling """
11
+ entry: list[Entry] = []
12
+ for strategy_type, input_value in input:
13
+ entry.append(Entry(strategy_type, input_value))
14
+
15
+ return entry
16
+
17
+ __all__ = [
18
+ 'ExecutionLogger',
19
+ 'get_entry',
20
+ 'get',
21
+ ]
@@ -0,0 +1,26 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ class DateMoment:
4
+ """
5
+ A utility class for date and time manipulation, similar to moment.js functionality.
6
+ """
7
+
8
+ def __init__(self, date: datetime):
9
+ self.date = date
10
+
11
+ def get_date(self) -> datetime:
12
+ return self.date
13
+
14
+ def add(self, amount: int, unit: str) -> 'DateMoment':
15
+ unit_lower = unit.lower()
16
+
17
+ if unit_lower == 's':
18
+ self.date += timedelta(seconds=amount)
19
+ elif unit_lower == 'm':
20
+ self.date += timedelta(minutes=amount)
21
+ elif unit_lower == 'h':
22
+ self.date += timedelta(hours=amount)
23
+ else:
24
+ raise ValueError(f"Unit {unit} not compatible - try [s, m or h]")
25
+
26
+ return self
@@ -0,0 +1,96 @@
1
+ # pylint: disable=redefined-builtin
2
+ from typing import Optional, Callable, List
3
+
4
+ from switcher_client.lib.types import ResultDetail
5
+
6
+ # Global logger storage
7
+ _logger: List['ExecutionLogger'] = []
8
+
9
+ class ExecutionLogger:
10
+ """It keeps track of latest execution results."""
11
+
12
+ _callback_error: Optional[Callable[[Exception], None]] = None
13
+
14
+ def __init__(self):
15
+ self.key: Optional[str] = None
16
+ self.input: Optional[List[List[str]]] = None
17
+ self.response: ResultDetail = ResultDetail(result=False, reason=None, metadata=None)
18
+
19
+ @staticmethod
20
+ def add(response: ResultDetail, key: str, input: Optional[List[List[str]]] = None) -> None:
21
+ """Add new execution result"""
22
+ # Remove existing execution with same key and input
23
+ for index, log in enumerate(_logger):
24
+ if ExecutionLogger._has_execution(log, key, input):
25
+ _logger.pop(index)
26
+ break
27
+
28
+ # Create new execution log entry
29
+ new_log = ExecutionLogger()
30
+ new_log.key = key
31
+ new_log.input = input
32
+ new_log.response = ResultDetail(
33
+ result=response.result,
34
+ reason=response.reason,
35
+ metadata={**(response.metadata or {}), 'cached': True}
36
+ )
37
+
38
+ _logger.append(new_log)
39
+
40
+ @staticmethod
41
+ def get_execution(key: str, input: Optional[List[List[str]]] = None) -> 'ExecutionLogger':
42
+ """Retrieve a specific result given a key and an input"""
43
+ for log in _logger:
44
+ if ExecutionLogger._has_execution(log, key, input):
45
+ return log
46
+
47
+ return ExecutionLogger()
48
+
49
+ @staticmethod
50
+ def get_by_key(key: str) -> List['ExecutionLogger']:
51
+ """Retrieve results given a switcher key"""
52
+ return [log for log in _logger if log.key == key]
53
+
54
+ @staticmethod
55
+ def clear_logger() -> None:
56
+ """Clear all results"""
57
+ _logger.clear()
58
+
59
+ @classmethod
60
+ def callback_error(cls) -> Optional[Callable[[Exception], None]]:
61
+ """Returns the subscribed error callback."""
62
+ return cls._callback_error
63
+
64
+ @staticmethod
65
+ def subscribe_notify_error(callback: Callable[[Exception], None]) -> None:
66
+ """Subscribe to notify when an asynchronous error is thrown."""
67
+ ExecutionLogger._callback_error = callback
68
+
69
+ @staticmethod
70
+ def _has_execution(log: 'ExecutionLogger', key: str, input: Optional[List[List[str]]]) -> bool:
71
+ """Check if log matches the given key and input"""
72
+ return log.key == key and ExecutionLogger._check_strategy_inputs(log.input, input)
73
+
74
+ @staticmethod
75
+ def _check_strategy_inputs(logger_inputs: Optional[List[List[str]]], inputs: Optional[List[List[str]]]) -> bool:
76
+ """Check if strategy inputs match between logger and current inputs"""
77
+ if not logger_inputs:
78
+ return not inputs or len(inputs) == 0
79
+
80
+ if not inputs:
81
+ return False
82
+
83
+ for strategy_input in logger_inputs:
84
+ if len(strategy_input) >= 2:
85
+ strategy, input_value = strategy_input[0], strategy_input[1]
86
+ # Find matching strategy and input in current inputs
87
+ found = any(
88
+ len(current_input) >= 2 and
89
+ current_input[0] == strategy and
90
+ current_input[1] == input_value
91
+ for current_input in inputs
92
+ )
93
+ if not found:
94
+ return False
95
+
96
+ return True
@@ -0,0 +1,46 @@
1
+ # pylint: disable=too-few-public-methods
2
+ class IPCIDR:
3
+ """Class for checking if an IP address is within a CIDR range."""
4
+
5
+ def __init__(self, cidr: str) -> None:
6
+ """
7
+ Initialize IPCIDR with a CIDR string.
8
+
9
+ Args:
10
+ cidr: CIDR notation string (e.g., '192.168.1.0/24')
11
+ """
12
+ self.cidr = cidr
13
+
14
+ def _ip4_to_int(self, ip: str) -> int:
15
+ """
16
+ Convert an IPv4 address string to a 32-bit integer.
17
+
18
+ Args:
19
+ ip: IPv4 address string (e.g., '192.168.1.1')
20
+
21
+ Returns:
22
+ 32-bit integer representation of the IP address
23
+ """
24
+ octets = ip.split('.')
25
+ result = 0
26
+ for octet in octets:
27
+ result = (result << 8) + int(octet)
28
+ return result & 0xFFFFFFFF # Ensure 32-bit unsigned integer
29
+
30
+ def is_ip4_in_cidr(self, ip: str) -> bool:
31
+ """
32
+ Check if an IPv4 address is within the CIDR range.
33
+
34
+ Args:
35
+ ip: IPv4 address string to check
36
+
37
+ Returns:
38
+ True if the IP is within the CIDR range, False otherwise
39
+ """
40
+ cidr_parts = self.cidr.split('/')
41
+ range_ip = cidr_parts[0]
42
+ bits = int(cidr_parts[1]) if len(cidr_parts) > 1 else 32
43
+
44
+ mask = ~(2 ** (32 - bits) - 1) & 0xFFFFFFFF
45
+
46
+ return (self._ip4_to_int(ip) & mask) == (self._ip4_to_int(range_ip) & mask)
@@ -0,0 +1,43 @@
1
+ import json
2
+
3
+ from typing import Any, List, Union
4
+
5
+ def payload_reader(payload: Any) -> List[str]:
6
+ """Extract all field keys from a JSON payload structure.
7
+
8
+ This function recursively traverses a JSON structure
9
+ and returns all possible field paths in dot notation.
10
+ """
11
+ # Handle string payload - if it's a string, try to parse it as JSON
12
+ if isinstance(payload, str):
13
+ parsed = parse_json(payload)
14
+ if parsed is not None:
15
+ payload = parsed
16
+
17
+ # If payload is a list/array, recursively process each element
18
+ if isinstance(payload, list):
19
+ result = []
20
+ for item in payload:
21
+ result.extend(payload_reader(item))
22
+ return result
23
+
24
+ # If payload is not a dict, return empty list (primitive values don't have keys)
25
+ if not isinstance(payload, dict):
26
+ return []
27
+
28
+ # Process dictionary keys
29
+ result = []
30
+
31
+ for field in payload.keys():
32
+ result.append(field)
33
+ nested_fields = payload_reader(payload[field])
34
+ for nested_field in nested_fields:
35
+ result.append(f"{field}.{nested_field}")
36
+
37
+ return result
38
+
39
+ def parse_json(json_str: str) -> Union[Any, None]:
40
+ try:
41
+ return json.loads(json_str)
42
+ except (json.JSONDecodeError, TypeError):
43
+ return None
@@ -0,0 +1,3 @@
1
+ from .timed_match import TimedMatch
2
+
3
+ __all__ = ['TimedMatch']
@@ -0,0 +1,246 @@
1
+ import multiprocessing
2
+ import time
3
+
4
+ from typing import List, Optional, Any
5
+ from dataclasses import dataclass
6
+
7
+ from switcher_client.lib.globals.global_context import DEFAULT_REGEX_MAX_BLACKLISTED, DEFAULT_REGEX_MAX_TIME_LIMIT
8
+ from switcher_client.lib.utils.timed_match.worker import TaskType, WorkerResult, WorkerTask, persistent_regex_worker
9
+
10
+ @dataclass
11
+ class Blacklist:
12
+ """Represents a blacklisted regex pattern and input combination."""
13
+ patterns: List[str]
14
+ input_value: str
15
+
16
+ class TimedMatch:
17
+ """
18
+ This class provides regex match operations with timeout-based ReDoS protection.
19
+
20
+ Operations are executed in isolated processes with configurable timeouts.
21
+ Processes that exceed the timeout are terminated, preventing ReDoS attacks.
22
+ Failed operations are cached in a blacklist to avoid repeated resource usage.
23
+ """
24
+
25
+ _blacklisted: List[Blacklist] = []
26
+ _max_blacklisted: int = DEFAULT_REGEX_MAX_BLACKLISTED
27
+ _max_time_limit: float = DEFAULT_REGEX_MAX_TIME_LIMIT / 1000.0 # Convert to seconds
28
+
29
+ # Persistent worker management
30
+ _worker_process: Optional[multiprocessing.Process] = None
31
+ _task_queue: Optional[multiprocessing.Queue] = None
32
+ _result_queue: Optional[multiprocessing.Queue] = None
33
+ _worker_ctx: Optional[Any] = None
34
+ _task_counter: int = 0
35
+ _worker_needs_restart: bool = False
36
+ _old_workers_to_cleanup: List[multiprocessing.Process] = []
37
+
38
+ @classmethod
39
+ def initialize_worker(cls) -> bool:
40
+ """
41
+ Initialize the persistent worker process for regex matching.
42
+
43
+ Creates a new worker process with communication queues. If a worker
44
+ already exists, it will be terminated before creating a new one.
45
+
46
+ Returns:
47
+ True if worker was successfully initialized, False otherwise
48
+ """
49
+ # Terminate existing worker if any
50
+ cls.terminate_worker()
51
+
52
+ # Create multiprocessing context
53
+ cls._worker_ctx = multiprocessing.get_context('spawn')
54
+
55
+ # Create communication queues
56
+ cls._task_queue = cls._worker_ctx.Queue()
57
+ cls._result_queue = cls._worker_ctx.Queue()
58
+
59
+ # Create and start worker process
60
+ cls._worker_process = cls._worker_ctx.Process(
61
+ target=persistent_regex_worker,
62
+ args=(cls._task_queue, cls._result_queue)
63
+ )
64
+ if cls._worker_process:
65
+ cls._worker_process.start()
66
+
67
+ # Reset task counter
68
+ cls._task_counter = 0
69
+
70
+ return cls._worker_process is not None and cls._worker_process.is_alive()
71
+
72
+ @classmethod
73
+ def terminate_worker(cls) -> None:
74
+ """
75
+ Terminate all worker processes (current and old ones).
76
+
77
+ Sends a shutdown signal to workers and forcefully terminates them if needed.
78
+ Cleans up all worker-related resources.
79
+ """
80
+ try:
81
+ # Terminate current worker
82
+ if cls._worker_process and cls._worker_process.is_alive():
83
+ cls._graceful_shutdown()
84
+
85
+ # Terminate all old workers waiting for cleanup
86
+ cls._terminate_all_old_workers()
87
+ finally:
88
+ cls._cleanup_resources()
89
+
90
+ @classmethod
91
+ def _graceful_shutdown(cls) -> None:
92
+ """Attempt graceful shutdown of worker process."""
93
+ if cls._task_queue:
94
+ shutdown_task = WorkerTask(
95
+ task_type=TaskType.SHUTDOWN,
96
+ task_id=f"shutdown_{time.time()}"
97
+ )
98
+ cls._task_queue.put(shutdown_task, timeout=1.0)
99
+ if cls._worker_process:
100
+ cls._worker_process.join(timeout=2.0)
101
+
102
+ @classmethod
103
+ def _cleanup_resources(cls) -> None:
104
+ """Clean up all worker-related resources."""
105
+ cls._worker_process = None
106
+ cls._task_queue = None
107
+ cls._result_queue = None
108
+ cls._worker_ctx = None
109
+ cls._task_counter = 0
110
+ cls._worker_needs_restart = False
111
+ cls._old_workers_to_cleanup.clear()
112
+
113
+ @classmethod
114
+ def try_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool = False) -> bool:
115
+ """
116
+ Executes regex matching operation with timeout protection.
117
+
118
+ The operation runs in an isolated process with timeout protection to prevent
119
+ runaway regex operations that could lead to ReDoS attacks.
120
+
121
+ Failed operations (timeouts, errors) are automatically added to a blacklist
122
+ to prevent repeated attempts with the same problematic patterns.
123
+
124
+ Args:
125
+ patterns: Array of regular expression patterns to test against the input
126
+ input_value: The input string to match against the regex patterns
127
+ use_fullmatch: If True, uses re.fullmatch; if False, uses re.search
128
+
129
+ Returns:
130
+ True if any of the regex patterns match the input, false otherwise
131
+ """
132
+ if cls._is_blacklisted(patterns, input_value):
133
+ return False
134
+
135
+ return cls._safe_match(patterns, input_value, use_fullmatch)
136
+
137
+ @classmethod
138
+ def _safe_match(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> bool:
139
+ """ Run regex match with timeout protection using persistent worker."""
140
+ task_id = cls._create_and_send_task(patterns, input_value, use_fullmatch)
141
+ return cls._wait_for_result(task_id, patterns, input_value)
142
+
143
+ @classmethod
144
+ def _create_and_send_task(cls, patterns: List[str], input_value: str, use_fullmatch: bool) -> str:
145
+ """Create task and send to worker."""
146
+ cls._task_counter += 1
147
+ task_id = f"task_{cls._task_counter}_{time.time()}"
148
+
149
+ task = WorkerTask(
150
+ task_type=TaskType.MATCH,
151
+ patterns=patterns,
152
+ input_value=input_value,
153
+ use_fullmatch=use_fullmatch,
154
+ task_id=task_id
155
+ )
156
+
157
+ if cls._task_queue:
158
+ cls._task_queue.put(task, timeout=1.0)
159
+ return task_id
160
+
161
+ @classmethod
162
+ def _wait_for_result(cls, task_id: str, patterns: List[str], input_value: str) -> bool:
163
+ """Wait for result from worker with timeout."""
164
+ start_time = time.time()
165
+ while time.time() - start_time < cls._max_time_limit:
166
+ try:
167
+ if cls._result_queue:
168
+ result = cls._result_queue.get(timeout=0.1)
169
+ if result.task_id == task_id:
170
+ return cls._process_worker_result(result, patterns, input_value)
171
+ except Exception: # pylint: disable=broad-exception-caught
172
+ continue
173
+
174
+ # Timeout occurred - start new worker immediately and defer cleanup of old one
175
+ cls._replace_worker_immediately()
176
+ cls._add_to_blacklist(patterns, input_value)
177
+ return False
178
+
179
+ @classmethod
180
+ def _process_worker_result(cls, result: WorkerResult, patterns: List[str], input_value: str) -> bool:
181
+ """Process result from worker."""
182
+ if result.success:
183
+ return result.result if result.result is not None else False
184
+ cls._add_to_blacklist(patterns, input_value)
185
+ return False
186
+
187
+ @classmethod
188
+ def _is_blacklisted(cls, patterns: List[str], input_value: str) -> bool:
189
+ for blacklisted in cls._blacklisted:
190
+ # Check if input can contain same segment that could fail matching
191
+ if blacklisted.input_value in input_value or input_value in blacklisted.input_value:
192
+ # Check if any of the patterns match (regex order should not affect)
193
+ matching_patterns = [p for p in patterns if p in blacklisted.patterns]
194
+ if matching_patterns:
195
+ return True
196
+ return False
197
+
198
+ @classmethod
199
+ def _add_to_blacklist(cls, patterns: List[str], input_value: str) -> None:
200
+ # Maintain blacklist size limit
201
+ if len(cls._blacklisted) >= cls._max_blacklisted:
202
+ cls._blacklisted.pop(0) # Remove oldest entry
203
+
204
+ cls._blacklisted.append(Blacklist(
205
+ patterns=patterns.copy(),
206
+ input_value=input_value
207
+ ))
208
+
209
+ @classmethod
210
+ def _replace_worker_immediately(cls) -> None:
211
+ """Replace worker immediately without waiting for cleanup."""
212
+ # Move current worker to cleanup list if it exists
213
+ if cls._worker_process:
214
+ cls._old_workers_to_cleanup.append(cls._worker_process)
215
+
216
+ # Clear current worker references (but don't cleanup yet)
217
+ cls._worker_process = None
218
+ cls._task_queue = None
219
+ cls._result_queue = None
220
+ cls._worker_ctx = None
221
+ cls._task_counter = 0
222
+
223
+ # Initialize new worker immediately
224
+ cls.initialize_worker()
225
+
226
+ @classmethod
227
+ def _terminate_all_old_workers(cls) -> None:
228
+ """Forcefully terminate all old workers synchronously."""
229
+ for worker in cls._old_workers_to_cleanup[:]:
230
+ if worker and worker.is_alive():
231
+ worker.terminate()
232
+ worker.join(timeout=1.0)
233
+
234
+ cls._old_workers_to_cleanup.clear()
235
+
236
+ @classmethod
237
+ def clear_blacklist(cls) -> None:
238
+ cls._blacklisted.clear()
239
+
240
+ @classmethod
241
+ def set_max_blacklisted(cls, value: int) -> None:
242
+ cls._max_blacklisted = value
243
+
244
+ @classmethod
245
+ def set_max_time_limit(cls, value: int) -> None:
246
+ cls._max_time_limit = value / 1000.0 # Convert to seconds