ttnn-visualizer 0.41.0__py3-none-any.whl → 0.43.0__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 (39) hide show
  1. ttnn_visualizer/__init__.py +0 -1
  2. ttnn_visualizer/app.py +15 -4
  3. ttnn_visualizer/csv_queries.py +150 -40
  4. ttnn_visualizer/decorators.py +42 -16
  5. ttnn_visualizer/exceptions.py +45 -1
  6. ttnn_visualizer/file_uploads.py +1 -0
  7. ttnn_visualizer/instances.py +42 -15
  8. ttnn_visualizer/models.py +12 -7
  9. ttnn_visualizer/queries.py +3 -109
  10. ttnn_visualizer/remote_sqlite_setup.py +104 -19
  11. ttnn_visualizer/requirements.txt +2 -3
  12. ttnn_visualizer/serializers.py +1 -0
  13. ttnn_visualizer/settings.py +9 -5
  14. ttnn_visualizer/sftp_operations.py +657 -220
  15. ttnn_visualizer/sockets.py +9 -3
  16. ttnn_visualizer/static/assets/{allPaths-4_pFqSAW.js → allPaths-BQN_j7ek.js} +1 -1
  17. ttnn_visualizer/static/assets/{allPathsLoader-CpLPTLlt.js → allPathsLoader-BvkkQ77q.js} +2 -2
  18. ttnn_visualizer/static/assets/index-B-fsa5Ru.js +1 -0
  19. ttnn_visualizer/static/assets/{index-DFVwehlj.js → index-Bng0kcmi.js} +214 -214
  20. ttnn_visualizer/static/assets/{index-C1rJBrMl.css → index-C-t6jBt9.css} +1 -1
  21. ttnn_visualizer/static/assets/index-DLOviMB1.js +1 -0
  22. ttnn_visualizer/static/assets/{splitPathsBySizeLoader-D-RvsTqO.js → splitPathsBySizeLoader-Cl0NRdfL.js} +1 -1
  23. ttnn_visualizer/static/index.html +2 -2
  24. ttnn_visualizer/tests/__init__.py +0 -1
  25. ttnn_visualizer/tests/test_queries.py +0 -69
  26. ttnn_visualizer/tests/test_serializers.py +2 -2
  27. ttnn_visualizer/utils.py +7 -3
  28. ttnn_visualizer/views.py +315 -52
  29. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/LICENSE +0 -1
  30. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/METADATA +6 -3
  31. ttnn_visualizer-0.43.0.dist-info/RECORD +45 -0
  32. ttnn_visualizer/ssh_client.py +0 -85
  33. ttnn_visualizer/static/assets/index-BKzgFDAn.js +0 -1
  34. ttnn_visualizer/static/assets/index-BvSuWPlB.js +0 -1
  35. ttnn_visualizer-0.41.0.dist-info/RECORD +0 -46
  36. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/LICENSE_understanding.txt +0 -0
  37. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/WHEEL +0 -0
  38. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/entry_points.txt +0 -0
  39. {ttnn_visualizer-0.41.0.dist-info → ttnn_visualizer-0.43.0.dist-info}/top_level.txt +0 -0
@@ -6,25 +6,29 @@ import json
6
6
  import logging
7
7
  import re
8
8
  import time
9
+ import subprocess
9
10
  from pathlib import Path
10
11
  from stat import S_ISDIR
11
12
  from threading import Thread
12
13
  from typing import List, Optional
13
14
 
14
15
  from flask import current_app
15
- from paramiko.client import SSHClient
16
- from paramiko.sftp_client import SFTPClient
17
16
 
18
17
  from ttnn_visualizer.decorators import remote_exception_handler
19
18
  from ttnn_visualizer.enums import ConnectionTestStates
20
- from ttnn_visualizer.exceptions import NoProjectsException, RemoteConnectionException
19
+ from ttnn_visualizer.exceptions import (
20
+ NoProjectsException,
21
+ RemoteConnectionException,
22
+ SSHException,
23
+ AuthenticationException,
24
+ NoValidConnectionsError,
25
+ )
21
26
  from ttnn_visualizer.models import RemoteConnection, RemoteReportFolder
22
27
  from ttnn_visualizer.sockets import (
23
28
  FileProgress,
24
29
  FileStatus,
25
30
  emit_file_status,
26
31
  )
27
- from ttnn_visualizer.ssh_client import get_client
28
32
  from ttnn_visualizer.utils import update_last_synced
29
33
 
30
34
  logger = logging.getLogger(__name__)
@@ -34,6 +38,53 @@ TEST_PROFILER_FILE = "profile_log_device.csv"
34
38
  REPORT_DATA_DIRECTORY = Path(__file__).parent.absolute().joinpath("data")
35
39
 
36
40
 
41
+ def handle_ssh_subprocess_error(
42
+ e: subprocess.CalledProcessError, remote_connection: RemoteConnection
43
+ ):
44
+ """
45
+ Convert subprocess SSH errors to appropriate SSH exceptions.
46
+
47
+ :param e: The subprocess.CalledProcessError
48
+ :param remote_connection: The RemoteConnection object for context
49
+ :raises: SSHException, AuthenticationException, or NoValidConnectionsError
50
+ """
51
+ stderr = e.stderr.lower() if e.stderr else ""
52
+
53
+ # Check for authentication failures
54
+ if any(
55
+ auth_err in stderr
56
+ for auth_err in [
57
+ "permission denied",
58
+ "authentication failed",
59
+ "publickey",
60
+ "password",
61
+ "host key verification failed",
62
+ ]
63
+ ):
64
+ raise AuthenticationException(f"SSH authentication failed: {e.stderr}")
65
+
66
+ # Check for connection failures
67
+ elif any(
68
+ conn_err in stderr
69
+ for conn_err in [
70
+ "connection refused",
71
+ "network is unreachable",
72
+ "no route to host",
73
+ "name or service not known",
74
+ "connection timed out",
75
+ ]
76
+ ):
77
+ raise NoValidConnectionsError(f"SSH connection failed: {e.stderr}")
78
+
79
+ # Check for general SSH protocol errors
80
+ elif "ssh:" in stderr or "protocol" in stderr:
81
+ raise SSHException(f"SSH protocol error: {e.stderr}")
82
+
83
+ # Default to generic SSH exception
84
+ else:
85
+ raise SSHException(f"SSH command failed: {e.stderr}")
86
+
87
+
37
88
  def start_background_task(task, *args):
38
89
  with current_app.app_context():
39
90
  if current_app.config["USE_WEBSOCKETS"]:
@@ -57,31 +108,49 @@ def resolve_file_path(remote_connection, file_path: str) -> str:
57
108
  :return: The resolved file path.
58
109
  :raises FileNotFoundError: If no files match the pattern.
59
110
  """
60
- ssh_client = get_client(remote_connection)
61
-
62
111
  if "*" in file_path:
63
- command = f"ls -1 {file_path}"
64
- stdin, stdout, stderr = ssh_client.exec_command(command)
65
- files = stdout.read().decode().splitlines()
66
- ssh_client.close()
112
+ # Build SSH command to list files matching the pattern
113
+ ssh_cmd = [
114
+ "ssh",
115
+ f"{remote_connection.username}@{remote_connection.host}",
116
+ ]
67
117
 
68
- if not files:
69
- raise FileNotFoundError(f"No files found matching pattern: {file_path}")
118
+ # Handle non-standard SSH port
119
+ if remote_connection.port != 22:
120
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
70
121
 
71
- # Return the first file found
72
- return files[0]
122
+ # Add the ls command
123
+ ssh_cmd.append(f"ls -1 {file_path}")
73
124
 
74
- return file_path
125
+ try:
126
+ result = subprocess.run(ssh_cmd, capture_output=True, text=True, check=True)
127
+
128
+ files = result.stdout.strip().splitlines()
129
+
130
+ if not files or (len(files) == 1 and files[0] == ""):
131
+ raise FileNotFoundError(f"No files found matching pattern: {file_path}")
132
+
133
+ # Return the first file found
134
+ return files[0]
135
+
136
+ except subprocess.CalledProcessError as e:
137
+ logger.error(f"SSH command failed: {e}")
138
+ logger.error(f"stderr: {e.stderr}")
75
139
 
140
+ # Check if it's an SSH-specific error (authentication, connection, etc.)
141
+ if e.returncode == 255: # SSH returns 255 for SSH protocol errors
142
+ handle_ssh_subprocess_error(e, remote_connection)
143
+ else:
144
+ # File not found or other command error
145
+ raise FileNotFoundError(f"No files found matching pattern: {file_path}")
146
+ except Exception as e:
147
+ logger.error(f"Error resolving file path: {e}")
148
+ raise FileNotFoundError(f"Error resolving file path: {file_path}")
76
149
 
77
- def calculate_folder_size(client: SSHClient, folder_path: str) -> int:
78
- """Calculate the total size of the folder before compression."""
79
- stdin, stdout, stderr = client.exec_command(f"du -sb {folder_path}")
80
- size_info = stdout.read().decode().strip().split("\t")[0]
81
- return int(size_info)
150
+ return file_path
82
151
 
83
152
 
84
- def get_cluster_desc_path(ssh_client) -> Optional[str]:
153
+ def get_cluster_desc_path(remote_connection: RemoteConnection) -> Optional[str]:
85
154
  """
86
155
  List all folders matching '/tmp/umd_*' on the remote machine, filter for those containing
87
156
  'cluster_descriptor.yaml', and return the full path to the most recently modified YAML file.
@@ -94,37 +163,78 @@ def get_cluster_desc_path(ssh_client) -> Optional[str]:
94
163
  cluster_desc_file = "cluster_descriptor.yaml"
95
164
 
96
165
  try:
97
- # Command to list all folders matching '/tmp/umd_*'
98
- list_folders_command = "ls -1d /tmp/umd_* 2>/dev/null"
99
- stdin, stdout, stderr = ssh_client.exec_command(list_folders_command)
166
+ # Build SSH command to list folders matching '/tmp/umd_*'
167
+ ssh_cmd = [
168
+ "ssh",
169
+ f"{remote_connection.username}@{remote_connection.host}",
170
+ ]
171
+
172
+ # Handle non-standard SSH port
173
+ if remote_connection.port != 22:
174
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
175
+
176
+ # Add the ls command
177
+ ssh_cmd.append("ls -1d /tmp/umd_* 2>/dev/null")
178
+
179
+ # Execute SSH command to list folders
180
+ result = subprocess.run(
181
+ ssh_cmd,
182
+ capture_output=True,
183
+ text=True,
184
+ check=False, # Don't raise exception on non-zero exit (in case no folders found)
185
+ )
100
186
 
101
187
  # Get the list of folders
102
- folder_paths = stdout.read().decode().splitlines()
188
+ folder_paths = (
189
+ result.stdout.strip().splitlines() if result.stdout.strip() else []
190
+ )
103
191
 
104
192
  if not folder_paths:
105
193
  logger.info("No folders found matching the pattern '/tmp/umd_*'")
106
194
  return None
107
195
 
108
196
  # Check each folder for 'cluster_descriptor.yaml' and track the most recent one
109
- with ssh_client.open_sftp() as sftp:
110
- for folder in folder_paths:
111
- yaml_file_path = f"{folder}/{cluster_desc_file}"
112
- try:
113
- # Check if 'cluster_descriptor.yaml' exists and get its modification time
114
- attributes = sftp.stat(yaml_file_path)
115
- mod_time = attributes.st_mtime # Modification time
116
-
117
- # Update the latest file if this one is newer
118
- if mod_time > latest_mod_time:
119
- latest_mod_time = mod_time
120
- latest_yaml_path = yaml_file_path
121
- logger.info(
122
- f"Found newer {cluster_desc_file}': {yaml_file_path}"
123
- )
124
-
125
- except FileNotFoundError:
197
+ for folder in folder_paths:
198
+ yaml_file_path = f"{folder}/{cluster_desc_file}"
199
+
200
+ # Build SSH command to check if file exists and get its modification time
201
+ stat_cmd = [
202
+ "ssh",
203
+ "-o",
204
+ "PasswordAuthentication=no",
205
+ f"{remote_connection.username}@{remote_connection.host}",
206
+ ]
207
+
208
+ if remote_connection.port != 22:
209
+ stat_cmd.extend(["-p", str(remote_connection.port)])
210
+
211
+ # Use stat to get modification time (seconds since epoch)
212
+ stat_cmd.append(f"stat -c %Y '{yaml_file_path}' 2>/dev/null")
213
+
214
+ try:
215
+ stat_result = subprocess.run(
216
+ stat_cmd, capture_output=True, text=True, check=True
217
+ )
218
+
219
+ mod_time = float(stat_result.stdout.strip())
220
+
221
+ # Update the latest file if this one is newer
222
+ if mod_time > latest_mod_time:
223
+ latest_mod_time = mod_time
224
+ latest_yaml_path = yaml_file_path
225
+ logger.info(f"Found newer {cluster_desc_file}: {yaml_file_path}")
226
+
227
+ except subprocess.CalledProcessError as e:
228
+ # Check if it's an SSH-specific error
229
+ if e.returncode == 255: # SSH returns 255 for SSH protocol errors
230
+ handle_ssh_subprocess_error(e, remote_connection)
231
+ else:
232
+ # File not found or other command error
126
233
  logger.debug(f"'{cluster_desc_file}' not found in: {folder}")
127
234
  continue
235
+ except ValueError:
236
+ logger.debug(f"'{cluster_desc_file}' not found in: {folder}")
237
+ continue
128
238
 
129
239
  if latest_yaml_path:
130
240
  logger.info(
@@ -142,173 +252,404 @@ def get_cluster_desc_path(ssh_client) -> Optional[str]:
142
252
  message=f"Failed to get '{cluster_desc_file}' path",
143
253
  status=ConnectionTestStates.FAILED,
144
254
  )
145
- finally:
146
- ssh_client.close()
147
255
 
148
256
 
149
257
  @remote_exception_handler
150
258
  def get_cluster_desc(remote_connection: RemoteConnection):
151
- client = get_client(remote_connection)
152
- cluster_path = get_cluster_desc_path(client)
259
+ cluster_path = get_cluster_desc_path(remote_connection)
153
260
  if cluster_path:
154
261
  return read_remote_file(remote_connection, cluster_path)
155
262
  else:
156
263
  return None
157
264
 
158
265
 
159
- def walk_sftp_directory(sftp: SFTPClient, remote_path: str):
160
- """SFTP implementation of os.walk."""
161
- files, folders = [], []
162
- for f in sftp.listdir_attr(remote_path):
163
- if S_ISDIR(f.st_mode if f.st_mode else 0):
164
- folders.append(f.filename)
165
- else:
166
- files.append(f.filename)
167
- return files, folders
168
-
169
-
170
266
  def is_excluded(file_path, exclude_patterns):
171
- """Check if the file matches any exclusion pattern."""
172
- return any(re.search(pattern, file_path) for pattern in exclude_patterns)
267
+ """Check if a file path should be excluded based on patterns."""
268
+ for pattern in exclude_patterns:
269
+ if pattern in file_path:
270
+ return True
271
+ return False
173
272
 
174
273
 
175
274
  @remote_exception_handler
176
275
  def sync_files_and_directories(
177
- client, remote_profiler_folder: str, destination_dir: Path, exclude_patterns=None, sid=None
276
+ remote_connection: RemoteConnection,
277
+ remote_profiler_folder: str,
278
+ destination_dir: Path,
279
+ exclude_patterns=None,
280
+ sid=None,
178
281
  ):
179
- """Download files and directories sequentially in one unified loop."""
180
- exclude_patterns = (
181
- exclude_patterns or []
182
- ) # Default to an empty list if not provided
183
-
184
- with client.open_sftp() as sftp:
185
- # Ensure the destination directory exists
186
- destination_dir.mkdir(parents=True, exist_ok=True)
187
- finished_files = 0 # Initialize finished files counter
188
-
189
- # Recursively handle files and folders in the current directory
190
- def download_directory_contents(remote_dir, local_dir):
191
- # Ensure the local directory exists
192
- local_dir.mkdir(parents=True, exist_ok=True)
193
-
194
- # Get files and folders in the remote directory
195
- files, folders = walk_sftp_directory(sftp, remote_dir)
196
- total_files = len(files)
197
-
198
- # Function to download a file with progress reporting
199
- def download_file(remote_file_path, local_file_path, index):
200
- nonlocal finished_files
201
- # Download file with progress callback
202
- logger.info(f"Downloading {remote_file_path}")
203
- download_file_with_progress(
204
- sftp,
205
- remote_file_path,
206
- local_file_path,
207
- sid,
208
- total_files,
209
- finished_files,
210
- )
211
- logger.info(f"Finished downloading {remote_file_path}")
212
- finished_files += 1
282
+ """Download files and directories using SFTP with progress reporting."""
283
+ exclude_patterns = exclude_patterns or []
213
284
 
214
- # Download all files in the current directory
215
- for index, file in enumerate(files, start=1):
216
- remote_file_path = f"{remote_dir}/{file}"
217
- local_file_path = Path(local_dir, file)
285
+ # Ensure the destination directory exists
286
+ destination_dir.mkdir(parents=True, exist_ok=True)
218
287
 
219
- # Skip files that match any exclusion pattern
220
- if is_excluded(remote_file_path, exclude_patterns):
221
- logger.info(f"Skipping {remote_file_path} (excluded by pattern)")
222
- continue
288
+ logger.info(
289
+ f"Starting SFTP sync from {remote_profiler_folder} to {destination_dir}"
290
+ )
223
291
 
224
- download_file(remote_file_path, local_file_path, index)
292
+ # First, get list of all files and directories
293
+ logger.info("Getting remote file and directory lists...")
294
+ all_files = get_remote_file_list(
295
+ remote_connection, remote_profiler_folder, exclude_patterns
296
+ )
297
+ all_dirs = get_remote_directory_list(
298
+ remote_connection, remote_profiler_folder, exclude_patterns
299
+ )
225
300
 
226
- # Recursively handle subdirectories
227
- for folder in folders:
228
- remote_subdir = f"{remote_dir}/{folder}"
229
- local_subdir = local_dir / folder
230
- if is_excluded(remote_subdir, exclude_patterns):
231
- logger.info(
232
- f"Skipping directory {remote_subdir} (excluded by pattern)"
233
- )
234
- continue
235
- download_directory_contents(remote_subdir, local_subdir)
301
+ logger.info(f"Found {len(all_files)} files and {len(all_dirs)} directories to sync")
236
302
 
237
- # Start downloading from the root folder
238
- download_directory_contents(remote_profiler_folder, destination_dir)
303
+ # Create local directory structure
304
+ logger.info("Creating local directory structure...")
305
+ for remote_dir in all_dirs:
306
+ try:
307
+ # Calculate relative path from the base remote folder
308
+ relative_path = Path(remote_dir).relative_to(remote_profiler_folder)
309
+ local_dir = destination_dir / relative_path
310
+ local_dir.mkdir(parents=True, exist_ok=True)
311
+ except ValueError:
312
+ # Skip if remote_dir is not relative to remote_profiler_folder
313
+ continue
239
314
 
240
- # Create a .last-synced file in directory
241
- update_last_synced(destination_dir)
315
+ # Download files with progress reporting
316
+ total_files = len(all_files)
317
+ finished_files = 0
242
318
 
243
- # Emit final status
244
- final_progress = FileProgress(
245
- current_file_name="", # No specific file for the final status
246
- number_of_files=0,
247
- percent_of_current=100,
248
- finished_files=finished_files,
249
- status=FileStatus.FINISHED,
250
- )
319
+ logger.info(f"Starting download of {total_files} files...")
251
320
 
252
- if current_app.config["USE_WEBSOCKETS"]:
253
- emit_file_status(final_progress, sid)
254
- logger.info("All files downloaded. Final progress emitted.")
321
+ for remote_file in all_files:
322
+ try:
323
+ # Calculate relative path from the base remote folder
324
+ relative_path = Path(remote_file).relative_to(remote_profiler_folder)
325
+ local_file = destination_dir / relative_path
255
326
 
327
+ # Download the file using SFTP
328
+ download_single_file_sftp(remote_connection, remote_file, local_file)
256
329
 
257
- def download_file_with_progress(
258
- sftp, remote_path, local_path, sid, total_files, finished_files
259
- ):
260
- """Download a file and emit progress using FileProgress."""
261
- try:
330
+ finished_files += 1
262
331
 
263
- def download_progress_callback(transferred, total):
264
- percent_of_current = (transferred / total) * 100
332
+ # Emit progress
265
333
  progress = FileProgress(
266
- current_file_name=remote_path,
334
+ current_file_name=str(relative_path),
267
335
  number_of_files=total_files,
268
- percent_of_current=percent_of_current,
336
+ percent_of_current=100, # We don't get per-file progress with SFTP
269
337
  finished_files=finished_files,
270
338
  status=FileStatus.DOWNLOADING,
271
339
  )
272
- emit_file_status(progress, sid)
273
340
 
274
- # Perform the download
275
- sftp.get(remote_path, str(local_path), callback=download_progress_callback)
341
+ if current_app.config["USE_WEBSOCKETS"]:
342
+ emit_file_status(progress, sid)
343
+
344
+ if finished_files % 10 == 0: # Log every 10 files
345
+ logger.info(f"Downloaded {finished_files}/{total_files} files")
346
+
347
+ except ValueError:
348
+ # Skip if remote_file is not relative to remote_profiler_folder
349
+ logger.warning(f"Skipping file outside base folder: {remote_file}")
350
+ continue
351
+ except Exception as e:
352
+ logger.error(f"Failed to download {remote_file}: {e}")
353
+ # Continue with other files rather than failing completely
354
+ continue
355
+
356
+ # Create a .last-synced file in directory
357
+ update_last_synced(destination_dir)
358
+
359
+ # Emit final status
360
+ final_progress = FileProgress(
361
+ current_file_name="",
362
+ number_of_files=total_files,
363
+ percent_of_current=100,
364
+ finished_files=finished_files,
365
+ status=FileStatus.FINISHED,
366
+ )
367
+
368
+ if current_app.config["USE_WEBSOCKETS"]:
369
+ emit_file_status(final_progress, sid)
370
+
371
+ logger.info(
372
+ f"SFTP sync completed. Downloaded {finished_files}/{total_files} files."
373
+ )
374
+
375
+
376
+ def get_remote_file_list(
377
+ remote_connection: RemoteConnection, remote_folder: str, exclude_patterns=None
378
+ ) -> List[str]:
379
+ """Get a list of all files in the remote directory recursively, applying exclusion patterns."""
380
+ exclude_patterns = exclude_patterns or []
381
+
382
+ # Build SSH command to find all files recursively
383
+ ssh_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
384
+
385
+ # Handle non-standard SSH port
386
+ if remote_connection.port != 22:
387
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
388
+
389
+ ssh_cmd.extend(
390
+ [
391
+ f"{remote_connection.username}@{remote_connection.host}",
392
+ f"find '{remote_folder}' -type f",
393
+ ]
394
+ )
395
+
396
+ try:
397
+ result = subprocess.run(
398
+ ssh_cmd, capture_output=True, text=True, check=True, timeout=60
399
+ )
400
+
401
+ all_files = result.stdout.strip().splitlines()
402
+
403
+ # Filter out excluded files
404
+ filtered_files = []
405
+ for file_path in all_files:
406
+ if not is_excluded(file_path, exclude_patterns):
407
+ filtered_files.append(file_path.strip())
408
+
409
+ return filtered_files
410
+
411
+ except subprocess.CalledProcessError as e:
412
+ if e.returncode == 255: # SSH protocol errors
413
+ handle_ssh_subprocess_error(e, remote_connection)
414
+ return []
415
+ else:
416
+ logger.error(f"Error getting file list: {e.stderr}")
417
+ return []
418
+ except subprocess.TimeoutExpired:
419
+ logger.error(f"Timeout getting file list from: {remote_folder}")
420
+ return []
421
+ except Exception as e:
422
+ logger.error(f"Error getting file list: {e}")
423
+ return []
424
+
425
+
426
+ def get_remote_directory_list(
427
+ remote_connection: RemoteConnection, remote_folder: str, exclude_patterns=None
428
+ ) -> List[str]:
429
+ """Get a list of all directories in the remote directory recursively, applying exclusion patterns."""
430
+ exclude_patterns = exclude_patterns or []
431
+
432
+ # Build SSH command to find all directories recursively
433
+ ssh_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
434
+
435
+ # Handle non-standard SSH port
436
+ if remote_connection.port != 22:
437
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
438
+
439
+ ssh_cmd.extend(
440
+ [
441
+ f"{remote_connection.username}@{remote_connection.host}",
442
+ f"find '{remote_folder}' -type d",
443
+ ]
444
+ )
445
+
446
+ try:
447
+ result = subprocess.run(
448
+ ssh_cmd, capture_output=True, text=True, check=True, timeout=60
449
+ )
450
+
451
+ all_dirs = result.stdout.strip().splitlines()
452
+
453
+ # Filter out excluded directories
454
+ filtered_dirs = []
455
+ for dir_path in all_dirs:
456
+ if not is_excluded(dir_path, exclude_patterns):
457
+ filtered_dirs.append(dir_path.strip())
458
+
459
+ return filtered_dirs
460
+
461
+ except subprocess.CalledProcessError as e:
462
+ if e.returncode == 255: # SSH protocol errors
463
+ handle_ssh_subprocess_error(e, remote_connection)
464
+ return []
465
+ else:
466
+ logger.error(f"Error getting directory list: {e.stderr}")
467
+ return []
468
+ except subprocess.TimeoutExpired:
469
+ logger.error(f"Timeout getting directory list from: {remote_folder}")
470
+ return []
471
+ except Exception as e:
472
+ logger.error(f"Error getting directory list: {e}")
473
+ return []
474
+
475
+
476
+ def download_single_file_sftp(
477
+ remote_connection: RemoteConnection, remote_file: str, local_file: Path
478
+ ):
479
+ """Download a single file using SFTP."""
480
+ # Ensure local directory exists
481
+ local_file.parent.mkdir(parents=True, exist_ok=True)
482
+
483
+ # Build SFTP command
484
+ sftp_cmd = ["sftp", "-o", "PasswordAuthentication=no"]
485
+
486
+ # Handle non-standard SSH port
487
+ if remote_connection.port != 22:
488
+ sftp_cmd.extend(["-P", str(remote_connection.port)])
489
+
490
+ # Add batch mode and other options
491
+ sftp_cmd.extend(
492
+ [
493
+ "-b",
494
+ "-", # Read commands from stdin
495
+ f"{remote_connection.username}@{remote_connection.host}",
496
+ ]
497
+ )
498
+
499
+ # SFTP commands to execute
500
+ sftp_commands = f"get '{remote_file}' '{local_file}'\nquit\n"
501
+
502
+ try:
503
+ result = subprocess.run(
504
+ sftp_cmd,
505
+ input=sftp_commands,
506
+ capture_output=True,
507
+ text=True,
508
+ check=True,
509
+ timeout=300, # 5 minute timeout per file
510
+ )
511
+
512
+ logger.debug(f"Downloaded: {remote_file} -> {local_file}")
276
513
 
277
- except OSError as e:
278
- logger.error(f"Error downloading file {remote_path} to {local_path}: {str(e)}")
279
- raise
514
+ except subprocess.CalledProcessError as e:
515
+ if e.returncode == 255: # SSH protocol errors
516
+ handle_ssh_subprocess_error(e, remote_connection)
517
+ else:
518
+ logger.error(f"Error downloading file {remote_file}: {e.stderr}")
519
+ raise RuntimeError(f"Failed to download {remote_file}")
520
+ except subprocess.TimeoutExpired:
521
+ logger.error(f"Timeout downloading file: {remote_file}")
522
+ raise RuntimeError(f"Timeout downloading {remote_file}")
523
+ except Exception as e:
524
+ logger.error(f"Error downloading file {remote_file}: {e}")
525
+ raise RuntimeError(f"Failed to download {remote_file}")
280
526
 
281
527
 
282
528
  def get_remote_profiler_folder_from_config_path(
283
- sftp: SFTPClient, config_path: str
529
+ remote_connection: RemoteConnection, config_path: str
284
530
  ) -> RemoteReportFolder:
285
531
  """Read a remote config file and return RemoteFolder object."""
286
- attributes = sftp.lstat(str(config_path))
287
- with sftp.open(str(config_path), "rb") as config_file:
288
- data = json.loads(config_file.read())
532
+ try:
533
+ # Build SSH command to get file modification time
534
+ stat_cmd = [
535
+ "ssh",
536
+ "-o",
537
+ "PasswordAuthentication=no",
538
+ f"{remote_connection.username}@{remote_connection.host}",
539
+ ]
540
+
541
+ # Handle non-standard SSH port
542
+ if remote_connection.port != 22:
543
+ stat_cmd.extend(["-p", str(remote_connection.port)])
544
+
545
+ # Get modification time using stat command
546
+ stat_cmd.append(f"stat -c %Y '{config_path}' 2>/dev/null")
547
+
548
+ stat_result = subprocess.run(
549
+ stat_cmd, capture_output=True, text=True, check=True
550
+ )
551
+
552
+ last_modified = int(float(stat_result.stdout.strip()))
289
553
 
554
+ # Build SSH command to read file content
555
+ cat_cmd = [
556
+ "ssh",
557
+ "-o",
558
+ "PasswordAuthentication=no",
559
+ f"{remote_connection.username}@{remote_connection.host}",
560
+ ]
561
+
562
+ if remote_connection.port != 22:
563
+ cat_cmd.extend(["-p", str(remote_connection.port)])
564
+
565
+ # Read file content using cat command
566
+ cat_cmd.append(f"cat '{config_path}'")
567
+
568
+ cat_result = subprocess.run(cat_cmd, capture_output=True, text=True, check=True)
569
+
570
+ # Parse JSON data
571
+ data = json.loads(cat_result.stdout)
290
572
  report_name = data.get("report_name")
291
573
  logger.info(f"********* report_name: {report_name}")
292
574
 
293
575
  return RemoteReportFolder(
294
576
  remotePath=str(Path(config_path).parent),
295
577
  reportName=report_name,
296
- lastModified=(
297
- int(attributes.st_mtime) if attributes.st_mtime else int(time.time())
298
- ),
578
+ lastModified=last_modified,
579
+ )
580
+
581
+ except subprocess.CalledProcessError as e:
582
+ logger.error(f"SSH command failed while reading config: {e}")
583
+ logger.error(f"stderr: {e.stderr}")
584
+
585
+ # Check if it's an SSH-specific error (authentication, connection, etc.)
586
+ if e.returncode == 255: # SSH returns 255 for SSH protocol errors
587
+ handle_ssh_subprocess_error(e, remote_connection)
588
+ # This line never executes as handle_ssh_subprocess_error raises an exception
589
+ return RemoteReportFolder(
590
+ remotePath=str(Path(config_path).parent),
591
+ reportName="",
592
+ lastModified=int(time.time()),
593
+ )
594
+ else:
595
+ # Fall back to current time if we can't get modification time
596
+ return RemoteReportFolder(
597
+ remotePath=str(Path(config_path).parent),
598
+ reportName="",
599
+ lastModified=int(time.time()),
600
+ )
601
+ except (json.JSONDecodeError, ValueError) as e:
602
+ logger.error(f"Error parsing config file {config_path}: {e}")
603
+ # Fall back to current time and no report name
604
+ return RemoteReportFolder(
605
+ remotePath=str(Path(config_path).parent),
606
+ reportName="",
607
+ lastModified=int(time.time()),
299
608
  )
300
609
 
301
610
 
302
611
  def get_remote_performance_folder(
303
- sftp: SFTPClient, profile_folder: str
612
+ remote_connection: RemoteConnection, profile_folder: str
304
613
  ) -> RemoteReportFolder:
305
- """Read a remote config file and return RemoteFolder object."""
306
- attributes = sftp.stat(str(profile_folder))
614
+ """Get remote performance folder info and return RemoteFolder object."""
307
615
  performance_name = profile_folder.split("/")[-1]
308
616
  remote_path = profile_folder
309
- last_modified = (
310
- int(attributes.st_mtime) if attributes.st_mtime else int(time.time())
311
- )
617
+
618
+ # Get modification time using subprocess SSH command
619
+ try:
620
+ ssh_command = ["ssh", "-o", "PasswordAuthentication=no"]
621
+ if remote_connection.port != 22:
622
+ ssh_command.extend(["-p", str(remote_connection.port)])
623
+ ssh_command.extend(
624
+ [
625
+ f"{remote_connection.username}@{remote_connection.host}",
626
+ f"stat -c %Y '{profile_folder}'",
627
+ ]
628
+ )
629
+
630
+ result = subprocess.run(ssh_command, capture_output=True, text=True, timeout=30)
631
+
632
+ if result.returncode == 0:
633
+ last_modified = int(result.stdout.strip())
634
+ else:
635
+ # If stat fails, handle SSH errors
636
+ if result.returncode == 255:
637
+ handle_ssh_subprocess_error(
638
+ subprocess.CalledProcessError(
639
+ result.returncode, ssh_command, result.stdout, result.stderr
640
+ ),
641
+ remote_connection,
642
+ )
643
+ logger.warning(
644
+ f"Could not get modification time for {profile_folder}, using current time"
645
+ )
646
+ last_modified = int(time.time())
647
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, ValueError) as e:
648
+ logger.warning(
649
+ f"Error getting modification time for {profile_folder}: {e}, using current time"
650
+ )
651
+ last_modified = int(time.time())
652
+
312
653
  return RemoteReportFolder(
313
654
  remotePath=str(remote_path),
314
655
  reportName=str(performance_name),
@@ -321,37 +662,49 @@ def read_remote_file(
321
662
  remote_connection,
322
663
  remote_path=None,
323
664
  ):
324
- """Read a remote file."""
325
- ssh_client = get_client(remote_connection)
326
- with ssh_client.open_sftp() as sftp:
327
- if remote_path:
328
- path = Path(remote_path)
329
- else:
330
- path = Path(remote_connection.profilerPath)
665
+ """Read a remote file using SSH cat command."""
666
+ if remote_path:
667
+ path = Path(remote_path)
668
+ else:
669
+ path = Path(remote_connection.profilerPath)
331
670
 
332
- logger.info(f"Opening remote file {path}")
333
- directory_path = str(path.parent)
334
- file_name = str(path.name)
671
+ logger.info(f"Reading remote file {path}")
335
672
 
336
- try:
337
- sftp.chdir(path=directory_path)
338
- with sftp.open(filename=file_name) as file:
339
- content = file.read()
340
- return content
341
- except FileNotFoundError:
342
- logger.error(f"File not found: {path}")
673
+ # Build SSH command to read the file
674
+ ssh_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
675
+
676
+ # Handle non-standard SSH port
677
+ if remote_connection.port != 22:
678
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
679
+
680
+ ssh_cmd.extend(
681
+ [f"{remote_connection.username}@{remote_connection.host}", f"cat '{path}'"]
682
+ )
683
+
684
+ try:
685
+ result = subprocess.run(ssh_cmd, capture_output=True, check=True, timeout=30)
686
+ return result.stdout
687
+ except subprocess.CalledProcessError as e:
688
+ if e.returncode == 255: # SSH protocol errors
689
+ handle_ssh_subprocess_error(e, remote_connection)
343
690
  return None
344
- except IOError as e:
345
- logger.error(f"Error reading remote file {path}: {e}")
691
+ else:
692
+ # File not found or other command error
693
+ logger.error(f"File not found or cannot be read: {path}")
346
694
  return None
695
+ except subprocess.TimeoutExpired:
696
+ logger.error(f"Timeout reading remote file: {path}")
697
+ return None
698
+ except Exception as e:
699
+ logger.error(f"Error reading remote file {path}: {e}")
700
+ return None
347
701
 
348
702
 
349
703
  @remote_exception_handler
350
704
  def check_remote_path_for_reports(remote_connection):
351
705
  """Check the remote path for config files."""
352
- ssh_client = get_client(remote_connection)
353
706
  remote_config_paths = find_folders_by_files(
354
- ssh_client, remote_connection.profilerPath, [TEST_CONFIG_FILE]
707
+ remote_connection, remote_connection.profilerPath, [TEST_CONFIG_FILE]
355
708
  )
356
709
  if not remote_config_paths:
357
710
  raise NoProjectsException(
@@ -362,42 +715,122 @@ def check_remote_path_for_reports(remote_connection):
362
715
 
363
716
  @remote_exception_handler
364
717
  def check_remote_path_exists(remote_connection: RemoteConnection, path_key: str):
365
- client = get_client(remote_connection)
366
- sftp = client.open_sftp()
367
- # Attempt to list the directory to see if it exists
718
+ """Check if a remote path exists using SSH test command."""
719
+ path = getattr(remote_connection, path_key)
720
+
721
+ # Build SSH command to test if path exists
722
+ ssh_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
723
+
724
+ # Handle non-standard SSH port
725
+ if remote_connection.port != 22:
726
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
727
+
728
+ ssh_cmd.extend(
729
+ [f"{remote_connection.username}@{remote_connection.host}", f"test -d '{path}'"]
730
+ )
731
+
368
732
  try:
369
- sftp.stat(getattr(remote_connection, path_key))
370
- except IOError as e:
371
- # Directory does not exist or is inaccessible
372
- if path_key == "performancePath":
373
- message = "Performance directory does not exist or cannot be accessed"
733
+ result = subprocess.run(ssh_cmd, capture_output=True, check=True, timeout=10)
734
+ # If command succeeds, directory exists
735
+ return True
736
+ except subprocess.CalledProcessError as e:
737
+ if e.returncode == 255: # SSH protocol errors
738
+ handle_ssh_subprocess_error(e, remote_connection)
374
739
  else:
375
- message = "Profiler directory does not exist or cannot be accessed"
376
-
377
- logger.error(message)
740
+ # Directory does not exist or is inaccessible
741
+ if path_key == "performancePath":
742
+ message = "Performance directory does not exist or cannot be accessed"
743
+ else:
744
+ message = "Profiler directory does not exist or cannot be accessed"
745
+
746
+ logger.error(message)
747
+ raise RemoteConnectionException(
748
+ message=message, status=ConnectionTestStates.FAILED
749
+ )
750
+ except subprocess.TimeoutExpired:
751
+ logger.error(f"Timeout checking remote path: {path}")
378
752
  raise RemoteConnectionException(
379
- message=message, status=ConnectionTestStates.FAILED
753
+ message=f"Timeout checking remote path: {path}",
754
+ status=ConnectionTestStates.FAILED,
380
755
  )
381
756
 
382
757
 
383
758
  def find_folders_by_files(
384
- ssh_client, root_folder: str, file_names: List[str]
759
+ remote_connection: RemoteConnection, root_folder: str, file_names: List[str]
385
760
  ) -> List[str]:
386
761
  """Given a remote path, return a list of top-level folders that contain any of the specified files."""
387
762
  matched_folders: List[str] = []
388
- with ssh_client.open_sftp() as sftp:
389
- all_files = sftp.listdir_attr(root_folder)
390
- top_level_directories = filter(lambda e: S_ISDIR(e.st_mode), all_files)
391
763
 
392
- for directory in top_level_directories:
393
- dirname = Path(root_folder, directory.filename)
394
- directory_files = sftp.listdir(str(dirname))
764
+ # Build SSH command to find directories in root_folder
765
+ ssh_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
395
766
 
396
- # Check if any of the specified file names exist in the directory
397
- if any(file_name in directory_files for file_name in file_names):
398
- matched_folders.append(str(dirname))
767
+ # Handle non-standard SSH port
768
+ if remote_connection.port != 22:
769
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
399
770
 
400
- return matched_folders
771
+ ssh_cmd.extend(
772
+ [
773
+ f"{remote_connection.username}@{remote_connection.host}",
774
+ f"find '{root_folder}' -maxdepth 1 -type d -not -path '{root_folder}'",
775
+ ]
776
+ )
777
+
778
+ try:
779
+ result = subprocess.run(
780
+ ssh_cmd, capture_output=True, text=True, check=True, timeout=30
781
+ )
782
+
783
+ directories = result.stdout.strip().splitlines()
784
+
785
+ # For each directory, check if it contains any of the specified files
786
+ for directory in directories:
787
+ directory = directory.strip()
788
+ if not directory:
789
+ continue
790
+
791
+ # Build SSH command to check for files in this directory
792
+ file_checks = []
793
+ for file_name in file_names:
794
+ file_checks.append(f"test -f '{directory}/{file_name}'")
795
+
796
+ # Use OR logic to check if any of the files exist
797
+ check_cmd = ["ssh", "-o", "PasswordAuthentication=no"]
798
+ if remote_connection.port != 22:
799
+ check_cmd.extend(["-p", str(remote_connection.port)])
800
+
801
+ check_cmd.extend(
802
+ [
803
+ f"{remote_connection.username}@{remote_connection.host}",
804
+ f"({' || '.join(file_checks)})",
805
+ ]
806
+ )
807
+
808
+ try:
809
+ check_result = subprocess.run(
810
+ check_cmd, capture_output=True, check=True, timeout=10
811
+ )
812
+ # If command succeeds, at least one file exists
813
+ matched_folders.append(directory)
814
+ except subprocess.CalledProcessError:
815
+ # None of the files exist in this directory, skip it
816
+ continue
817
+
818
+ return matched_folders
819
+
820
+ except subprocess.CalledProcessError as e:
821
+ if e.returncode == 255: # SSH protocol errors
822
+ handle_ssh_subprocess_error(e, remote_connection)
823
+ # This line should never be reached as handle_ssh_subprocess_error raises an exception
824
+ return []
825
+ else:
826
+ logger.error(f"Error finding folders: {e.stderr}")
827
+ return []
828
+ except subprocess.TimeoutExpired:
829
+ logger.error(f"Timeout finding folders in: {root_folder}")
830
+ return []
831
+ except Exception as e:
832
+ logger.error(f"Error finding folders: {e}")
833
+ return []
401
834
 
402
835
 
403
836
  @remote_exception_handler
@@ -405,19 +838,24 @@ def get_remote_performance_folders(
405
838
  remote_connection: RemoteConnection,
406
839
  ) -> List[RemoteReportFolder]:
407
840
  """Return a list of remote folders containing a profile_log_device file."""
408
- client = get_client(remote_connection)
841
+ if remote_connection.performancePath is None:
842
+ error = "Performance path is not configured for this connection"
843
+ logger.error(error)
844
+ raise NoProjectsException(status=ConnectionTestStates.FAILED, message=error)
845
+
409
846
  performance_paths = find_folders_by_files(
410
- client, remote_connection.performancePath, [TEST_PROFILER_FILE]
847
+ remote_connection, remote_connection.performancePath, [TEST_PROFILER_FILE]
411
848
  )
412
849
  if not performance_paths:
413
850
  error = f"No profiler paths found at {remote_connection.performancePath}"
414
851
  logger.info(error)
415
852
  raise NoProjectsException(status=ConnectionTestStates.FAILED, message=error)
416
853
  remote_folder_data = []
417
- with client.open_sftp() as sftp:
418
- for path in performance_paths:
419
- remote_folder_data.append(get_remote_performance_folder(sftp, path))
420
- return remote_folder_data
854
+ for path in performance_paths:
855
+ remote_folder_data.append(
856
+ get_remote_performance_folder(remote_connection, path)
857
+ )
858
+ return remote_folder_data
421
859
 
422
860
 
423
861
  @remote_exception_handler
@@ -425,21 +863,19 @@ def get_remote_profiler_folders(
425
863
  remote_connection: RemoteConnection,
426
864
  ) -> List[RemoteReportFolder]:
427
865
  """Return a list of remote folders containing a config.json file."""
428
- client = get_client(remote_connection)
429
866
  remote_config_paths = find_folders_by_files(
430
- client, remote_connection.profilerPath, [TEST_CONFIG_FILE]
867
+ remote_connection, remote_connection.profilerPath, [TEST_CONFIG_FILE]
431
868
  )
432
869
  if not remote_config_paths:
433
870
  error = f"No projects found at {remote_connection.profilerPath}"
434
871
  logger.info(error)
435
872
  raise NoProjectsException(status=ConnectionTestStates.FAILED, message=error)
436
873
  remote_folder_data = []
437
- with client.open_sftp() as sftp:
438
- for config_path in remote_config_paths:
439
- remote_folder = get_remote_profiler_folder_from_config_path(
440
- sftp, str(Path(config_path).joinpath(TEST_CONFIG_FILE))
441
- )
442
- remote_folder_data.append(remote_folder)
874
+ for config_path in remote_config_paths:
875
+ remote_folder = get_remote_profiler_folder_from_config_path(
876
+ remote_connection, str(Path(config_path).joinpath(TEST_CONFIG_FILE))
877
+ )
878
+ remote_folder_data.append(remote_folder)
443
879
  return remote_folder_data
444
880
 
445
881
 
@@ -452,15 +888,18 @@ def sync_remote_profiler_folders(
452
888
  sid=None,
453
889
  ):
454
890
  """Main function to sync test folders, handles both compressed and individual syncs."""
455
- client = get_client(remote_connection)
456
891
  profiler_folder = Path(remote_folder_path).name
457
892
  destination_dir = Path(
458
- REPORT_DATA_DIRECTORY, path_prefix, remote_connection.host, current_app.config["PROFILER_DIRECTORY_NAME"], profiler_folder
893
+ REPORT_DATA_DIRECTORY,
894
+ path_prefix,
895
+ remote_connection.host,
896
+ current_app.config["PROFILER_DIRECTORY_NAME"],
897
+ profiler_folder,
459
898
  )
460
899
  destination_dir.mkdir(parents=True, exist_ok=True)
461
900
 
462
901
  sync_files_and_directories(
463
- client, remote_folder_path, destination_dir, exclude_patterns, sid
902
+ remote_connection, remote_folder_path, destination_dir, exclude_patterns, sid
464
903
  )
465
904
 
466
905
 
@@ -472,7 +911,6 @@ def sync_remote_performance_folders(
472
911
  exclude_patterns: Optional[List[str]] = None,
473
912
  sid=None,
474
913
  ):
475
- client = get_client(remote_connection)
476
914
  remote_folder_path = profile.remotePath
477
915
  profile_folder = Path(remote_folder_path).name
478
916
  destination_dir = Path(
@@ -483,7 +921,6 @@ def sync_remote_performance_folders(
483
921
  profile_folder,
484
922
  )
485
923
  destination_dir.mkdir(parents=True, exist_ok=True)
486
-
487
924
  sync_files_and_directories(
488
- client, remote_folder_path, destination_dir, exclude_patterns, sid
925
+ remote_connection, remote_folder_path, destination_dir, exclude_patterns, sid
489
926
  )