""" Endobest Dashboard - Utility Functions Module This module contains generic utility functions used throughout the Endobest Dashboard: - HTTP client management (thread-safe) - Nested data structure navigation with wildcard support - Configuration path resolution (script vs PyInstaller) - Thread position management for progress bars - Filename generation utilities """ import os import sys import threading import httpx from eb_dashboard_constants import CONFIG_FOLDER_NAME # ============================================================================ # GLOBAL VARIABLES (managed by main module) # ============================================================================ thread_local_storage = threading.local() # These will be set/accessed from the main module httpx_clients = {} _clients_lock = threading.Lock() threads_list = [] _threads_list_lock = threading.Lock() # ============================================================================ # HTTP CLIENT MANAGEMENT # ============================================================================ def get_httpx_client() -> httpx.Client: """ Get or create thread-local HTTP client. Keep-alive is disabled to avoid stale connections with load balancers. """ global httpx_clients thread_id = threading.get_ident() with _clients_lock: if thread_id not in httpx_clients: # Create client with keep-alive disabled httpx_clients[thread_id] = httpx.Client( headers={"Connection": "close"}, # Explicitly request closing limits=httpx.Limits(max_keepalive_connections=0, max_connections=100) ) return httpx_clients[thread_id] def clear_httpx_client(): """ Removes the current thread's client from the cache. Ensures a fresh client (and socket pool) will be created on the next call. """ global httpx_clients thread_id = threading.get_ident() with _clients_lock: if thread_id in httpx_clients: try: # Close the client before removing it httpx_clients[thread_id].close() except: pass del httpx_clients[thread_id] def get_thread_position(): """ Get the position of the current thread in the threads list. Used for managing progress bar positions in multithreaded environment. """ global threads_list thread_id = threading.get_ident() with _threads_list_lock: if thread_id not in threads_list: threads_list.append(thread_id) return len(threads_list) - 1 else: return threads_list.index(thread_id) # ============================================================================ # NESTED DATA NAVIGATION # ============================================================================ def get_nested_value(data_structure, path, default=None): """ Extracts a value from a nested structure of dictionaries and lists. Supports a wildcard '*' in the path to retrieve all elements from a list. Args: data_structure: The nested dict/list structure to navigate path: List of keys/indices to follow. Use '*' for list wildcard. default: Value to return if path not found Returns: The value at the end of the path, or default if not found Examples: get_nested_value({"a": {"b": 1}}, ["a", "b"]) -> 1 get_nested_value({"items": [{"x": 1}, {"x": 2}]}, ["items", "*", "x"]) -> [1, 2] """ if data_structure is None: return "$$$$ No Data" if not path: return default if "*" in path: wildcard_index = path.index("*") path_before = path[:wildcard_index] path_after = path[wildcard_index+1:] # Create a temporary function for non-wildcard path resolution def _get_simple_nested_value(ds, p, d): cl = ds for k in p: if isinstance(cl, dict): cl = cl.get(k) elif isinstance(cl, list): try: if isinstance(k, int) and -len(cl) <= k < len(cl): cl = cl[k] else: return d except (IndexError, TypeError): return d else: return d if cl is None: return d return cl base_level = _get_simple_nested_value(data_structure, path_before, default) if not isinstance(base_level, list): return default results = [] for item in base_level: # For each item, recursively call to resolve the rest of the path value = get_nested_value(item, path_after, default) if value is not default and value != "$$$$ No Data": results.append(value) # Flatten the results by one level to handle multiple wildcards final_results = [] for res in results: if isinstance(res, list): final_results.extend(res) else: final_results.append(res) return final_results # No wildcard, original logic (iterative) current_level = data_structure for key_or_index in path: if isinstance(current_level, dict): current_level = current_level.get(key_or_index) if current_level is None: return default elif isinstance(current_level, list): try: if isinstance(key_or_index, int) and -len(current_level) <= key_or_index < len(current_level): current_level = current_level[key_or_index] else: return default except (IndexError, TypeError): return default else: return default return current_level # ============================================================================ # CONFIGURATION UTILITIES # ============================================================================ def get_config_path(): """ Gets the correct path to the config folder. Works for both script execution and PyInstaller executable. Returns: Path to config folder """ if getattr(sys, 'frozen', False): # Running as a PyInstaller bundle config_folder = CONFIG_FOLDER_NAME return os.path.join(sys._MEIPASS, config_folder) else: # Running as a script return CONFIG_FOLDER_NAME def get_old_filename(current_filename, old_suffix="_old"): """Generate old backup filename from current filename. Example: "endobest_inclusions.json" → "endobest_inclusions_old.json" Args: current_filename: Current file name (e.g., "endobest_inclusions.json") old_suffix: Suffix to append before file extension (default: "_old") Returns: Old backup filename with suffix before extension """ name, ext = os.path.splitext(current_filename) return f"{name}{old_suffix}{ext}"