search-api-webui 0.1.7__py3-none-any.whl → 0.1.8__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.
@@ -17,4 +17,3 @@
17
17
  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
19
  # DEALINGS IN THE SOFTWARE.
20
-
search_api_webui/app.py CHANGED
@@ -19,25 +19,55 @@
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  import json
22
- import os
22
+ import socket
23
+ import sys
24
+ import threading
25
+ import time
23
26
  import webbrowser
24
27
  from pathlib import Path
25
- from flask import Flask, request, jsonify, send_from_directory
28
+
29
+ from flask import Flask, jsonify, request, send_from_directory
26
30
  from flask_cors import CORS
31
+
27
32
  from search_api_webui.providers import load_providers
28
33
 
29
- CURRENT_DIR = Path(__file__).resolve().parent
34
+ try:
35
+ import webview
36
+ WEBVIEW_AVAILABLE = True
37
+ except ImportError:
38
+ WEBVIEW_AVAILABLE = False
39
+
40
+
41
+ def get_resource_path(relative_path):
42
+ '''Get absolute path to resource, works for dev and for PyInstaller.'''
43
+ try:
44
+ # PyInstaller creates a temp folder and stores path in _MEIPASS
45
+ base_path = Path(sys._MEIPASS)
46
+ except Exception:
47
+ base_path = Path(__file__).resolve().parent
48
+
49
+ return base_path / relative_path
30
50
 
31
- STATIC_FOLDER = CURRENT_DIR / 'static'
32
- if not STATIC_FOLDER.exists():
33
- DEV_FRONTEND_DIST = CURRENT_DIR.parent / 'frontend' / 'dist'
34
- if DEV_FRONTEND_DIST.exists():
35
- STATIC_FOLDER = DEV_FRONTEND_DIST
36
51
 
37
- app = Flask(__name__, static_folder='static')
52
+ CURRENT_DIR = Path(__file__).resolve().parent
53
+
54
+ # Handle static folder for both dev and packaged app
55
+ if hasattr(sys, '_MEIPASS'):
56
+ # Running in PyInstaller bundle
57
+ STATIC_FOLDER = Path(sys._MEIPASS) / 'static'
58
+ else:
59
+ # Running in development
60
+ STATIC_FOLDER = CURRENT_DIR / 'static'
61
+ if not STATIC_FOLDER.exists():
62
+ DEV_FRONTEND_DIST = CURRENT_DIR.parent / 'frontend' / 'dist'
63
+ if DEV_FRONTEND_DIST.exists():
64
+ STATIC_FOLDER = DEV_FRONTEND_DIST
65
+
66
+ app = Flask(__name__, static_folder=str(STATIC_FOLDER))
38
67
  CORS(app)
39
68
 
40
- PROVIDERS_YAML = CURRENT_DIR / 'providers.yaml'
69
+ # Use get_resource_path for providers.yaml
70
+ PROVIDERS_YAML = get_resource_path('providers.yaml')
41
71
  USER_CONFIG_DIR = Path.home() / '.search-api-webui'
42
72
  USER_CONFIG_JSON = USER_CONFIG_DIR / 'config.json'
43
73
 
@@ -47,25 +77,28 @@ if not USER_CONFIG_DIR.exists():
47
77
  if PROVIDERS_YAML.exists():
48
78
  provider_map = load_providers(str(PROVIDERS_YAML))
49
79
  else:
50
- print(f"Error: Configuration file not found at {PROVIDERS_YAML}")
80
+ print(f'Error: Configuration file not found at {PROVIDERS_YAML}')
51
81
  provider_map = {}
52
82
 
83
+
53
84
  def get_stored_config():
54
85
  if not USER_CONFIG_JSON.exists():
55
86
  return {}
56
87
  try:
57
- with open(USER_CONFIG_JSON, 'r', encoding='utf-8') as f:
88
+ with open(USER_CONFIG_JSON, encoding='utf-8') as f:
58
89
  return json.load(f)
59
90
  except Exception as e:
60
- print(f"Error reading config: {e}")
91
+ print(f'Error reading config: {e}')
61
92
  return {}
62
93
 
94
+
63
95
  def save_stored_config(config_dict):
64
96
  try:
65
97
  with open(USER_CONFIG_JSON, 'w', encoding='utf-8') as f:
66
98
  json.dump(config_dict, f, indent=2)
67
99
  except Exception as e:
68
- print(f"Error saving config: {e}")
100
+ print(f'Error saving config: {e}')
101
+
69
102
 
70
103
  @app.route('/api/providers', methods=['GET'])
71
104
  def get_providers_list():
@@ -76,34 +109,37 @@ def get_providers_list():
76
109
  config_details = provider_instance.config
77
110
 
78
111
  user_conf = stored_config.get(name, {})
79
-
112
+
80
113
  if isinstance(user_conf, str):
81
114
  user_conf = {'api_key': user_conf}
82
115
 
83
116
  has_key = bool(user_conf.get('api_key'))
84
117
 
85
- providers_info.append({
86
- "name": name,
87
- "has_key": has_key,
88
- "details": config_details,
89
- "user_settings": {
90
- "api_url": user_conf.get('api_url', ''),
91
- "limit": user_conf.get('limit', '10'),
92
- "language": user_conf.get('language', 'en-US')
93
- }
94
- })
118
+ providers_info.append(
119
+ {
120
+ 'name': name,
121
+ 'has_key': has_key,
122
+ 'details': config_details,
123
+ 'user_settings': {
124
+ 'api_url': user_conf.get('api_url', ''),
125
+ 'limit': user_conf.get('limit', '10'),
126
+ 'language': user_conf.get('language', 'en-US'),
127
+ },
128
+ },
129
+ )
95
130
  return jsonify(providers_info)
96
131
 
132
+
97
133
  @app.route('/api/config', methods=['POST'])
98
134
  def update_config():
99
135
  data = request.json
100
136
  provider_name = data.get('provider')
101
137
 
102
138
  if not provider_name:
103
- return jsonify({"error": "Provider name is required"}), 400
139
+ return jsonify({'error': 'Provider name is required'}), 400
104
140
 
105
141
  if 'api_key' not in data:
106
- return jsonify({"error": "API Key field is missing"}), 400
142
+ return jsonify({'error': 'API Key field is missing'}), 400
107
143
 
108
144
  api_key = data.get('api_key')
109
145
 
@@ -118,7 +154,7 @@ def update_config():
118
154
 
119
155
  if not api_key:
120
156
  if provider_name in all_config:
121
- all_config[provider_name]['api_key'] = ""
157
+ all_config[provider_name]['api_key'] = ''
122
158
  else:
123
159
  if provider_name not in all_config:
124
160
  all_config[provider_name] = {}
@@ -129,7 +165,8 @@ def update_config():
129
165
  all_config[provider_name]['language'] = language
130
166
 
131
167
  save_stored_config(all_config)
132
- return jsonify({"status": "success"})
168
+ return jsonify({'status': 'success'})
169
+
133
170
 
134
171
  @app.route('/api/search', methods=['POST'])
135
172
  def search_api():
@@ -149,46 +186,104 @@ def search_api():
149
186
  api_key = provider_config.get('api_key')
150
187
 
151
188
  if not api_key:
152
- return jsonify({"error": f"API Key for {provider_name} is missing. Please configure it."}), 401
189
+ return (
190
+ jsonify({'error': f'API Key for {provider_name} is missing. Please configure it.'}),
191
+ 401,
192
+ )
153
193
 
154
194
  provider = provider_map.get(provider_name)
155
195
  if not provider:
156
- return jsonify({"error": "Provider not found"}), 404
196
+ return jsonify({'error': 'Provider not found'}), 404
157
197
 
158
198
  search_kwargs = {
159
199
  'api_url': provider_config.get('api_url'),
160
200
  'limit': provider_config.get('limit'),
161
- 'language': provider_config.get('language')
201
+ 'language': provider_config.get('language'),
162
202
  }
163
203
 
164
204
  result = provider.search(query, api_key, **search_kwargs)
165
205
  return jsonify(result)
166
206
 
207
+
167
208
  # Host React Frontend
168
209
  @app.route('/', defaults={'path': ''})
169
210
  @app.route('/<path:path>')
170
211
  def serve(path):
171
- if path != "" and (STATIC_FOLDER / path).exists():
212
+ if path != '' and (STATIC_FOLDER / path).exists():
172
213
  return send_from_directory(str(STATIC_FOLDER), path)
173
214
  else:
174
215
  return send_from_directory(str(STATIC_FOLDER), 'index.html')
175
216
 
176
- def main():
177
- import argparse
178
- parser = argparse.ArgumentParser(description="Search API WebUI")
179
- parser.add_argument("--port", type=int, default=8889, help="Port to run the server on")
180
- parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to run the server on")
181
- args = parser.parse_args()
182
217
 
183
- url = f"http://{args.host}:{args.port}"
184
- print(f"Starting Search API WebUI...")
185
- print(f" - Config Storage: {USER_CONFIG_JSON}")
186
- print(f" - Serving on: {url}")
218
+ def wait_for_server_ready(host, port):
219
+ start_time = time.time()
220
+ while time.time() - start_time < 10:
221
+ try:
222
+ with socket.create_connection((host, port), timeout=1):
223
+ return True
224
+ except (OSError, ConnectionRefusedError):
225
+ time.sleep(0.1)
226
+ return False
227
+
187
228
 
188
- # Open browser automatically after a short delay to ensure server is ready
189
- webbrowser.open(url)
229
+ def main():
230
+ import argparse
190
231
 
191
- app.run(host=args.host, port=args.port)
232
+ parser = argparse.ArgumentParser(description='Search API WebUI')
233
+ parser.add_argument('--port', type=int, default=8889, help='Port to run the server on')
234
+ parser.add_argument('--host', type=str, default='127.0.0.1', help='Host to run the server on')
235
+ parser.add_argument('-w', '--webview', action='store_true', help='Use webview to open the application')
236
+ args = parser.parse_args()
192
237
 
193
- if __name__ == "__main__":
238
+ url = f'http://{args.host}:{args.port}'
239
+ print('Starting Search API WebUI...')
240
+ print(f' - Config Storage: {USER_CONFIG_JSON}')
241
+ print(f' - Serving on: {url}')
242
+ if args.webview:
243
+ print(' - Mode: webview')
244
+
245
+ if args.webview:
246
+ if not WEBVIEW_AVAILABLE:
247
+ print('Warning: webview library not installed. Falling back to webbrowser.')
248
+ # Start server in background thread and wait for it to be ready
249
+ server_thread = threading.Thread(
250
+ target=lambda: app.run(
251
+ host=args.host, port=args.port, use_reloader=False,
252
+ ),
253
+ daemon=True,
254
+ )
255
+ server_thread.start()
256
+ if wait_for_server_ready(args.host, args.port):
257
+ print(f'Server is ready! Opening browser: {url}')
258
+ webbrowser.open(url)
259
+ else:
260
+ print('Error: Server took too long to start. Browser not opened.')
261
+ else:
262
+ # Start server in background thread and wait for it to be ready, then start webview
263
+ server_thread = threading.Thread(
264
+ target=lambda: app.run(
265
+ host=args.host, port=args.port, use_reloader=False,
266
+ ),
267
+ daemon=True,
268
+ )
269
+ server_thread.start()
270
+ if wait_for_server_ready(args.host, args.port):
271
+ print('Server is ready! Using webview mode...')
272
+ webview.create_window('Search API WebUI', url, width=1200, height=800)
273
+ webview.start()
274
+ else:
275
+ print('Error: Server took too long to start. Webview not opened.')
276
+ else:
277
+ # Start a background thread to check server status and open the browser automatically
278
+ def open_browser():
279
+ if wait_for_server_ready(args.host, args.port):
280
+ print(f'Server is ready! Opening browser: {url}')
281
+ webbrowser.open(url)
282
+ else:
283
+ print('Error: Server took too long to start. Browser not opened.')
284
+ threading.Thread(target=open_browser, daemon=True).start()
285
+ app.run(host=args.host, port=args.port)
286
+
287
+
288
+ if __name__ == '__main__':
194
289
  main()
@@ -19,25 +19,28 @@
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  import os
22
+
22
23
  import yaml
24
+
23
25
  from .generic import GenericProvider
24
26
  from .querit import QueritSdkProvider
25
27
 
28
+
26
29
  def load_providers(file_path='providers.yaml'):
27
- """
30
+ '''
28
31
  Parses the YAML configuration file and instantiates the appropriate provider classes.
29
-
32
+
30
33
  Args:
31
34
  file_path (str): Path to the providers configuration file.
32
-
35
+
33
36
  Returns:
34
37
  dict: A dictionary mapping provider names to their initialized instances.
35
- """
38
+ '''
36
39
  if not os.path.exists(file_path):
37
- print(f"Warning: Provider config file not found at {file_path}")
40
+ print(f'Warning: Provider config file not found at {file_path}')
38
41
  return {}
39
-
40
- with open(file_path, 'r', encoding='utf-8') as f:
42
+
43
+ with open(file_path, encoding='utf-8') as f:
41
44
  configs = yaml.safe_load(f)
42
45
 
43
46
  providers = {}
@@ -20,15 +20,74 @@
20
20
 
21
21
  from abc import ABC, abstractmethod
22
22
 
23
+
24
+ def parse_server_latency(latency_value):
25
+ '''
26
+ Parse server latency value from various formats to milliseconds.
27
+
28
+ Args:
29
+ latency_value: The latency value to parse. Can be:
30
+ - int/float: A numeric value
31
+ - str: A string with or without unit suffix (e.g., "123ms", "0.123s", "123")
32
+
33
+ Returns:
34
+ float | None: The latency in milliseconds, or None if parsing fails.
35
+
36
+ Rules:
37
+ 1. If numeric (int/float):
38
+ - If it's an integer, assume milliseconds
39
+ - If it's a decimal (fractional), assume seconds and convert to ms
40
+ 2. If string:
41
+ - If ends with 'ms' (case insensitive), parse as milliseconds
42
+ - If ends with 's' (case insensitive), parse as seconds and convert to ms
43
+ - If no suffix, convert to number and apply rule 1
44
+ '''
45
+ if latency_value is None:
46
+ return None
47
+
48
+ try:
49
+ # Handle string input
50
+ if isinstance(latency_value, str):
51
+ value_str = latency_value.strip().lower()
52
+
53
+ # Check for explicit unit suffix
54
+ if value_str.endswith('ms'):
55
+ return round(float(value_str[:-2]), 2)
56
+ elif value_str.endswith('s'):
57
+ return round(float(value_str[:-1]) * 1000, 2)
58
+ else:
59
+ # No suffix, convert to number and apply numeric logic
60
+ num_value = float(value_str)
61
+ if num_value.is_integer():
62
+ # Integer implies milliseconds
63
+ return round(num_value, 2)
64
+ else:
65
+ # Decimal implies seconds, convert to ms
66
+ return round(num_value * 1000, 2)
67
+
68
+ # Handle numeric input
69
+ elif isinstance(latency_value, (int, float)):
70
+ if float(latency_value).is_integer():
71
+ # Integer implies milliseconds
72
+ return round(float(latency_value), 2)
73
+ else:
74
+ # Decimal implies seconds, convert to ms
75
+ return round(float(latency_value) * 1000, 2)
76
+
77
+ return None
78
+ except (ValueError, AttributeError):
79
+ return None
80
+
81
+
23
82
  class BaseProvider(ABC):
24
- """
83
+ '''
25
84
  Abstract base class for all search providers.
26
85
  Enforces a standard interface for executing search queries.
27
- """
86
+ '''
28
87
 
29
88
  @abstractmethod
30
89
  def search(self, query, api_key, **kwargs):
31
- """
90
+ '''
32
91
  Execute a search request against the provider.
33
92
 
34
93
  Args:
@@ -39,7 +98,8 @@ class BaseProvider(ABC):
39
98
  Returns:
40
99
  dict: A standardized dictionary containing:
41
100
  - 'results': List of dicts with 'title', 'url', 'snippet'.
42
- - 'metrics': Dict with 'latency_ms' and 'size_bytes'.
101
+ - 'metrics': Dict with 'latency_ms' (client latency), 'server_latency_ms' (server reported latency),
102
+ and 'size_bytes'.
43
103
  - 'error': (Optional) Error message string if occurred.
44
- """
104
+ '''
45
105
  pass
@@ -18,41 +18,48 @@
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
+ import html
22
+ import logging
21
23
  import time
22
- import requests
24
+
23
25
  import jmespath
24
- from .base import BaseProvider
26
+ import requests
27
+
28
+ from .base import BaseProvider, parse_server_latency
29
+
30
+ logger = logging.getLogger(__name__)
31
+
25
32
 
26
33
  class GenericProvider(BaseProvider):
27
- """
34
+ '''
28
35
  A generic search provider driven by YAML configuration.
29
36
  It constructs HTTP requests dynamically and maps responses using JMESPath.
30
- """
37
+ '''
31
38
 
32
39
  def __init__(self, config):
33
- """
40
+ '''
34
41
  Initialize the provider with a configuration dictionary.
35
-
42
+
36
43
  Args:
37
44
  config (dict): Configuration containing url, headers, params, and mapping rules.
38
- """
45
+ '''
39
46
  self.config = config
40
47
  self.session = requests.Session() # Persistent connection session
41
- self._connection_ready = False # Connection ready status
42
- self._last_url = None # Last used URL tracker
48
+ self._connection_ready = False # Connection ready status
49
+ self._last_url = None # Last used URL tracker
43
50
 
44
51
  def _fill_template(self, template_obj, **kwargs):
45
- """
46
- Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
52
+ '''
53
+ Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
47
54
  with values provided in kwargs.
48
-
55
+
49
56
  Args:
50
57
  template_obj (dict | str): The structure containing placeholders.
51
58
  **kwargs: Key-value pairs to inject into the template.
52
-
59
+
53
60
  Returns:
54
61
  The structure with placeholders replaced by actual values.
55
- """
62
+ '''
56
63
  if isinstance(template_obj, str):
57
64
  # Treat None values as empty strings to prevent "None" appearing in URLs
58
65
  safe_kwargs = {k: (v if v is not None else '') for k, v in kwargs.items()}
@@ -66,7 +73,7 @@ class GenericProvider(BaseProvider):
66
73
  return template_obj
67
74
 
68
75
  def _ensure_connection(self, url, headers):
69
- """
76
+ '''
70
77
  Pre-warm HTTPS connection and verify availability.
71
78
  Uses lightweight HEAD request to verify connection without fetching response body.
72
79
 
@@ -76,7 +83,7 @@ class GenericProvider(BaseProvider):
76
83
 
77
84
  Returns:
78
85
  bool: Whether connection is ready
79
- """
86
+ '''
80
87
  # Re-warm if URL changed or connection not ready
81
88
  if url != self._last_url or not self._connection_ready:
82
89
  try:
@@ -84,13 +91,24 @@ class GenericProvider(BaseProvider):
84
91
  self.session.head(url, headers=headers, timeout=5)
85
92
  self._connection_ready = True
86
93
  self._last_url = url
87
- print(f' [Connection Pool] Connected to: {url}')
94
+ logger.debug('[Connection Pool] Connected to: %s', url)
88
95
  except Exception as e:
89
96
  self._connection_ready = False
90
- print(f' [Connection Pool] Connection warm-up failed: {e}')
97
+ logger.warning('[Connection Pool] Connection warm-up failed: %s', e)
91
98
  raise
92
99
 
93
100
  def search(self, query, api_key, **kwargs):
101
+ '''
102
+ Perform search using the configured API.
103
+
104
+ Args:
105
+ query: Search query string
106
+ api_key: API key for authentication
107
+ **kwargs: Additional parameters (limit, language, api_url)
108
+
109
+ Returns:
110
+ dict: Search results with 'results' and 'metrics' keys
111
+ '''
94
112
  # 1. Extract parameters with defaults
95
113
  limit = kwargs.get('limit', '10')
96
114
  language = kwargs.get('language', 'en-US')
@@ -99,13 +117,13 @@ class GenericProvider(BaseProvider):
99
117
  # 2. Determine configuration
100
118
  url = custom_url if custom_url else self.config.get('url')
101
119
  method = self.config.get('method', 'GET')
102
-
120
+
103
121
  # 3. Prepare context for template injection
104
122
  context = {
105
123
  'query': query,
106
124
  'api_key': api_key,
107
125
  'limit': limit,
108
- 'language': language
126
+ 'language': language,
109
127
  }
110
128
 
111
129
  # 4. construct request components
@@ -113,24 +131,21 @@ class GenericProvider(BaseProvider):
113
131
  params = self._fill_template(self.config.get('params', {}), **context)
114
132
  json_body = self._fill_template(self.config.get('payload', {}), **context)
115
133
 
116
- # Logging (Masking sensitive API keys)
117
- print(f'[{self.config.get("name", "Unknown")}] Search:')
118
- print(f' URL: {url} | Method: {method}')
119
-
134
+ logger.info('[%s] Search: URL=%s | Method=%s',
135
+ self.config.get('name', 'Unknown'), url, method)
136
+
120
137
  # Ensure connection is pre-warmed (use HEAD request to verify availability)
121
138
  # Pre-warming is not counted in request latency, only verifies connection
122
139
  try:
123
140
  self._ensure_connection(url, headers)
124
141
  except Exception as e:
125
- print(f"Connection Warm-up Error: {e}")
142
+ logger.error('Connection Warm-up Error: %s', e)
126
143
  return {
127
- "error": f"Connection failed: {str(e)}",
128
- "results": [],
129
- "metrics": {"latency_ms": 0, "size_bytes": 0}
144
+ 'error': f'Connection failed: {str(e)}',
145
+ 'results': [],
146
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
130
147
  }
131
148
 
132
- start_time = time.time()
133
-
134
149
  try:
135
150
  req_args = {'headers': headers, 'timeout': 30}
136
151
  if params:
@@ -139,29 +154,31 @@ class GenericProvider(BaseProvider):
139
154
  req_args['json'] = json_body
140
155
 
141
156
  # Use Session to send request (connection is reused)
157
+ start_time = time.time()
142
158
  if method.upper() == 'GET':
143
159
  response = self.session.get(url, **req_args)
144
160
  else:
145
161
  response = self.session.post(url, **req_args)
162
+ end_time = time.time()
146
163
 
147
164
  response.raise_for_status()
148
165
  except Exception as e:
149
- print(f"Request Error: {e}")
166
+ logger.error('Request Error: %s', e)
150
167
  return {
151
- "error": str(e),
152
- "results": [],
153
- "metrics": {"latency_ms": 0, "size_bytes": 0}
168
+ 'error': str(e),
169
+ 'results': [],
170
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
154
171
  }
155
172
 
156
- end_time = time.time()
157
-
158
173
  # 5. Parse and Normalize Response
159
174
  try:
160
175
  raw_data = response.json()
161
176
  except Exception as e:
162
- print(f"JSON Parse Error: {e}")
177
+ logger.error('JSON Parse Error: %s', e)
163
178
  raw_data = {}
164
179
 
180
+ logger.debug('Full response: %s', raw_data)
181
+
165
182
  mapping = self.config.get('response_mapping', {})
166
183
  # Use JMESPath to find the list of results
167
184
  root_list = jmespath.search(mapping.get('root_path', '@'), raw_data) or []
@@ -174,13 +191,23 @@ class GenericProvider(BaseProvider):
174
191
  # Map specific fields (title, url, etc.) based on config
175
192
  for std_key, source_path in field_map.items():
176
193
  val = jmespath.search(source_path, item)
177
- entry[std_key] = val if val else ""
194
+ # Decode HTML entities for site_name
195
+ if std_key == 'site_name' and val:
196
+ val = html.unescape(val)
197
+ entry[std_key] = val if val else ''
178
198
  normalized_results.append(entry)
179
199
 
200
+ # Extract server latency from response if configured
201
+ server_latency_path = mapping.get('server_latency_path')
202
+ server_latency_ms = parse_server_latency(
203
+ jmespath.search(server_latency_path, raw_data),
204
+ ) if server_latency_path else None
205
+
180
206
  return {
181
- "results": normalized_results,
182
- "metrics": {
183
- "latency_ms": round((end_time - start_time) * 1000, 2),
184
- "size_bytes": len(response.content)
185
- }
207
+ 'results': normalized_results,
208
+ 'metrics': {
209
+ 'latency_ms': round((end_time - start_time) * 1000, 2),
210
+ 'server_latency_ms': server_latency_ms,
211
+ 'size_bytes': len(response.content),
212
+ },
186
213
  }