kameleoon-client-python 3.22.0__py2.py3-none-any.whl → 3.22.2__py2.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.
@@ -67,17 +67,15 @@ class VisitorManager:
67
67
  KameleoonLogger.debug("RETURN: VisitorManager.get_or_create_visitor(visitor_code: %s)", visitor_code)
68
68
 
69
69
  def peek_visitor(self, visitor_code: str) -> Optional[Visitor]:
70
- """Retrieves a visitor by their code if it exists, otherwise returns `None`."""
71
- KameleoonLogger.debug(f"CALL: VisitorManager.peekVisitor(visitor_code: {visitor_code})")
72
- if (slot := self.__try_acquire_slot(visitor_code)) is None:
73
- visitor = None
74
- else:
75
- try:
76
- visitor = slot.visitor
77
- finally:
78
- slot.lock.release()
70
+ """
71
+ Retrieves a visitor by their code if it exists, otherwise returns `None`. Unlike `get_visitor`, does not
72
+ count as visitor activity and takes no lock: a plain read needs no protection against the purge.
73
+ """
74
+ KameleoonLogger.debug("CALL: VisitorManager.peek_visitor(visitor_code: %s)", visitor_code)
75
+ slot = self._slots.get(visitor_code)
76
+ visitor = slot.visitor if slot is not None else None
79
77
  KameleoonLogger.debug(
80
- f"RETURN: VisitorManager.peekVisitor(visitor_code: {visitor_code}) -> " f"(visitor: {visitor})"
78
+ "RETURN: VisitorManager.peek_visitor(visitor_code: %s) -> (visitor: %s)", visitor_code, visitor
81
79
  )
82
80
  return visitor
83
81
 
@@ -180,7 +178,7 @@ class VisitorManager:
180
178
  while True:
181
179
  slot = self._slots[visitor_code]
182
180
  slot.lock.acquire()
183
- if slot == self._slots.get(visitor_code):
181
+ if slot is self._slots.get(visitor_code):
184
182
  return slot
185
183
  slot.lock.release()
186
184
 
@@ -190,7 +188,7 @@ class VisitorManager:
190
188
  if slot is None:
191
189
  return None
192
190
  slot.lock.acquire()
193
- if slot == self._slots.get(visitor_code):
191
+ if slot is self._slots.get(visitor_code):
194
192
  return slot
195
193
  slot.lock.release()
196
194
 
@@ -199,7 +197,7 @@ class VisitorManager:
199
197
  if slot is None:
200
198
  return None
201
199
  slot.lock.acquire()
202
- if slot == self._slots.get(visitor_code):
200
+ if slot is self._slots.get(visitor_code):
203
201
  return slot
204
202
  slot.lock.release()
205
203
  return None
@@ -12,6 +12,10 @@ class ThreadEventLoop:
12
12
  ThreadEventLoop is helper object to handle asynchronous tasks execution in a separate thread.
13
13
  """
14
14
 
15
+ # Upper bound for `stop` to wait for the running tasks and for the thread to exit; a task that never
16
+ # completes (or a loop thread blocked by a synchronous call) must not hang the disposal forever.
17
+ STOP_TIMEOUT = 10.0
18
+
15
19
  def __init__(self) -> None:
16
20
  self.loop: Optional[asyncio.AbstractEventLoop] = None
17
21
  self.thread: Optional[threading.Thread] = None
@@ -40,17 +44,38 @@ class ThreadEventLoop:
40
44
  return self.loop
41
45
 
42
46
  def stop(self) -> None:
43
- """Stops the thread and the event loop"""
47
+ """
48
+ Stops the thread and the event loop.
49
+
50
+ Waits (up to `STOP_TIMEOUT`) for the already scheduled tasks to complete before stopping the loop.
51
+ """
44
52
  if self.thread is None:
45
53
  return
46
54
  self.running_allowed = False
47
- time.sleep(0.001)
48
- while self.count_tasks() > 0:
49
- time.sleep(0.01)
50
55
  loop = self.get_loop()
56
+ if threading.current_thread() is self.thread:
57
+ # Called from the loop's own thread (e.g. a GC-triggered `__del__`): it cannot wait for or join itself.
58
+ loop.call_soon(loop.stop)
59
+ return
60
+ # The draining runs on the loop itself, after everything already queued (e.g. tasks being created by
61
+ # `run_coroutine_threadsafe`), so it observes the tasks a cross-thread `all_tasks` poll could miss.
62
+ drained = asyncio.run_coroutine_threadsafe(self.__wait_for_tasks(), loop)
63
+ try:
64
+ drained.result(timeout=self.STOP_TIMEOUT)
65
+ except Exception: # pylint: disable=W0703
66
+ drained.cancel()
51
67
  loop.call_soon_threadsafe(loop.stop)
52
- self.thread.join()
53
- loop.close()
68
+ self.thread.join(timeout=self.STOP_TIMEOUT)
69
+ if not self.thread.is_alive():
70
+ loop.close()
71
+
72
+ @staticmethod
73
+ async def __wait_for_tasks() -> None:
74
+ while True:
75
+ tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
76
+ if not tasks:
77
+ return
78
+ await asyncio.wait(tasks, timeout=ThreadEventLoop.STOP_TIMEOUT)
54
79
 
55
80
  def run_coro(self, coro: Coroutine[Any, Any, Any]) -> Optional[concurrent.futures.Future[Any]]:
56
81
  """
@@ -66,10 +91,6 @@ class ThreadEventLoop:
66
91
  self.start()
67
92
  return asyncio.run_coroutine_threadsafe(coro, self.get_loop())
68
93
 
69
- def count_tasks(self) -> int:
70
- """Returns the number of running tasks."""
71
- return len(asyncio.all_tasks(self.get_loop()))
72
-
73
94
 
74
95
  def run_in_thread(
75
96
  func: Callable[[], Any], thread_name: Optional[str] = None, with_event_loop=False
@@ -86,16 +107,15 @@ def run_in_thread(
86
107
  :rtype: threading.Thread
87
108
  """
88
109
  if with_event_loop:
110
+
89
111
  def target():
90
112
  loop = asyncio.new_event_loop()
91
113
  func()
92
114
  loop.close()
115
+
93
116
  else:
94
117
  target = func
95
- thread = threading.Thread(target=target)
96
- thread.daemon = True
97
- if thread_name:
98
- thread.setName(thread_name)
118
+ thread = threading.Thread(target=target, name=thread_name or None, daemon=True)
99
119
  thread.start()
100
120
  return thread
101
121
 
@@ -42,10 +42,14 @@ class Scheduler:
42
42
 
43
43
  def stop(self) -> None:
44
44
  """
45
- Starts all scheduled jobs.
45
+ Stops all scheduled jobs.
46
46
 
47
47
  Should not be called more than once.
48
48
  """
49
+ if self._loop.is_closed():
50
+ # The jobs died with the loop; nothing left to stop (e.g. disposal after the host app closed its loop).
51
+ KameleoonLogger.debug("Scheduler is not stopped: event loop is already closed")
52
+ return
49
53
  try:
50
54
  self._loop.call_soon_threadsafe(self._stop)
51
55
  except Exception as err: # pylint: disable=W0703
@@ -53,8 +57,7 @@ class Scheduler:
53
57
 
54
58
  def _stop(self) -> None:
55
59
  for job in self._jobs.values():
56
- if job.stop_signal is not None:
57
- job.stop_signal.set()
60
+ job.stop()
58
61
 
59
62
  class Job:
60
63
  """`Job` represents a scheduled job"""
@@ -65,13 +68,24 @@ class Scheduler:
65
68
  self.func = func
66
69
  self.stop_signal: Optional[asyncio.Event] = None
67
70
  self.future: Optional[concurrent.futures.Future[Any]] = None
71
+ self._stop_requested = False
68
72
 
69
73
  def run(self, loop: asyncio.AbstractEventLoop) -> None:
70
74
  """Starts the job execution in the given event loop."""
71
75
  if self.future is None:
72
76
  self.future = asyncio.run_coroutine_threadsafe(self._cycle(), loop)
73
77
 
78
+ def stop(self) -> None:
79
+ """Requests the job to stop. Must be called on the job's event loop."""
80
+ # `stop_signal` only exists once `_cycle` has started; a stop posted with `call_soon_threadsafe` right
81
+ # after `run` is processed before the task's first step, so the request has to be remembered as well.
82
+ self._stop_requested = True
83
+ if self.stop_signal is not None:
84
+ self.stop_signal.set()
85
+
74
86
  async def _cycle(self) -> None:
87
+ if self._stop_requested:
88
+ return
75
89
  self.stop_signal = asyncio.Event()
76
90
  interval = self.interval
77
91
  stop_task = asyncio.create_task(self.stop_signal.wait())
@@ -9,7 +9,7 @@ class StringUtils:
9
9
 
10
10
  @staticmethod
11
11
  def secret(secret: Optional[str]) -> str:
12
- """Hide part of the string, keeping only a few initial characters visible."""
12
+ """Hides the string behind a fixed-width mask, keeping only a few trailing characters visible."""
13
13
  hid_ch = '*'
14
14
  vis_count = 4
15
15
 
@@ -21,8 +21,7 @@ class StringUtils:
21
21
  if length <= vis_count:
22
22
  return hid_ch * length
23
23
 
24
- hidden_length = max(length - vis_count, vis_count)
25
- return secret[:length - hidden_length] + hid_ch * hidden_length
24
+ return hid_ch * vis_count + secret[max(length - vis_count, vis_count):]
26
25
 
27
26
  @staticmethod
28
27
  def bool_to_string_lower_case(data: bool) -> str:
@@ -1,5 +1,6 @@
1
1
  """Tracking"""
2
- from typing import Iterable, List
2
+ import asyncio
3
+ from typing import Iterable, List, Optional
3
4
  from kameleoon.data.manager.visitor_manager import VisitorManager
4
5
  from kameleoon.logging.kameleoon_logger import KameleoonLogger
5
6
  from kameleoon.managers.asyncexec.async_executor import AsyncExecutor
@@ -7,7 +8,7 @@ from kameleoon.managers.data.data_manager import DataManager
7
8
  from kameleoon.managers.tracking.tracking_builder import TrackingBuilder
8
9
  from kameleoon.managers.tracking.visitor_tracking_registry import (
9
10
  VisitorTrackingRegistry,
10
- LockVisitorTrackingRegistry,
11
+ ConcurrentVisitorTrackingRegistry,
11
12
  )
12
13
  from kameleoon.network.net_provider import Response
13
14
  from kameleoon.network.network_manager import NetworkManager
@@ -19,6 +20,7 @@ class TrackingManager:
19
20
  """Tracking manager"""
20
21
  LINES_DELIMITER = "\n"
21
22
  REQUEST_SIZE_LIMIT = 2560 * 1024 # 2.5 * 1024^2 characters
23
+ PROBE_MAX_VISITORS = 1
22
24
 
23
25
  # pylint: disable=R0913
24
26
  def __init__(
@@ -30,7 +32,8 @@ class TrackingManager:
30
32
  "CALL: TrackingManager(data_manager, network_manager. visitor_manager, "
31
33
  "track_interval_seconds: %s, scheduler)", track_interval_seconds
32
34
  )
33
- self._tracking_visitors: VisitorTrackingRegistry = LockVisitorTrackingRegistry(visitor_manager)
35
+ self._tracking_visitors: VisitorTrackingRegistry = ConcurrentVisitorTrackingRegistry(visitor_manager)
36
+ self._probe_mode = False
34
37
  self._data_manager = data_manager
35
38
  self._network_manager = network_manager
36
39
  self._visitor_manager = visitor_manager
@@ -41,6 +44,11 @@ class TrackingManager:
41
44
  "track_interval_seconds: %s, scheduler)", track_interval_seconds
42
45
  )
43
46
 
47
+ @property
48
+ def probe_mode(self) -> bool:
49
+ """Test purpose only"""
50
+ return self._probe_mode
51
+
44
52
  def add_visitor_code(self, visitor_code: str) -> None:
45
53
  """Adds a visitor code to the tracking registry."""
46
54
  KameleoonLogger.debug("CALL: TrackingManager.add_visitor_code(visitor_code: %s)", visitor_code)
@@ -54,7 +62,10 @@ class TrackingManager:
54
62
  `track_all` is triggered by the interval tracking timer.
55
63
  """
56
64
  KameleoonLogger.debug("CALL: TrackingManager.track_all()")
57
- self.__track(self._tracking_visitors.extract())
65
+ if self._probe_mode:
66
+ self.__track_probe()
67
+ else:
68
+ self.__track(self._tracking_visitors.extract())
58
69
  KameleoonLogger.debug("RETURN: TrackingManager.track_all()")
59
70
 
60
71
  def track_visitor(self, visitor_code: str) -> None:
@@ -63,7 +74,14 @@ class TrackingManager:
63
74
  self.__track([visitor_code])
64
75
  KameleoonLogger.debug("RETURN: TrackingManager.track_visitor(visitor_code: %s)", visitor_code)
65
76
 
66
- def __track(self, visitor_codes: Iterable[str]) -> None:
77
+ def __track_probe(self) -> None:
78
+ # Sends one visitor's data; visitors without data are dropped (as on the normal path) until one is found.
79
+ while visitor_codes := self._tracking_visitors.extract(self.PROBE_MAX_VISITORS):
80
+ if self.__track(visitor_codes):
81
+ return
82
+
83
+ def __track(self, visitor_codes: Iterable[str]) -> bool:
84
+ """Returns whether a request was sent."""
67
85
  builder = TrackingBuilder(
68
86
  visitor_codes, self._data_manager.data_file, self._visitor_manager, self.REQUEST_SIZE_LIMIT
69
87
  )
@@ -75,33 +93,58 @@ class TrackingManager:
75
93
  "If it is not caused by the peak load, decreasing the tracking interval is recommended."
76
94
  )
77
95
  self._tracking_visitors.add_all(builder.visitor_codes_to_keep)
78
- self.__perform_tracking_request(
96
+ return self.__perform_tracking_request(
79
97
  builder.visitor_codes_to_send, builder.unsent_visitor_data, builder.tracking_lines
80
98
  )
81
99
 
82
100
  def __perform_tracking_request(
83
101
  self, visitor_codes: List[str], visitors_data: List[Sendable], tracking_lines: List[str]
84
- ) -> None:
102
+ ) -> bool:
103
+ """Returns whether a request was sent."""
85
104
  if not tracking_lines:
86
- return
105
+ return False
87
106
  lines = self.LINES_DELIMITER.join(tracking_lines)
88
107
  # Mark unsent data as transmitted
89
108
  for sendable_data in visitors_data:
90
109
  sendable_data.mark_as_transmitting()
91
110
 
92
- async def call() -> Response:
93
- service = self._network_manager.get_service(DataService)
94
- response = await service.send_tracking_data(lines)
95
- if response.success:
111
+ async def call() -> Optional[Response]:
112
+ response: Optional[Response] = None
113
+ try:
114
+ service = self._network_manager.get_service(DataService)
115
+ response = await service.send_tracking_data(lines)
116
+ except asyncio.CancelledError:
117
+ # Not a Data API failure: roll the data back for the next request without entering probe mode.
118
+ self.__handle_failed_request(visitor_codes, visitors_data)
119
+ raise
120
+ except Exception as exception: # pylint: disable=W0703
121
+ KameleoonLogger.error("Tracking request failed: %s", exception)
122
+ if (response is not None) and response.success:
96
123
  KameleoonLogger.debug("Successful request for tracking visitors: %s, data: %s",
97
124
  visitor_codes, visitors_data)
98
125
  for sendable_data in visitors_data:
99
126
  sendable_data.mark_as_sent()
127
+ self.__leave_probe_mode()
100
128
  else:
101
- KameleoonLogger.debug("Failed request for tracking visitors: %s, data: %s",
102
- visitor_codes, visitors_data)
103
- for sendable_data in visitors_data:
104
- sendable_data.mark_as_unsent()
105
- self._tracking_visitors.add_all(visitor_codes)
129
+ self.__handle_failed_request(visitor_codes, visitors_data)
130
+ self.__enter_probe_mode()
106
131
  return response
107
132
  self._async_executor.run_coro(call(), "send_tracking_data")
133
+ return True
134
+
135
+ def __handle_failed_request(self, visitor_codes: List[str], visitors_data: List[Sendable]) -> None:
136
+ KameleoonLogger.debug("Failed request for tracking visitors: %s, data: %s", visitor_codes, visitors_data)
137
+ for sendable_data in visitors_data:
138
+ sendable_data.mark_as_unsent()
139
+ self._tracking_visitors.add_all(visitor_codes)
140
+
141
+ def __enter_probe_mode(self) -> None:
142
+ if not self._probe_mode:
143
+ self._probe_mode = True
144
+ KameleoonLogger.info("Tracking request failed: only one visitor's data per tracking request"
145
+ " will be sent until a request succeeds")
146
+
147
+ def __leave_probe_mode(self) -> None:
148
+ if self._probe_mode:
149
+ self._probe_mode = False
150
+ KameleoonLogger.info("Tracking request succeeded: full-size tracking requests are restored")
@@ -1,6 +1,8 @@
1
1
  """Tracking"""
2
+
3
+ import sys
2
4
  from threading import Lock
3
- from typing import Iterable, List, Set
5
+ from typing import Dict, Iterable, List, Set
4
6
  from kameleoon.data.manager.visitor_manager import VisitorManager
5
7
 
6
8
 
@@ -10,6 +12,28 @@ class VisitorTrackingRegistry:
10
12
 
11
13
  Must be thread-safe
12
14
  """
15
+
16
+ UNLIMITED = sys.maxsize
17
+ DEFAULT_STORAGE_LIMIT = 1_000_000
18
+ DEFAULT_EXTRACTION_LIMIT = 20_000
19
+ LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT = 2
20
+ REMOVAL_FACTOR = 0.8
21
+
22
+ def __init__(
23
+ self,
24
+ visitor_manager: VisitorManager,
25
+ storage_limit=DEFAULT_STORAGE_LIMIT,
26
+ extraction_limit=DEFAULT_EXTRACTION_LIMIT,
27
+ ) -> None:
28
+ self._visitor_manager = visitor_manager
29
+ self._storage_limit = storage_limit
30
+ self._extraction_limit = extraction_limit
31
+
32
+ @property
33
+ def visitors(self) -> Set[str]:
34
+ """Test purpose only"""
35
+ raise NotImplementedError()
36
+
13
37
  def add(self, visitor_code: str) -> None:
14
38
  """Adds a single visitor code to the registry."""
15
39
  raise NotImplementedError()
@@ -18,72 +42,71 @@ class VisitorTrackingRegistry:
18
42
  """Adds multiple visitor codes to the registry."""
19
43
  raise NotImplementedError()
20
44
 
21
- def extract(self) -> Iterable[str]:
22
- """Extracts and returns stored visitor codes from the registry."""
45
+ def extract(self, limit: int = UNLIMITED) -> List[str]:
46
+ """
47
+ Removes and returns visitor codes. A backlog under the doubled `extraction_limit` is returned at once
48
+ (unless `limit` is smaller); otherwise at most `min(limit, extraction_limit)` codes are returned.
49
+ Codes are extracted in no particular order.
50
+ """
23
51
  raise NotImplementedError()
24
52
 
53
+ def _extraction_count(self, size: int, limit: int) -> int:
54
+ if (size <= limit) and (size < self._extraction_limit * self.LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT):
55
+ return size
56
+ return min(limit, self._extraction_limit)
25
57
 
26
- class LockVisitorTrackingRegistry(VisitorTrackingRegistry):
27
- """An implementation of the `VisitorTrackingRegistry` based on the `threading.Lock`"""
28
- LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT = 2
29
- REMOVAL_FACTOR = 0.8
58
+ def _storage_limit_after_removal(self) -> int:
59
+ return int(self._storage_limit * self.REMOVAL_FACTOR)
30
60
 
31
- def __init__(self, visitor_manager: VisitorManager, storage_limit=1000_000, extraction_limit=20_000) -> None:
32
- self._visitor_manager = visitor_manager
33
- self._storage_limit = storage_limit
34
- self._extraction_limit = extraction_limit
35
- self._visitors: Set[str] = set()
36
- self._lock = Lock()
61
+
62
+ class ConcurrentVisitorTrackingRegistry(VisitorTrackingRegistry):
63
+ """
64
+ Lock-free on the hot path: `add` is a single `dict` operation, atomic under the GIL (CPython, PyPy) and under the
65
+ free-threaded build's per-object locking, so request threads never block on the registry - with the GIL a lock
66
+ there causes a convoy on every interpreter switch (~5x lower throughput, p99 of hundreds of microseconds at
67
+ 8 threads; see `tests/benchmarks/visitor_tracking_registry_benchmark.py`). The dict is never swapped out;
68
+ `extract` removes with `popitem()` (O(1)) under a lock that only serializes removers, so a concurrently added
69
+ code is either returned now or kept for the next extraction, never lost. Nothing here depends on the order in
70
+ which concurrent operations interleave, only on single operations not corrupting or losing entries.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ visitor_manager: VisitorManager,
76
+ storage_limit=VisitorTrackingRegistry.DEFAULT_STORAGE_LIMIT,
77
+ extraction_limit=VisitorTrackingRegistry.DEFAULT_EXTRACTION_LIMIT,
78
+ ) -> None:
79
+ super().__init__(visitor_manager, storage_limit, extraction_limit)
80
+ self._visitors: Dict[str, None] = {}
81
+ self._extract_lock = Lock()
82
+
83
+ @property
84
+ def visitors(self) -> Set[str]:
85
+ return set(self._visitors)
37
86
 
38
87
  def add(self, visitor_code: str) -> None:
39
- """Adds a single visitor code to the registry."""
40
- with self._lock:
41
- self._visitors.add(visitor_code)
88
+ self._visitors[visitor_code] = None
42
89
 
43
90
  def add_all(self, visitor_codes: Iterable[str]) -> None:
44
- """Adds multiple visitor codes to the registry."""
45
- with self._lock:
46
- self._visitors.update(visitor_codes)
47
- if len(self._visitors) > self._storage_limit:
48
- self.__erase_nonexistent_visitors()
49
- self.__erase_to_storage_limit()
50
-
51
- def __erase_nonexistent_visitors(self) -> None:
52
- """Not thread-safe"""
53
- visitors_to_remove = [vc for vc in self._visitors if self._visitor_manager.get_visitor(vc) is None]
54
- self._visitors.difference_update(visitors_to_remove)
91
+ self._visitors.update(dict.fromkeys(visitor_codes))
92
+ if len(self._visitors) > self._storage_limit:
93
+ self.__erase_to_storage_limit()
55
94
 
56
95
  def __erase_to_storage_limit(self) -> None:
57
- """Not thread-safe"""
58
- visitors_to_remove_count = len(self._visitors) - int(self._storage_limit * self.REMOVAL_FACTOR)
59
- for _ in range(visitors_to_remove_count):
60
- self._visitors.pop()
61
-
62
- def extract(self) -> Iterable[str]:
63
- """
64
- Extracts and returns visitor codes from the registry. The number of the extracted visitor codes
65
- is limited to the doubled `extraction_limit`.
66
- """
67
- return self.__extract_all() if self.__should_extract_all_be_used() else self.__extract_limited()
68
-
69
- def __should_extract_all_be_used(self) -> bool:
70
- return len(self._visitors) < self._extraction_limit * self.LIMITED_EXTRACTION_THRESHOLD_COEFFICIENT
71
-
72
- def __extract_all(self) -> Iterable[str]:
73
- new_visitors: Set[str] = set()
74
- with self._lock:
75
- old_visitors = self._visitors
76
- self._visitors = new_visitors
77
- return old_visitors
78
-
79
- def __extract_limited(self) -> Iterable[str]:
80
- with self._lock:
81
- if self.__should_extract_all_be_used():
82
- return self.__extract_all()
83
- extracted: List[str] = [""] * self._extraction_limit
84
- for i, visitor_code in enumerate(self._visitors):
85
- if i >= self._extraction_limit:
86
- break
87
- extracted[i] = visitor_code
88
- self._visitors.difference_update(extracted)
89
- return extracted
96
+ with self._extract_lock:
97
+ # Drain with `popitem()` instead of a `for` loop over the live dict: request threads keep calling `add`
98
+ # meanwhile, and a loop over a dict that gets a new key fails with "dictionary changed size during
99
+ # iteration" - which would abort the eviction. Codes added while draining stay in the live dict.
100
+ drained = [self._visitors.popitem()[0] for _ in range(len(self._visitors))]
101
+ # `peek_visitor`: an eviction pass must not count as activity and extend the visitors' sessions.
102
+ self._visitors.update(
103
+ dict.fromkeys(vc for vc in drained if self._visitor_manager.peek_visitor(vc) is not None)
104
+ )
105
+ visitors_to_remove_count = len(self._visitors) - self._storage_limit_after_removal()
106
+ for _ in range(visitors_to_remove_count):
107
+ self._visitors.popitem()
108
+
109
+ def extract(self, limit: int = VisitorTrackingRegistry.UNLIMITED) -> List[str]:
110
+ with self._extract_lock:
111
+ count = self._extraction_count(len(self._visitors), limit)
112
+ return [self._visitors.popitem()[0] for _ in range(count)]
@@ -1,16 +1,25 @@
1
1
  """Network"""
2
+
3
+ # Postponed evaluation: `concurrent.futures.Future[...]` is not subscriptable at runtime before Python 3.9.
4
+ from __future__ import annotations
5
+
2
6
  import asyncio
3
7
  import base64
8
+ import concurrent.futures
9
+ import threading
4
10
  import time
5
- from typing import Optional
11
+ from typing import Optional, Tuple
6
12
 
7
13
  from kameleoon.helpers.string_utils import StringUtils
8
14
  from kameleoon.logging.kameleoon_logger import KameleoonLogger
15
+ from kameleoon.network.net_provider import Response
9
16
  from kameleoon.network.services.automation_service import AutomationService
10
17
 
11
18
 
12
19
  class AccessTokenSource:
13
20
  """Access Token Source"""
21
+
22
+ _SILENCE_PERIOD = 300.0 # in seconds
14
23
  _TOKEN_EXPIRATION_GAP = 60.0 # in seconds
15
24
  _TOKEN_OBSOLESCENCE_GAP = 1800.0 # in seconds
16
25
  _JWT_ACCESS_TOKEN_FIELD = "access_token"
@@ -19,17 +28,22 @@ class AccessTokenSource:
19
28
  def __init__(self, network_manager, client_id: str, client_secret: str) -> None:
20
29
  KameleoonLogger.debug(
21
30
  lambda: f"CALL: AccessTokenSource(network_manager, client_id: '{StringUtils.secret(client_id)}', "
22
- f"client_secret: '{StringUtils.secret(client_secret)}')"
31
+ f"client_secret: '{StringUtils.secret(client_secret)}')"
23
32
  )
24
33
  self._network_manager = network_manager
25
34
  self._client_id = client_id
26
35
  self._client_secret = client_secret
27
- self._cached_token: Optional[AccessTokenSource.ExpiringToken] = None
28
- self._fetching = False
29
36
  self._basic_authorization_token = AccessTokenSource.construct_basic_token(client_id, client_secret)
37
+ # Written by the fetching coroutine, read by callers on any thread / event loop.
38
+ self._cached_token: Optional[AccessTokenSource.ExpiringToken] = None
39
+ self._silent_after_fetch_failure_until = 0.0 # 0 = never silenced
40
+ # In-flight fetch shared by all concurrent callers; `None` when idle. A `concurrent.futures.Future` (not an
41
+ # asyncio one) because callers may run on different event loops. Guarded by `_lock`.
42
+ self._fetching: Optional[concurrent.futures.Future[Optional[str]]] = None
43
+ self._lock = threading.Lock()
30
44
  KameleoonLogger.debug(
31
45
  lambda: f"RETURN: AccessTokenSource(network_manager, client_id: '{StringUtils.secret(client_id)}', "
32
- f"client_secret: '{StringUtils.secret(client_secret)}')"
46
+ f"client_secret: '{StringUtils.secret(client_secret)}')"
33
47
  )
34
48
 
35
49
  @staticmethod
@@ -39,73 +53,97 @@ class AccessTokenSource:
39
53
  return "Basic " + base64.b64encode(basic_token_content.encode("utf-8")).decode("utf-8")
40
54
 
41
55
  async def get_token(self, timeout: Optional[float] = None) -> Optional[str]:
42
- """Returns the access token. Fetches a new token if the cached one is expired or obsolete."""
56
+ """
57
+ Returns the access token. Fetches a new token if the cached one is expired or obsolete.
58
+
59
+ Returns `None` without a request during the silence period which follows a fetch failure.
60
+ """
43
61
  KameleoonLogger.debug("CALL: AccessTokenSource.get_token(timeout: %s)", timeout)
44
62
  now = time.time()
45
63
  token = self._cached_token
46
- if (token is None) or token.is_expired(now):
47
- return await self._call_fetch_token(timeout)
48
- if not self._fetching and token.is_obsolete(now):
49
- self._run_fetch_token()
50
- value = token.value
51
- KameleoonLogger.debug("RETURN: AccessTokenSource.get_token(timeout: %s) -> (token: %s)",
52
- timeout, token)
53
- return value
64
+ silent = now < self._silent_after_fetch_failure_until
65
+ if (token is not None) and not token.is_expired(now):
66
+ if token.is_obsolete(now) and not silent:
67
+ self._fetch_token(timeout) # refresh in background, keep serving the current token
68
+ result = token.value
69
+ elif silent:
70
+ result = None
71
+ else:
72
+ result = await asyncio.wrap_future(self._fetch_token(timeout))
73
+ KameleoonLogger.debug(lambda: f"RETURN: AccessTokenSource.get_token(timeout: {timeout}) -> "
74
+ f"(token: '{StringUtils.secret(result)}')")
75
+ return result
54
76
 
55
77
  def discard_token(self, token: str) -> None:
56
78
  """Discards the specified token, if it matches the cached token."""
57
- KameleoonLogger.debug("CALL: AccessTokenSource.discard_token(token: %s)", token)
79
+ KameleoonLogger.debug(lambda: f"CALL: AccessTokenSource.discard_token(token: '{StringUtils.secret(token)}')")
58
80
  cached_token = self._cached_token
59
81
  if cached_token and (cached_token.value == token):
60
82
  self._cached_token = None
61
- KameleoonLogger.debug("RETURN: AccessTokenSource.discard_token(token: %s)", token)
83
+ KameleoonLogger.debug(lambda: f"RETURN: AccessTokenSource.discard_token(token: '{StringUtils.secret(token)}')")
62
84
 
63
- async def _call_fetch_token(self, timeout: Optional[float]) -> Optional[str]:
85
+ def _fetch_token(self, timeout: Optional[float]) -> concurrent.futures.Future[Optional[str]]:
86
+ """Starts a fetch unless one is already in flight; returns the future shared by all waiters."""
87
+ with self._lock:
88
+ if self._fetching is not None:
89
+ return self._fetching
90
+ future: concurrent.futures.Future[Optional[str]] = concurrent.futures.Future()
91
+ # A waiter that is cancelled (`asyncio.wrap_future` forwards cancellation) must not cancel the shared
92
+ # fetch: a running future ignores `cancel()`, so the other waiters and `set_result()` are unaffected.
93
+ future.set_running_or_notify_cancel()
94
+ self._fetching = future
64
95
  try:
65
- self._fetching = True
66
- return await self._fetch_token(timeout)
96
+ asyncio.get_running_loop().create_task(self._perform_fetch(future, timeout))
67
97
  except Exception as exception: # pylint: disable=W0703
68
- KameleoonLogger.error("Failed to call access token fetching: %s", exception)
69
- self._fetching = False
70
- return None
98
+ self._on_fetch_done(future, None, exception)
99
+ return future
71
100
 
72
- def _run_fetch_token(self) -> None:
73
- try:
74
- self._fetching = True
75
- asyncio.create_task(self._fetch_token())
76
- except Exception as exception: # pylint: disable=W0703
77
- KameleoonLogger.error("Failed to run access token fetching: %s", exception)
78
- self._fetching = False
79
-
80
- async def _fetch_token(self, timeout: Optional[float] = None) -> Optional[str]:
81
- KameleoonLogger.debug("CALL: AccessTokenSource._fetch_token(timeout: %s)", timeout)
101
+ async def _perform_fetch(self, future: concurrent.futures.Future[Optional[str]], timeout: Optional[float]) -> None:
102
+ KameleoonLogger.debug("CALL: AccessTokenSource._perform_fetch(timeout: %s)", timeout)
103
+ token: Optional[str] = None
104
+ error: Optional[Exception] = None
82
105
  try:
83
106
  service: AutomationService = self._network_manager.get_service(AutomationService)
84
107
  response = await service.fetch_access_jwtoken(self._basic_authorization_token, timeout)
85
- if not response.success:
86
- KameleoonLogger.error("Failed to fetch access JWT")
87
- return None
88
- if not isinstance(response.content, dict):
89
- KameleoonLogger.error("Failed to obtain proper access JWT")
90
- return None
91
- try:
92
- jwt = response.content
93
- token = jwt[self._JWT_ACCESS_TOKEN_FIELD]
94
- expires_in = jwt[self._JWT_EXPIRES_IN_FIELD]
95
- except Exception as exception: # pylint: disable=W0703
96
- KameleoonLogger.error("Failed to parse access JWT: %s", exception)
97
- return None
98
- if not (isinstance(token, str) and token and isinstance(expires_in, int) and expires_in):
99
- KameleoonLogger.error("Failed to read access JWT")
100
- return None
101
- self._handle_fetched_token(token, expires_in)
102
- KameleoonLogger.debug("RETURN: AccessTokenSource._fetch_token(timeout: %s) -> (token: %s)",
103
- timeout, token)
104
- return token
108
+ token, expires_in = self._parse_response(response)
109
+ self._cached_token = self._new_expiring_token(token, expires_in)
110
+ KameleoonLogger.info("Fetched access token")
111
+ except Exception as exception: # pylint: disable=W0703
112
+ error = exception
105
113
  finally:
106
- self._fetching = False
114
+ # Also runs on cancellation (e.g. the event loop shutting down), so waiters are never left pending.
115
+ self._on_fetch_done(future, token, error)
116
+ KameleoonLogger.debug(lambda: f"RETURN: AccessTokenSource._perform_fetch(timeout: {timeout}) -> "
117
+ f"(token: '{StringUtils.secret(token)}')")
118
+
119
+ def _on_fetch_done(
120
+ self, future: concurrent.futures.Future[Optional[str]], token: Optional[str], error: Optional[Exception]
121
+ ) -> None:
122
+ if error is not None:
123
+ self._silent_after_fetch_failure_until = time.time() + self._SILENCE_PERIOD
124
+ KameleoonLogger.warning(
125
+ "Failed to fetch access token (%s); it will not be requested for %ds", error, int(self._SILENCE_PERIOD)
126
+ )
127
+ # Clear before completing so waiters that re-enter get_token() can start a new fetch.
128
+ with self._lock:
129
+ self._fetching = None
130
+ future.set_result(token)
107
131
 
108
- def _handle_fetched_token(self, token: str, expires_in: int) -> None:
132
+ def _parse_response(self, response: Response) -> Tuple[str, int]:
133
+ if not response.success:
134
+ raise ValueError(f"access token request failed: {response}")
135
+ jwt = response.content
136
+ if not isinstance(jwt, dict):
137
+ raise ValueError(f"access token response is not a JSON object: {response}")
138
+ expires_in = jwt.get(self._JWT_EXPIRES_IN_FIELD)
139
+ if not (isinstance(expires_in, int) and (expires_in > 0)):
140
+ raise ValueError(f"access token response has no valid '{self._JWT_EXPIRES_IN_FIELD}' field")
141
+ token = jwt.get(self._JWT_ACCESS_TOKEN_FIELD)
142
+ if not (isinstance(token, str) and token):
143
+ raise ValueError(f"access token response has no '{self._JWT_ACCESS_TOKEN_FIELD}' field")
144
+ return token, expires_in
145
+
146
+ def _new_expiring_token(self, token: str, expires_in: int) -> AccessTokenSource.ExpiringToken:
109
147
  now = time.time()
110
148
  exp_time = now + expires_in - self._TOKEN_EXPIRATION_GAP
111
149
  if expires_in > self._TOKEN_OBSOLESCENCE_GAP:
@@ -113,17 +151,16 @@ class AccessTokenSource:
113
151
  else:
114
152
  obs_time = exp_time
115
153
  if expires_in <= self._TOKEN_EXPIRATION_GAP:
116
- KameleoonLogger.error(
117
- "Access token life time (%ss) is not long enough to cache the token", expires_in
118
- )
154
+ KameleoonLogger.error("Access token life time (%ss) is not long enough to cache the token", expires_in)
119
155
  else:
120
156
  KameleoonLogger.warning(
121
- 'Access token life time (%ss) is not long enough to refresh cached token in background', expires_in
157
+ "Access token life time (%ss) is not long enough to refresh cached token in background", expires_in
122
158
  )
123
- self._cached_token = self.ExpiringToken(token, exp_time, obs_time)
159
+ return self.ExpiringToken(token, exp_time, obs_time)
124
160
 
125
161
  class ExpiringToken:
126
162
  """Expiring Token"""
163
+
127
164
  def __init__(self, value: str, expiration_time: float, obsolescence_time: float) -> None:
128
165
  self.value = value
129
166
  self.expiration_time = expiration_time
@@ -1,6 +1,7 @@
1
1
  """Network"""
2
2
  from enum import Enum
3
3
  from typing import Any, Dict, Optional
4
+ from kameleoon.helpers.string_utils import StringUtils
4
5
 
5
6
 
6
7
  class ResponseContentType(Enum):
@@ -67,7 +68,7 @@ class Request:
67
68
  body = self.body
68
69
 
69
70
  headers = [
70
- f"{name}:{'***' if name == 'Authorization' else value}"
71
+ f"{name}:{StringUtils.secret(value) if name == 'Authorization' else value}"
71
72
  for name, value in (self.headers or {}).items()
72
73
  ]
73
74
 
@@ -6,6 +6,7 @@ from typing import Any, Coroutine
6
6
 
7
7
  from kameleoon.events.http_request_failure import HttpRequestFailure
8
8
  from kameleoon.events.request_type import RequestType
9
+ from kameleoon.helpers.string_utils import StringUtils
9
10
  from kameleoon.logging.kameleoon_logger import KameleoonLogger
10
11
  from kameleoon.logging.log_level import LogLevel
11
12
  from kameleoon.network.network_manager import NetworkManager
@@ -70,7 +71,8 @@ class ServiceImpl(Service):
70
71
  response.content,
71
72
  )
72
73
  if request.access_token and (response.code == 401):
73
- KameleoonLogger.warning("Unexpected rejection of access token %s", request.access_token)
74
+ KameleoonLogger.warning("Unexpected rejection of access token '%s'",
75
+ StringUtils.secret(request.access_token))
74
76
  access_token_source.discard_token(request.access_token)
75
77
  if attempt == retry_limit:
76
78
  try_access_token_auth = False
kameleoon/sdk_version.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Kameleoon SDK version"""
2
2
 
3
- version_info = (3, 22, 0)
3
+ version_info = (3, 22, 2)
4
4
  __version__ = ".".join(str(v) for v in version_info) # uses in scripts
5
5
 
6
6
 
@@ -23,7 +23,12 @@ class BrowserCondition(TargetingCondition):
23
23
  else BrowserType.INTERNET_EXPLORER
24
24
  )
25
25
  self.__version = json_condition.get("version", None)
26
- self.__operator = TargetingOperator[str(json_condition.get("versionMatchType", None))]
26
+ # Version is optional; the operator is only meaningful when it's set
27
+ self.__operator = (
28
+ TargetingOperator.from_string(json_condition.get("versionMatchType", None))
29
+ if self.__version is not None
30
+ else TargetingOperator.UNKNOWN
31
+ )
27
32
  except KeyError as exeception:
28
33
  KameleoonLogger.error("%s has wrong JSON structure: %s", self.__class__, exeception)
29
34
 
@@ -1,6 +1,9 @@
1
1
  """ Constants """
2
2
 
3
3
  from enum import Enum
4
+ from typing import Any
5
+
6
+ from kameleoon.logging.kameleoon_logger import KameleoonLogger
4
7
 
5
8
 
6
9
  class TargetingOperator(Enum):
@@ -17,3 +20,13 @@ class TargetingOperator(Enum):
17
20
  FALSE = "FALSE"
18
21
  AMONG_VALUES = "AMONG_VALUES"
19
22
  ANY = "ANY"
23
+ UNKNOWN = "UNKNOWN"
24
+
25
+ @classmethod
26
+ def from_string(cls, raw: Any) -> "TargetingOperator":
27
+ """Parse an operator name from the configuration; unparsable values map to UNKNOWN."""
28
+ try:
29
+ return cls[str(raw).upper()]
30
+ except KeyError:
31
+ KameleoonLogger.error("Unexpected TargetingOperator: %s", raw)
32
+ return cls.UNKNOWN
@@ -19,11 +19,12 @@ class VersionCondition(TargetingCondition):
19
19
  def __init__(self, json_condition: Dict[str, Union[str, int, Any]]):
20
20
  super().__init__(json_condition)
21
21
  self._condition_version = json_condition.get("version", None)
22
- try:
23
- self.__operator = TargetingOperator[str(json_condition.get("versionMatchType", None))]
24
- except KeyError as ex:
25
- KameleoonLogger.error("%s has wrong JSON structure: %s", self.__class__, ex)
26
- self.__operator = TargetingOperator.UNDEFINED
22
+ # Version may be optional (e.g. SDK_LANGUAGE); the operator is only meaningful when it's set
23
+ self.__operator = (
24
+ TargetingOperator.from_string(json_condition.get("versionMatchType", None))
25
+ if self._condition_version is not None
26
+ else TargetingOperator.UNKNOWN
27
+ )
27
28
  self.__cached_sem_version: Optional[SemVersion] = None
28
29
 
29
30
  def check(self, data: Any) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kameleoon-client-python
3
- Version: 3.22.0
3
+ Version: 3.22.2
4
4
  Summary: Kameleoon Client Python Software Development Kit.
5
5
  Home-page: https://developers.kameleoon.com/python-sdk.html
6
6
  Author: Kameleoon
@@ -20,27 +20,27 @@ Requires-Dist: requests<3.0.0,>=2.25
20
20
  Requires-Dist: sseclient-py<1.9.0,>=1.7.0
21
21
  Requires-Dist: aiohttp<4.0.0,>=3.8.0
22
22
  Provides-Extra: test
23
- Requires-Dist: types-requests==2.25.0; extra == "test"
24
- Requires-Dist: Django==3.2.4; extra == "test"
25
23
  Requires-Dist: sseclient-py<1.9.0,>=1.7.0; extra == "test"
26
- Requires-Dist: pytest-django==4.4.0; extra == "test"
27
- Requires-Dist: pytest-pythonpath==0.7.3; extra == "test"
28
- Requires-Dist: pytest-ordering==0.6; extra == "test"
29
- Requires-Dist: aiohttp<4.0.0,>=3.8.0; extra == "test"
30
- Requires-Dist: flake8==4.0.1; extra == "test"
31
- Requires-Dist: pytest-asyncio==0.21.*; extra == "test"
32
24
  Requires-Dist: pytest-cov==2.12.1; extra == "test"
33
- Requires-Dist: mypy==0.991; extra == "test"
25
+ Requires-Dist: pytest-django==4.5.2; extra == "test"
34
26
  Requires-Dist: pylint==2.12.*; extra == "test"
35
- Requires-Dist: Werkzeug==2.0.1; extra == "test"
36
- Requires-Dist: pyyaml<6.1.0,>=5.4.0; extra == "test"
37
- Requires-Dist: freezegun==1.1.0; extra == "test"
38
- Requires-Dist: twine==3.4.1; extra == "test"
39
- Requires-Dist: types-PyYAML==5.4.3; extra == "test"
40
- Requires-Dist: requests<3.0.0,>=2.25; extra == "test"
41
27
  Requires-Dist: wheel==0.36.2; extra == "test"
28
+ Requires-Dist: types-PyYAML==5.4.3; extra == "test"
29
+ Requires-Dist: types-requests==2.25.0; extra == "test"
42
30
  Requires-Dist: types-python-dateutil==2.8.2; extra == "test"
31
+ Requires-Dist: aiohttp<4.0.0,>=3.8.0; extra == "test"
32
+ Requires-Dist: Django==4.2.*; extra == "test"
43
33
  Requires-Dist: aioresponses==0.7.*; extra == "test"
34
+ Requires-Dist: freezegun==1.1.0; extra == "test"
35
+ Requires-Dist: pytest-asyncio==0.21.*; extra == "test"
36
+ Requires-Dist: Werkzeug==2.0.1; extra == "test"
37
+ Requires-Dist: mypy==0.991; extra == "test"
38
+ Requires-Dist: twine==3.4.1; extra == "test"
39
+ Requires-Dist: pyyaml<6.1.0,>=5.4.0; extra == "test"
40
+ Requires-Dist: flake8==4.0.1; extra == "test"
41
+ Requires-Dist: pytest-pythonpath==0.7.3; extra == "test"
42
+ Requires-Dist: requests<3.0.0,>=2.25; extra == "test"
43
+ Requires-Dist: pytest-ordering==0.6; extra == "test"
44
44
  Dynamic: author
45
45
  Dynamic: author-email
46
46
  Dynamic: classifier
@@ -5,7 +5,7 @@ kameleoon/kameleoon_client.py,sha256=Gg1NTcI0JUmYKPFYDRhJSG_Z2fxKaO8ECZ7R0yRgffA
5
5
  kameleoon/kameleoon_client_config.py,sha256=DJm--TtaTPc_09q3uv8XjHrX_q7C-sh0tf65lL2TxCw,11181
6
6
  kameleoon/kameleoon_client_factory.py,sha256=9_VXYnfZ0A5GNY6Y3j9aCfUV2uc-_-SQhslU3uUoeSQ,3228
7
7
  kameleoon/middleware.py,sha256=gwbt2YK0D6Uvmm3M7WqkTdwUIZEA85Use39Tt3oVeus,1584
8
- kameleoon/sdk_version.py,sha256=ncxEjKQ9_WR9SvDwaE9MGrPqwCzrZaFdPEX__Vce448,289
8
+ kameleoon/sdk_version.py,sha256=7ZsKymIDnKmWCSJ-FIVDMmzAxUjdAcZfrkndtWxSG08,289
9
9
  kameleoon/client_readiness/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  kameleoon/client_readiness/async_readiness.py,sha256=NTzreGNJ9jOdpia0gAnkOwjn14Vu6m53l7QC_eJQ5ig,1569
11
11
  kameleoon/client_readiness/threading_readiness.py,sha256=bpld4cxl4H6ZIvJxomU_Qmhii2ru7fRK1yzYp2SQhTw,5794
@@ -49,7 +49,7 @@ kameleoon/data/manager/forced_variation.py,sha256=k4_86h5O4_S-bqzfKIS3iU4xRCMXTl
49
49
  kameleoon/data/manager/legal_consent.py,sha256=5ymyDPywLetYa8e7nrgYp6Ds8j9qrqRpuCSg995gPNc,160
50
50
  kameleoon/data/manager/page_view_visit.py,sha256=ghn7zPSFtDyGpkwT4zEFKiMsJpXhicgmJsTSOnzcank,1730
51
51
  kameleoon/data/manager/visitor.py,sha256=YBXbQ7kMYEj38fikOuxfiXFde6nDowUmJjnW2e4aE2Q,24786
52
- kameleoon/data/manager/visitor_manager.py,sha256=oqOLp1VBI51v38Jb4XM9NqJq01X1rIZ7jk-qRrv94Pw,9167
52
+ kameleoon/data/manager/visitor_manager.py,sha256=dbXYE4mCJx0bU-ZErOXdL6UXdjGWGL_q6DJyIFH7Q70,9207
53
53
  kameleoon/data/manager/visitor_slot.py,sha256=zf3Ov38CQrR5lS6oJ4Ehbxma2heBGNtUvRohEj1k7sY,528
54
54
  kameleoon/events/__init__.py,sha256=LGBoX1JDZr6dPpsqlUmYE5jN4i9L_GdQif7bkQOL3Hs,751
55
55
  kameleoon/events/data_file_update_event.py,sha256=caM6BSpMhMU1itXAIsAnVLsUbH-IDcL5HJy0UtF2yWI,1141
@@ -65,12 +65,12 @@ kameleoon/helpers/domain.py,sha256=muSkEMF-jbIzrHvwJQNB9XHZGQ18tluc1kNRGgjC0bU,2
65
65
  kameleoon/helpers/functions.py,sha256=JEjReWV9u9muD6aGZwiwUjBMl8UPHn5CfiGmN8zxkfc,1401
66
66
  kameleoon/helpers/hasher.py,sha256=CR0qaaD1y_AXGyCxiJZuuNOT8VjoXsoQhoXJMaNdgAM,945
67
67
  kameleoon/helpers/logger.py,sha256=BHugg5UDUlKiPVMIU1x4X4oex1DK_9XUcaf8H7wSp2Y,1100
68
- kameleoon/helpers/multi_threading.py,sha256=QtNKJww0UwFi5xF1jvGFNl3O3palhigm6d3QUcPb5k0,4363
68
+ kameleoon/helpers/multi_threading.py,sha256=Em1DeYRH5Vx7GUSv5m6sdAxc43za3WSijUahROS1iHY,5491
69
69
  kameleoon/helpers/nonce.py,sha256=QmdCkHvzcdwY2yC7XsHzVLjUKxKdSyvgG1fi8Xz8Xh8,254
70
70
  kameleoon/helpers/repeat_timer.py,sha256=S8nC4jbK5z6C8on-FJ_CN8kZynBN22PsPcgaO8gOMwc,412
71
- kameleoon/helpers/scheduler.py,sha256=mwV3jDSoKk7omcvMA4f1RGv2fgqMCdQqUApRVcrTk4E,3376
71
+ kameleoon/helpers/scheduler.py,sha256=8VFnX8WpWItcuO1fDBZKV150wcR0jm5UM0nwqzcaKbw,4149
72
72
  kameleoon/helpers/sem_version.py,sha256=BGqUkPo5jx0nTgr7m_paIl0l8xCgi_zFsiS1D4gzNDo,1976
73
- kameleoon/helpers/string_utils.py,sha256=HAB0S9HlIT2v4fI3JZOqFfc1JewwDi-jZxIosiv0F4A,1886
73
+ kameleoon/helpers/string_utils.py,sha256=g2ofE2oF_04JsbG43xDS3clrVuxZIPq1cMKqeSqRthM,1855
74
74
  kameleoon/helpers/time.py,sha256=E7279NV9pDuYqVesIIrEhixMviKEoNhDLO9XQXNaWdk,486
75
75
  kameleoon/helpers/visitor_code.py,sha256=boya6fmyixNaOwdvxDzt91C7-XN8T9T_0mm3C5JQGIA,745
76
76
  kameleoon/hybrid/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -92,15 +92,15 @@ kameleoon/managers/remote_data/remote_data_manager.py,sha256=UOAmAo8U9-H_RDm8czz
92
92
  kameleoon/managers/remote_data/remote_visitor_data.py,sha256=8uZnwRt2zvhHhTA54-Y8IUvAve7sx7TGAf4x9mYX7fQ,13884
93
93
  kameleoon/managers/tracking/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
94
94
  kameleoon/managers/tracking/tracking_builder.py,sha256=SjKDbDBpRrCdafFb1o-o8lyqaFd_URbwDTi8toHBKBU,7540
95
- kameleoon/managers/tracking/tracking_manager.py,sha256=EyO5VJo8RMB96zgbDrOt7Us7Q2PVz2UCVkIP9SivBh8,5214
96
- kameleoon/managers/tracking/visitor_tracking_registry.py,sha256=5-4JQysILdSK6ajHWdv9A60ov8vaPtp9sV8emS7w4tk,3523
95
+ kameleoon/managers/tracking/tracking_manager.py,sha256=nOzqNHXpmEhTLOXRLIfwOSJHIdbTic0_-RLDXy1tEPY,7154
96
+ kameleoon/managers/tracking/visitor_tracking_registry.py,sha256=zTyOQtj3mFk1GUl9aSoT2Hv1EPcGrvGcWprKBg3y7fg,4834
97
97
  kameleoon/managers/warehouse/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
98
  kameleoon/managers/warehouse/warehouse_manager.py,sha256=9HFzc_B2EGWxipP7XIj9-1-P3cA-ksI7lBUB4PGP_wg,3351
99
99
  kameleoon/network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
100
- kameleoon/network/access_token_source.py,sha256=Yx-f4Gi23IDSYo67bGkw-STWIUQq91UuZEm5r40Bc9M,6587
100
+ kameleoon/network/access_token_source.py,sha256=8ZrGA9TTBolti-_sCVTpQTHch9sC0PMVdHt-rszYvwU,9040
101
101
  kameleoon/network/access_token_source_factory.py,sha256=7k07mpFjkeTUIsOmchsIHd6-o9hzo4yvl99hmHPoQuc,605
102
102
  kameleoon/network/activity_event.py,sha256=acutjP-03lOQlJF_olCrQwNLuR3JaIZnA0-MAt5EiVE,425
103
- kameleoon/network/net_provider.py,sha256=1DbPInqVjtm79MmHvk2qkDrfrep992q98uMX9bbeNc0,2498
103
+ kameleoon/network/net_provider.py,sha256=skL7Q4_mNeRrBQ016zCSxtW5cHJaLvMrdrNm1pU80hU,2573
104
104
  kameleoon/network/net_provider_impl.py,sha256=nP1Lz3LgxPBqQuML9BtaY7ZzXwSdsQ1zm5tLwJHFaT0,3053
105
105
  kameleoon/network/network_manager.py,sha256=OoE0PaKIDmsJbZV0F9KKroPHOWJGIiKx2ejkrHB5z6Y,1888
106
106
  kameleoon/network/network_manager_factory.py,sha256=SAaiNF_Fw6xoyOCZ3y9Wu3BLZwW74JsBmkTSIJhtilw,850
@@ -123,23 +123,18 @@ kameleoon/network/services/configuration_service_impl.py,sha256=-xFKFtkhd0Hk_nOn
123
123
  kameleoon/network/services/data_service.py,sha256=wWrb98JmlNCILvmG3JZPqdc8Sp5aUsMrDWaABvA0iKk,1098
124
124
  kameleoon/network/services/data_service_impl.py,sha256=1BegpK7towoXCy5eqztFfG0XeNrtgjoSiQz219saLDA,3859
125
125
  kameleoon/network/services/service.py,sha256=G2paqnw_1XjCC_eCbuOq9MWe0RqScIQMbnJUJj3ehtk,192
126
- kameleoon/network/services/service_impl.py,sha256=X1SSP-RMuib-fi7hsUWk8_OXFxKKuUaGtjZSkZFSFco,5344
126
+ kameleoon/network/services/service_impl.py,sha256=7DIpo9_BX_s6usZqVDlT_Ao3FBdzVQgmQ9Gk940Ue-U,5465
127
127
  kameleoon/real_time/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
128
128
  kameleoon/real_time/real_time_event.py,sha256=84d1wcd34ZbctjWFa1EIt8LdDSNt6c49xZA5NKQn-IA,548
129
129
  kameleoon/real_time/real_time_event_service.py,sha256=NOyQqs9704afYIIgLpsFcue1riFc6cbrTOOFIbOmbmQ,3060
130
- kameleoon/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
131
- kameleoon/storage/cache.py,sha256=a9p7DlmFPFVBM_RCYOEqomf5ieyd9wcPZcfiPFplJ5U,1902
132
- kameleoon/storage/cache_factory.py,sha256=joERiQmfKc0fSmgQsiTEH8SbJ20TekeG79UZnW45yW4,571
133
- kameleoon/storage/cache_factory_impl.py,sha256=HU-7Y8OeUD0078KasR3Qru2HeP4gkRtCHtwMVzgB_d8,356
134
- kameleoon/storage/cache_impl.py,sha256=8aGhq2Q2InKvY0UMpReVlt0tQZiwAA1HRxc6xmLGb9I,1058
135
130
  kameleoon/targeting/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
136
131
  kameleoon/targeting/models.py,sha256=21QikRumqRRlimD_qGnr9xzuFyLcCii3b398F7wf10Q,1018
137
132
  kameleoon/targeting/targeting_manager.py,sha256=IH1LcAptfCB4KhcWrHWROzE1aXCG5eElcgt_UGf96qI,7722
138
133
  kameleoon/targeting/tree_builder.py,sha256=Np7b4aIUN_Sgt87hWTXRNkMImCLCbzxe36dCmk6tpTc,5622
139
134
  kameleoon/targeting/tree_condition_factory.py,sha256=FbAqnJ4Y1wQ-pxH4tBLbVOd2hbU-9LMplUvWM7vCX78,6366
140
135
  kameleoon/targeting/conditions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
141
- kameleoon/targeting/conditions/browser_condition.py,sha256=_Zq8SLvXDopRCDm0ucBIcP_ynI0991Q9xIeqkOGkVvU,2605
142
- kameleoon/targeting/conditions/constants.py,sha256=3ospn-l0fVyNrrpsGZFfjW9uLIvsKOT6yCFttxVWy-I,389
136
+ kameleoon/targeting/conditions/browser_condition.py,sha256=_zgC6KrK5gFY4aQHcFkOdlNPK8TclNJwE-94_9hfISA,2818
137
+ kameleoon/targeting/conditions/constants.py,sha256=_m6M_HjKHFna-LbH1M3dWFaRUzzRN6h73Whl-VQJk04,857
143
138
  kameleoon/targeting/conditions/conversion_condition.py,sha256=l4lju0HlkVD82neDZehGhCaBqr7uaNOzq3S51MiYoNI,1707
144
139
  kameleoon/targeting/conditions/cookie_condition.py,sha256=iZsQKC6L1RD9jnQMOmEfEohPR9Ek5s4ON0SJCmpb7Cg,2714
145
140
  kameleoon/targeting/conditions/custom_datum.py,sha256=H8SDA6sXAANFKmToasPzxibGRv8vB2Z8dRPjLCZCRWo,4020
@@ -162,7 +157,7 @@ kameleoon/targeting/conditions/target_personalization_condition.py,sha256=42fm_F
162
157
  kameleoon/targeting/conditions/targeting_condition.py,sha256=QaGrJAaoGLQKQagHOUmdjGV6cDySl9O7KI_IdjOyQnU,1986
163
158
  kameleoon/targeting/conditions/time_elapsed_since_visit_condition.py,sha256=xm2nnppMd5EEAD9-KqQLXGO838u1Srm6HROvgNaLErc,1161
164
159
  kameleoon/targeting/conditions/unknown_condition.py,sha256=Hw2-ncD6bSSqQutiz_2uSqJNQMgwzh82k-UiVnJN8ag,551
165
- kameleoon/targeting/conditions/version_condition.py,sha256=hmgAgxZNOccatjS5uvo9QkxjYn0-AEhLD_hPGqVIolk,2733
160
+ kameleoon/targeting/conditions/version_condition.py,sha256=YUcP4ZDcNz2hYfdB-7uB0TgtwZ08LOYxe5X_eiNDuTE,2764
166
161
  kameleoon/targeting/conditions/visit_number_today_condition.py,sha256=Zgm4f_YUCBbbItanK0ZVDnYDOU-gOvkJGq-XB0wXDMQ,1769
167
162
  kameleoon/targeting/conditions/visit_number_total_condition.py,sha256=eKTBGNPsbwJh4_uayWbdSZxFFK5Cu5vzNjKV9Uilwcg,764
168
163
  kameleoon/targeting/conditions/visitor_code_condition.py,sha256=03q8MeXc4y1FhHyA_7rCOmDjN7iDka-sOUfhNDTDrCg,415
@@ -175,7 +170,7 @@ kameleoon/types/remote_visitor_data_filter.py,sha256=YqInU1WvVwdBC9cTLhVXK7JYLic
175
170
  kameleoon/types/rule.py,sha256=RrnZA_nkIbvzfUSUcuKCqxqs48aVVk6WhlGfg2WrPns,1307
176
171
  kameleoon/types/variable.py,sha256=txEU9O07WPQOz6NjRIibyJWQ_mFexyqRa8YaR6W6eSE,833
177
172
  kameleoon/types/variation.py,sha256=vrgQWDeDc1FvsG5_O7CfsB5Xl4QhiKgKsWQMyoF1BkQ,2771
178
- kameleoon_client_python-3.22.0.dist-info/METADATA,sha256=xXeuoFLIFNsR5D9subJbSLKmt_Jt0nyNodgl0MGebfM,13195
179
- kameleoon_client_python-3.22.0.dist-info/WHEEL,sha256=AeO2BvogYWm3eGaHCvhzmUYt8ia7KfURiHzO_1atlys,109
180
- kameleoon_client_python-3.22.0.dist-info/top_level.txt,sha256=i6mIQRivSZ-iWKzQ7WnfUr-c19Hkt0uJR_PKO53yZEc,10
181
- kameleoon_client_python-3.22.0.dist-info/RECORD,,
173
+ kameleoon_client_python-3.22.2.dist-info/METADATA,sha256=5yUAN6CSAN7zbIPgZEcJxK9p-qPsCaxS03iKdDuM9VM,13195
174
+ kameleoon_client_python-3.22.2.dist-info/WHEEL,sha256=AeO2BvogYWm3eGaHCvhzmUYt8ia7KfURiHzO_1atlys,109
175
+ kameleoon_client_python-3.22.2.dist-info/top_level.txt,sha256=i6mIQRivSZ-iWKzQ7WnfUr-c19Hkt0uJR_PKO53yZEc,10
176
+ kameleoon_client_python-3.22.2.dist-info/RECORD,,
File without changes
@@ -1,67 +0,0 @@
1
- """ Kameleoon Visitor Variation """
2
-
3
- from typing import TypeVar, Generic, Optional
4
-
5
-
6
- K = TypeVar("K")
7
- V = TypeVar("V")
8
-
9
-
10
- class Cache(Generic[K, V]):
11
- """
12
- Interface `Cache` represents a container type that is supposed to store relevant values by distinct keys
13
- and to manage relevance of the stored values and is not supposed to be thread safe.
14
- """
15
-
16
- def __init__(self, expiration_period: float):
17
- if expiration_period <= 0.0:
18
- raise ValueError("'expiration_period' arg must have positive value!")
19
- self._expiration_period = expiration_period
20
-
21
- @property
22
- def expiration_period(self) -> float:
23
- """
24
- Period during which a stored value is considered as relevant.
25
- :return: Expiration period
26
- :rtype: float
27
- """
28
- return self._expiration_period
29
-
30
- def __len__(self) -> int:
31
- raise NotImplementedError()
32
-
33
- def set(self, key: K, value: V) -> None:
34
- """
35
- If item stored by specified key exists, this method updates its value and its relevance,
36
- otherwise this method creates new relevant item with specified key and value.
37
- :param key: Key
38
- :type key: K
39
- :param value: New value
40
- :type value: V
41
- """
42
- raise NotImplementedError()
43
-
44
- def get(self, key: K) -> Optional[V]:
45
- """
46
- Makes an attempt to get value of item stored by specified key.
47
- :param key: Key
48
- :type key: K
49
- :return: Stored value or None if operation fails
50
- :rtype: Optional[V]
51
- """
52
- raise NotImplementedError()
53
-
54
- def clear(self) -> None:
55
- """
56
- Removes all stored values.
57
- """
58
- raise NotImplementedError()
59
-
60
- def purge(self) -> None:
61
- """
62
- Removes all expired items.
63
- """
64
- raise NotImplementedError()
65
-
66
- def __iter__(self):
67
- raise NotImplementedError()
@@ -1,17 +0,0 @@
1
- """ Kameleoon Visitor Variation """
2
-
3
- from .cache import Cache, K, V
4
-
5
-
6
- class CacheFactory:
7
- """`CacheFactory` is supposed to be used for creating instances of `Cache` type."""
8
-
9
- def create(self, expiration_period: float) -> Cache[K, V]:
10
- """
11
- Creates an instance of `Cache` type with specified expiration period.
12
- :param expiration_period: Expiration period of new instance of `Cache` type
13
- :type expiration_period: float
14
- :return: New instance of `Cache` type
15
- :rtype: Cache
16
- """
17
- raise NotImplementedError()
@@ -1,12 +0,0 @@
1
- """ Kameleoon Visitor Variation """
2
-
3
- from .cache import Cache, K, V
4
- from .cache_impl import CacheImpl
5
- from .cache_factory import CacheFactory
6
-
7
-
8
- class CacheFactoryImpl(CacheFactory):
9
- """`CacheFactoryImpl` implements `CacheFactory` interface."""
10
-
11
- def create(self, expiration_period: float) -> Cache[K, V]:
12
- return CacheImpl(expiration_period)
@@ -1,36 +0,0 @@
1
- """ Kameleoon Visitor Variation """
2
-
3
- import time
4
- from typing import Dict, Tuple, Optional
5
- from .cache import Cache, K, V
6
-
7
-
8
- class CacheImpl(Cache[K, V]):
9
- """`CacheImpl` implements `Cache` interface."""
10
-
11
- def __init__(self, expiration_period: float):
12
- super().__init__(expiration_period)
13
- self.__items: Dict[K, Tuple[V, float]] = {}
14
-
15
- def __len__(self) -> int:
16
- return len(self.__items)
17
-
18
- def set(self, key: K, value: V) -> None:
19
- expiration_ts = time.time() + self._expiration_period
20
- self.__items[key] = (value, expiration_ts)
21
-
22
- def get(self, key: K) -> Optional[V]:
23
- item = self.__items.get(key, None)
24
- return item[0] if item is not None else None
25
-
26
- def clear(self) -> None:
27
- self.__items.clear()
28
-
29
- def purge(self) -> None:
30
- now = time.time()
31
- to_be_removed = [k for k, v in self.__items.items() if v[1] <= now]
32
- for key in to_be_removed:
33
- del self.__items[key]
34
-
35
- def __iter__(self):
36
- return ((k, v[0]) for k, v in self.__items.items())