ttnn-visualizer 0.40.3__py3-none-any.whl → 0.42.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 (23) hide show
  1. ttnn_visualizer/app.py +21 -6
  2. ttnn_visualizer/csv_queries.py +135 -32
  3. ttnn_visualizer/decorators.py +7 -4
  4. ttnn_visualizer/exceptions.py +16 -0
  5. ttnn_visualizer/queries.py +3 -109
  6. ttnn_visualizer/remote_sqlite_setup.py +97 -19
  7. ttnn_visualizer/requirements.txt +1 -3
  8. ttnn_visualizer/sftp_operations.py +637 -219
  9. ttnn_visualizer/static/assets/{allPaths-DSFl5HNA.js → allPaths-wwXsGKJ2.js} +1 -1
  10. ttnn_visualizer/static/assets/{allPathsLoader-BwkPDbOI.js → allPathsLoader-BK9jqlVe.js} +2 -2
  11. ttnn_visualizer/static/assets/{index-CWerbNbe.js → index-Ybr1HJxx.js} +202 -202
  12. ttnn_visualizer/static/assets/{splitPathsBySizeLoader-DoKJAUb8.js → splitPathsBySizeLoader-CauQGZHk.js} +1 -1
  13. ttnn_visualizer/static/index.html +5 -5
  14. ttnn_visualizer/tests/test_queries.py +0 -68
  15. ttnn_visualizer/views.py +115 -9
  16. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/LICENSE +0 -1
  17. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/METADATA +2 -3
  18. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/RECORD +22 -23
  19. ttnn_visualizer/ssh_client.py +0 -85
  20. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/LICENSE_understanding.txt +0 -0
  21. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/WHEEL +0 -0
  22. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/entry_points.txt +0 -0
  23. {ttnn_visualizer-0.40.3.dist-info → ttnn_visualizer-0.42.0.dist-info}/top_level.txt +0 -0
ttnn_visualizer/app.py CHANGED
@@ -16,7 +16,7 @@ from typing import cast
16
16
 
17
17
  import flask
18
18
  from dotenv import load_dotenv
19
- from flask import Flask, jsonify
19
+ from flask import Flask, abort, jsonify
20
20
  from flask_cors import CORS
21
21
  from werkzeug.debug import DebuggedApplication
22
22
  from werkzeug.middleware.proxy_fix import ProxyFix
@@ -46,7 +46,11 @@ def create_app(settings_override=None):
46
46
 
47
47
  config = cast(DefaultConfig, Config())
48
48
 
49
- app = Flask(__name__, static_folder=config.STATIC_ASSETS_DIR, static_url_path="/")
49
+ app = Flask(
50
+ __name__,
51
+ static_folder=config.STATIC_ASSETS_DIR,
52
+ static_url_path=f"{config.BASE_PATH}static",
53
+ )
50
54
  logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO"))
51
55
 
52
56
  app.config.from_object(config)
@@ -56,14 +60,17 @@ def create_app(settings_override=None):
56
60
 
57
61
  middleware(app)
58
62
 
59
- app.register_blueprint(api, url_prefix=f"{app.config['BASE_PATH']}/api")
63
+ app.register_blueprint(api, url_prefix=f"{app.config['BASE_PATH']}api")
60
64
 
61
65
  extensions(app)
62
66
 
63
67
  if flask_env == "production":
64
- @app.route(f"{app.config['BASE_PATH']}/", defaults={"path": ""})
65
- @app.route("/<path:path>")
68
+ @app.route(f"{app.config['BASE_PATH']}", defaults={"path": ""})
69
+ @app.route(f"{app.config['BASE_PATH']}<path:path>")
66
70
  def catch_all(path):
71
+ if path.startswith("static/"):
72
+ abort(404) # Pass control to Flask's static view
73
+
67
74
  js_config = {
68
75
  "SERVER_MODE": app.config["SERVER_MODE"],
69
76
  "BASE_PATH": app.config["BASE_PATH"],
@@ -78,7 +85,15 @@ def create_app(settings_override=None):
78
85
  js,
79
86
  )
80
87
 
81
- return flask.Response(html_with_config, mimetype="text/html")
88
+ return flask.Response(
89
+ html_with_config,
90
+ mimetype="text/html",
91
+ headers={
92
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
93
+ "Pragma": "no-cache",
94
+ "Expires": "0",
95
+ },
96
+ )
82
97
 
83
98
  return app
84
99
 
@@ -2,7 +2,9 @@
2
2
  #
3
3
  # SPDX-FileCopyrightText: © 2025 Tenstorrent Inc.
4
4
  import csv
5
+ import json
5
6
  import os
7
+ import subprocess
6
8
  import tempfile
7
9
  from io import StringIO
8
10
  from pathlib import Path
@@ -12,9 +14,49 @@ import pandas as pd
12
14
  from tt_perf_report import perf_report
13
15
 
14
16
  from ttnn_visualizer.exceptions import DataFormatError
17
+ from ttnn_visualizer.models import Instance, RemoteConnection
18
+ from ttnn_visualizer.exceptions import SSHException, AuthenticationException, NoValidConnectionsError
15
19
  from ttnn_visualizer.models import Instance
16
- from ttnn_visualizer.ssh_client import get_client
17
-
20
+ from ttnn_visualizer.sftp_operations import read_remote_file
21
+
22
+
23
+ def handle_ssh_subprocess_error(e: subprocess.CalledProcessError, remote_connection: RemoteConnection):
24
+ """
25
+ Convert subprocess SSH errors to appropriate SSH exceptions.
26
+
27
+ :param e: The subprocess.CalledProcessError
28
+ :param remote_connection: The RemoteConnection object for context
29
+ :raises: SSHException, AuthenticationException, or NoValidConnectionsError
30
+ """
31
+ stderr = e.stderr.lower() if e.stderr else ""
32
+
33
+ # Check for authentication failures
34
+ if any(auth_err in stderr for auth_err in [
35
+ "permission denied",
36
+ "authentication failed",
37
+ "publickey",
38
+ "password",
39
+ "host key verification failed"
40
+ ]):
41
+ raise AuthenticationException(f"SSH authentication failed: {e.stderr}")
42
+
43
+ # Check for connection failures
44
+ elif any(conn_err in stderr for conn_err in [
45
+ "connection refused",
46
+ "network is unreachable",
47
+ "no route to host",
48
+ "name or service not known",
49
+ "connection timed out"
50
+ ]):
51
+ raise NoValidConnectionsError(f"SSH connection failed: {e.stderr}")
52
+
53
+ # Check for general SSH protocol errors
54
+ elif "ssh:" in stderr or "protocol" in stderr:
55
+ raise SSHException(f"SSH protocol error: {e.stderr}")
56
+
57
+ # Default to generic SSH exception
58
+ else:
59
+ raise SSHException(f"SSH command failed: {e.stderr}")
18
60
 
19
61
  class LocalCSVQueryRunner:
20
62
  def __init__(self, file_path: str, offset: int = 0):
@@ -109,7 +151,38 @@ class RemoteCSVQueryRunner:
109
151
  self.remote_connection = remote_connection
110
152
  self.sep = sep
111
153
  self.offset = offset
112
- self.ssh_client = get_client(remote_connection)
154
+
155
+ def _execute_ssh_command(self, command: str) -> str:
156
+ """Execute an SSH command and return the output."""
157
+ ssh_cmd = ["ssh"]
158
+
159
+ # Handle non-standard SSH port
160
+ if self.remote_connection.port != 22:
161
+ ssh_cmd.extend(["-p", str(self.remote_connection.port)])
162
+
163
+ ssh_cmd.extend([
164
+ f"{self.remote_connection.username}@{self.remote_connection.host}",
165
+ command
166
+ ])
167
+
168
+ try:
169
+ result = subprocess.run(
170
+ ssh_cmd,
171
+ capture_output=True,
172
+ text=True,
173
+ check=True,
174
+ timeout=30
175
+ )
176
+ return result.stdout
177
+ except subprocess.CalledProcessError as e:
178
+ if e.returncode == 255: # SSH protocol errors
179
+ handle_ssh_subprocess_error(e, self.remote_connection)
180
+ # This line should never be reached as handle_ssh_subprocess_error raises an exception
181
+ raise RuntimeError(f"SSH command failed: {e.stderr}")
182
+ else:
183
+ raise RuntimeError(f"SSH command failed: {e.stderr}")
184
+ except subprocess.TimeoutExpired:
185
+ raise RuntimeError(f"SSH command timed out: {command}")
113
186
 
114
187
  def execute_query(
115
188
  self,
@@ -127,12 +200,7 @@ class RemoteCSVQueryRunner:
127
200
  """
128
201
  # Fetch header row, accounting for the offset
129
202
  header_cmd = f"head -n {self.offset + 1} {self.file_path} | tail -n 1"
130
- stdin, stdout, stderr = self.ssh_client.exec_command(header_cmd)
131
- raw_header = stdout.read().decode("utf-8").strip()
132
- error = stderr.read().decode("utf-8").strip()
133
-
134
- if error:
135
- raise RuntimeError(f"Error fetching header row: {error}")
203
+ raw_header = self._execute_ssh_command(header_cmd).strip()
136
204
 
137
205
  # Sanitize headers
138
206
  headers = [
@@ -159,12 +227,7 @@ class RemoteCSVQueryRunner:
159
227
  limit_clause = f"| head -n {limit}" if limit else ""
160
228
  awk_cmd = f"awk -F'{self.sep}' 'NR > {self.offset + 1} {f'&& {awk_filter}' if awk_filter else ''} {{print}}' {self.file_path} {limit_clause}"
161
229
 
162
- stdin, stdout, stderr = self.ssh_client.exec_command(awk_cmd)
163
- output = stdout.read().decode("utf-8").strip()
164
- error = stderr.read().decode("utf-8").strip()
165
-
166
- if error:
167
- raise RuntimeError(f"Error executing AWK command: {error}")
230
+ output = self._execute_ssh_command(awk_cmd).strip()
168
231
 
169
232
  # Split rows into lists of strings
170
233
  rows = [
@@ -204,14 +267,9 @@ class RemoteCSVQueryRunner:
204
267
  if total_lines
205
268
  else f"cat {self.file_path}"
206
269
  )
207
- stdin, stdout, stderr = self.ssh_client.exec_command(cmd)
208
- output = stdout.read().decode("utf-8").strip()
209
- error = stderr.read().decode("utf-8").strip()
270
+ output = self._execute_ssh_command(cmd).strip()
210
271
 
211
- if error:
212
- raise RuntimeError(f"Error fetching raw rows: {error}")
213
-
214
- return output.splitlines()[self.offset :]
272
+ return output.splitlines()[self.offset:]
215
273
 
216
274
  def get_csv_header(self) -> Dict[str, int]:
217
275
  """
@@ -219,12 +277,7 @@ class RemoteCSVQueryRunner:
219
277
  :return: Dictionary of headers.
220
278
  """
221
279
  header_cmd = f"head -n {self.offset + 1} {self.file_path} | tail -n 1"
222
- stdin, stdout, stderr = self.ssh_client.exec_command(header_cmd)
223
- header = stdout.read().decode("utf-8").strip()
224
- error = stderr.read().decode("utf-8").strip()
225
-
226
- if error:
227
- raise RuntimeError(f"Error reading CSV header: {error}")
280
+ header = self._execute_ssh_command(header_cmd).strip()
228
281
 
229
282
  # Trim spaces in header names
230
283
  column_names = [name.strip() for name in header.split(self.sep)]
@@ -253,10 +306,59 @@ class RemoteCSVQueryRunner:
253
306
 
254
307
  def __exit__(self, exc_type, exc_val, exc_tb):
255
308
  """
256
- Clean up the SSH connection when exiting context.
309
+ Clean up resources when exiting context.
257
310
  """
258
- if self.ssh_client:
259
- self.ssh_client.close()
311
+ pass
312
+
313
+
314
+ class NPEQueries:
315
+ NPE_FOLDER = "npe_viz"
316
+ MANIFEST_FILE = "manifest.json"
317
+
318
+ @staticmethod
319
+ def get_npe_manifest(instance: Instance):
320
+
321
+ if (
322
+ not instance.remote_connection
323
+ or instance.remote_connection
324
+ and not instance.remote_connection.useRemoteQuerying
325
+ ):
326
+ file_path = Path(
327
+ instance.performance_path, NPEQueries.NPE_FOLDER, NPEQueries.MANIFEST_FILE
328
+ )
329
+ with open(file_path, "r") as f:
330
+ return json.load(f)
331
+ else:
332
+ profiler_folder = instance.remote_profile_folder
333
+ return read_remote_file(
334
+ instance.remote_connection,
335
+ f"{profiler_folder.remotePath}/{NPEQueries.NPE_FOLDER}/{NPEQueries.MANIFEST_FILE}",
336
+ )
337
+
338
+ @staticmethod
339
+ def get_npe_timeline(instance: Instance, filename: str):
340
+ if not filename:
341
+ raise ValueError("filename parameter is required and cannot be None or empty")
342
+
343
+ if (
344
+ not instance.remote_connection
345
+ or not instance.remote_connection.useRemoteQuerying
346
+ ):
347
+ if not instance.performance_path:
348
+ raise ValueError("instance.performance_path is None")
349
+
350
+ file_path = Path(
351
+ instance.performance_path, NPEQueries.NPE_FOLDER, filename
352
+ )
353
+ with open(file_path, "r") as f:
354
+ return json.load(f)
355
+ else:
356
+ profiler_folder = instance.remote_profile_folder
357
+ return read_remote_file(
358
+ instance.remote_connection,
359
+ f"{profiler_folder.remotePath}/{NPEQueries.NPE_FOLDER}/{filename}",
360
+ )
361
+
260
362
 
261
363
 
262
364
  class DeviceLogProfilerQueries:
@@ -584,6 +686,7 @@ class OpsPerformanceReportQueries:
584
686
  "inner_dim_block_size",
585
687
  "output_subblock_h",
586
688
  "output_subblock_w",
689
+ "global_call_count",
587
690
  "advice",
588
691
  "raw_op_code"
589
692
  ]
@@ -640,7 +743,7 @@ class OpsPerformanceReportQueries:
640
743
 
641
744
  for key, value in cls.PASSTHROUGH_COLUMNS.items():
642
745
  op_id = int(row[0])
643
- idx = op_id - 2 # IDs in result column one correspond to row numbers in ops perf results csv
746
+ idx = op_id - 2 # IDs in result column one correspond to row numbers in ops perf results csv
644
747
  processed_row[key] = ops_perf_results[idx][value]
645
748
 
646
749
  report.append(processed_row)
@@ -9,13 +9,10 @@ from ttnn_visualizer.enums import ConnectionTestStates
9
9
 
10
10
  from functools import wraps
11
11
  from flask import abort, request, session
12
- from paramiko.ssh_exception import (
12
+ from ttnn_visualizer.exceptions import (
13
13
  AuthenticationException,
14
14
  NoValidConnectionsError,
15
15
  SSHException,
16
- )
17
-
18
- from ttnn_visualizer.exceptions import (
19
16
  RemoteConnectionException,
20
17
  NoProjectsException,
21
18
  RemoteSqliteException,
@@ -37,6 +34,12 @@ def with_instance(func):
37
34
  abort(404)
38
35
 
39
36
  instance_query_data = get_or_create_instance(instance_id=instance_id)
37
+
38
+ # Handle case where get_or_create_instance returns None due to database error
39
+ if instance_query_data is None:
40
+ current_app.logger.error(f"Failed to get or create instance with ID: {instance_id}")
41
+ abort(500)
42
+
40
43
  instance = instance_query_data.to_pydantic()
41
44
 
42
45
  kwargs["instance"] = instance
@@ -43,5 +43,21 @@ class DataFormatError(Exception):
43
43
  class InvalidReportPath(Exception):
44
44
  pass
45
45
 
46
+
46
47
  class InvalidProfilerPath(Exception):
47
48
  pass
49
+
50
+
51
+ class SSHException(Exception):
52
+ """Base SSH exception for subprocess SSH operations"""
53
+ pass
54
+
55
+
56
+ class AuthenticationException(SSHException):
57
+ """Raised when SSH authentication fails"""
58
+ pass
59
+
60
+
61
+ class NoValidConnectionsError(SSHException):
62
+ """Raised when SSH connection cannot be established"""
63
+ pass
@@ -2,7 +2,6 @@
2
2
  #
3
3
  # SPDX-FileCopyrightText: © 2025 Tenstorrent AI ULC
4
4
 
5
- import json
6
5
  from typing import Generator, Dict, Any, Union
7
6
 
8
7
  from ttnn_visualizer.exceptions import (
@@ -23,11 +22,9 @@ from ttnn_visualizer.models import (
23
22
  ProducersConsumers,
24
23
  TensorComparisonRecord,
25
24
  )
26
- from ttnn_visualizer.ssh_client import get_client
27
25
  import sqlite3
28
26
  from typing import List, Optional
29
27
  from pathlib import Path
30
- import paramiko
31
28
 
32
29
 
33
30
  class LocalQueryRunner:
@@ -63,111 +60,10 @@ class LocalQueryRunner:
63
60
  self.connection.close()
64
61
 
65
62
 
66
- class RemoteQueryRunner:
67
- column_delimiter = "|||"
68
-
69
- def __init__(self, instance: Instance):
70
- self.instance = instance
71
- self._validate_instance()
72
- self.ssh_client = self._get_ssh_client(self.instance.remote_connection)
73
- self.sqlite_binary = self.instance.remote_connection.sqliteBinaryPath
74
- self.remote_db_path = str(
75
- Path(self.instance.remote_profiler_folder.remotePath, "db.sqlite")
76
- )
77
-
78
- def _validate_instance(self):
79
- """
80
- Validate that the instance has all required remote connection attributes.
81
- """
82
- if (
83
- not self.instance.remote_connection
84
- or not self.instance.remote_connection.sqliteBinaryPath
85
- or not self.instance.remote_profiler_folder
86
- or not self.instance.remote_profiler_folder.remotePath
87
- ):
88
- raise ValueError(
89
- "Remote connections require remote path and sqliteBinaryPath"
90
- )
91
-
92
- def _get_ssh_client(self, remote_connection) -> paramiko.SSHClient:
93
- """
94
- Retrieve the SSH client for the given remote connection.
95
- """
96
- return get_client(remote_connection=remote_connection)
97
-
98
- def _format_query(self, query: str, params: Optional[List] = None) -> str:
99
- """
100
- Format the query by replacing placeholders with properly quoted parameters.
101
- """
102
- if not params:
103
- return query
104
-
105
- formatted_params = [
106
- f"'{param}'" if isinstance(param, str) else str(param) for param in params
107
- ]
108
- return query.replace("?", "{}").format(*formatted_params)
109
-
110
- def _build_command(self, formatted_query: str) -> str:
111
- """
112
- Build the remote SQLite command.
113
- """
114
- return f'{self.sqlite_binary} {self.remote_db_path} "{formatted_query}" -json'
115
-
116
- def _execute_ssh_command(self, command: str) -> tuple:
117
- """
118
- Execute the SSH command and return the standard output and error.
119
- """
120
- stdin, stdout, stderr = self.ssh_client.exec_command(command)
121
- output = stdout.read().decode("utf-8").strip()
122
- error_output = stderr.read().decode("utf-8").strip()
123
- return output, error_output
124
-
125
- def _parse_output(self, output: str, command: str) -> List:
126
- """
127
- Parse the output from the SQLite command. Attempt JSON parsing first,
128
- then fall back to line-based parsing.
129
- """
130
- if not output.strip():
131
- return []
132
-
133
- try:
134
- rows = json.loads(output)
135
- return [tuple(row.values()) for row in rows]
136
- except json.JSONDecodeError:
137
- print(
138
- f"Output is not valid JSON, attempting manual parsing.\nCommand: {command}"
139
- )
140
- return [tuple(line.split("|")) for line in output.splitlines()]
141
-
142
- def execute_query(self, query: str, params: Optional[List] = None) -> List:
143
- """
144
- Execute a remote SQLite query using the instance's SSH client.
145
- """
146
- self._validate_instance()
147
- formatted_query = self._format_query(query, params)
148
- command = self._build_command(formatted_query)
149
- output, error_output = self._execute_ssh_command(command)
150
-
151
- if error_output:
152
- raise RuntimeError(
153
- f"Error executing query remotely: {error_output}\nCommand: {command}"
154
- )
155
-
156
- return self._parse_output(output, command)
157
-
158
- def close(self):
159
- """
160
- Close the SSH connection.
161
- """
162
- if self.ssh_client:
163
- self.ssh_client.close()
164
-
165
-
166
63
  class DatabaseQueries:
167
64
 
168
65
  instance: Optional[Instance] = None
169
- ssh_client = None
170
- query_runner: LocalQueryRunner | RemoteQueryRunner
66
+ query_runner: LocalQueryRunner
171
67
 
172
68
  def __init__(self, instance: Optional[Instance] = None, connection=None):
173
69
  self.instance = instance
@@ -181,7 +77,7 @@ class DatabaseQueries:
181
77
  )
182
78
  remote_connection = instance.remote_connection if instance else None
183
79
  if remote_connection and remote_connection.useRemoteQuerying:
184
- self.query_runner = RemoteQueryRunner(instance=instance)
80
+ raise NotImplementedError("Remote querying is not implemented yet")
185
81
  else:
186
82
  self.query_runner = LocalQueryRunner(instance=instance)
187
83
 
@@ -382,7 +278,5 @@ class DatabaseQueries:
382
278
  return self
383
279
 
384
280
  def __exit__(self, exc_type, exc_value, traceback):
385
- if isinstance(self.query_runner, RemoteQueryRunner):
386
- self.query_runner.close()
387
- elif isinstance(self.query_runner, LocalQueryRunner):
281
+ if isinstance(self.query_runner, LocalQueryRunner):
388
282
  self.query_runner.close()
@@ -3,29 +3,109 @@
3
3
  # SPDX-FileCopyrightText: © 2025 Tenstorrent AI ULC
4
4
 
5
5
  import re
6
+ import subprocess
6
7
 
7
8
  from ttnn_visualizer.decorators import remote_exception_handler
8
9
  from ttnn_visualizer.enums import ConnectionTestStates
9
- from ttnn_visualizer.exceptions import RemoteSqliteException
10
+ from ttnn_visualizer.exceptions import RemoteSqliteException, SSHException, AuthenticationException, NoValidConnectionsError
10
11
  from ttnn_visualizer.models import RemoteConnection
11
- from ttnn_visualizer.ssh_client import get_client
12
+
13
+
14
+ def handle_ssh_subprocess_error(e: subprocess.CalledProcessError, remote_connection: RemoteConnection):
15
+ """
16
+ Convert subprocess SSH errors to appropriate SSH exceptions.
17
+
18
+ :param e: The subprocess.CalledProcessError
19
+ :param remote_connection: The RemoteConnection object for context
20
+ :raises: SSHException, AuthenticationException, or NoValidConnectionsError
21
+ """
22
+ stderr = e.stderr.lower() if e.stderr else ""
23
+
24
+ # Check for authentication failures
25
+ if any(auth_err in stderr for auth_err in [
26
+ "permission denied",
27
+ "authentication failed",
28
+ "publickey",
29
+ "password",
30
+ "host key verification failed"
31
+ ]):
32
+ raise AuthenticationException(f"SSH authentication failed: {e.stderr}")
33
+
34
+ # Check for connection failures
35
+ elif any(conn_err in stderr for conn_err in [
36
+ "connection refused",
37
+ "network is unreachable",
38
+ "no route to host",
39
+ "name or service not known",
40
+ "connection timed out"
41
+ ]):
42
+ raise NoValidConnectionsError(f"SSH connection failed: {e.stderr}")
43
+
44
+ # Check for general SSH protocol errors
45
+ elif "ssh:" in stderr or "protocol" in stderr:
46
+ raise SSHException(f"SSH protocol error: {e.stderr}")
47
+
48
+ # Default to generic SSH exception
49
+ else:
50
+ raise SSHException(f"SSH command failed: {e.stderr}")
12
51
 
13
52
  MINIMUM_SQLITE_VERSION = "3.38.0"
14
53
 
15
54
 
55
+ def _execute_ssh_command(remote_connection: RemoteConnection, command: str) -> str:
56
+ """Execute an SSH command and return the output."""
57
+ ssh_cmd = ["ssh"]
58
+
59
+ # Handle non-standard SSH port
60
+ if remote_connection.port != 22:
61
+ ssh_cmd.extend(["-p", str(remote_connection.port)])
62
+
63
+ ssh_cmd.extend([
64
+ f"{remote_connection.username}@{remote_connection.host}",
65
+ command
66
+ ])
67
+
68
+ try:
69
+ result = subprocess.run(
70
+ ssh_cmd,
71
+ capture_output=True,
72
+ text=True,
73
+ check=True,
74
+ timeout=30
75
+ )
76
+ return result.stdout
77
+ except subprocess.CalledProcessError as e:
78
+ if e.returncode == 255: # SSH protocol errors
79
+ handle_ssh_subprocess_error(e, remote_connection)
80
+ # This line should never be reached as handle_ssh_subprocess_error raises an exception
81
+ raise RemoteSqliteException(
82
+ message=f"SSH command failed: {e.stderr}",
83
+ status=ConnectionTestStates.FAILED,
84
+ )
85
+ else:
86
+ raise RemoteSqliteException(
87
+ message=f"SSH command failed: {e.stderr}",
88
+ status=ConnectionTestStates.FAILED,
89
+ )
90
+ except subprocess.TimeoutExpired:
91
+ raise RemoteSqliteException(
92
+ message=f"SSH command timed out: {command}",
93
+ status=ConnectionTestStates.FAILED,
94
+ )
95
+
96
+
16
97
  def find_sqlite_binary(connection):
17
98
  """Check if SQLite is installed on the remote machine and return its path."""
18
- ssh_client = get_client(connection)
19
99
  try:
20
- stdin, stdout, stderr = ssh_client.exec_command("which sqlite3")
21
- binary_path = stdout.read().decode().strip()
22
- error = stderr.read().decode().strip()
100
+ output = _execute_ssh_command(connection, "which sqlite3")
101
+ binary_path = output.strip()
23
102
  if binary_path:
24
103
  print(f"SQLite binary found at: {binary_path}")
25
104
  return binary_path
26
- elif error:
27
- print(f"Error checking SQLite binary: {error}")
28
105
  return None
106
+ except RemoteSqliteException:
107
+ # Re-raise RemoteSqliteException as-is
108
+ raise
29
109
  except Exception as e:
30
110
  raise RemoteSqliteException(
31
111
  message=f"Error finding SQLite binary: {str(e)}",
@@ -33,17 +113,13 @@ def find_sqlite_binary(connection):
33
113
  )
34
114
 
35
115
 
36
- def is_sqlite_executable(ssh_client, binary_path):
116
+ def is_sqlite_executable(remote_connection: RemoteConnection, binary_path):
37
117
  """Check if the SQLite binary is executable by trying to run it."""
38
118
  try:
39
- stdin, stdout, stderr = ssh_client.exec_command(f"{binary_path} --version")
40
- output = stdout.read().decode().strip()
41
- error = stderr.read().decode().strip()
42
- stdout.channel.recv_exit_status()
43
- if error:
44
- raise Exception(f"Error while trying to run SQLite binary: {error}")
45
-
46
- version = get_sqlite_version(output)
119
+ output = _execute_ssh_command(remote_connection, f"{binary_path} --version")
120
+ version_output = output.strip()
121
+
122
+ version = get_sqlite_version(version_output)
47
123
  if not is_version_at_least(version, MINIMUM_SQLITE_VERSION):
48
124
  raise Exception(
49
125
  f"SQLite version {version} is below the required minimum of {MINIMUM_SQLITE_VERSION}."
@@ -52,6 +128,9 @@ def is_sqlite_executable(ssh_client, binary_path):
52
128
  print(f"SQLite binary at {binary_path} is executable. Version: {version}")
53
129
  return True
54
130
 
131
+ except RemoteSqliteException:
132
+ # Re-raise RemoteSqliteException as-is
133
+ raise
55
134
  except Exception as e:
56
135
  raise Exception(f"Error checking SQLite executability: {str(e)}")
57
136
 
@@ -76,8 +155,7 @@ def is_version_at_least(version, minimum_version):
76
155
  @remote_exception_handler
77
156
  def check_sqlite_path(remote_connection: RemoteConnection):
78
157
  try:
79
- client = get_client(remote_connection)
80
- is_sqlite_executable(client, remote_connection.sqliteBinaryPath)
158
+ is_sqlite_executable(remote_connection, remote_connection.sqliteBinaryPath)
81
159
  except Exception as e:
82
160
  raise RemoteSqliteException(message=str(e), status=ConnectionTestStates.FAILED)
83
161
 
@@ -1,7 +1,6 @@
1
1
  Flask==3.1.1
2
2
  gunicorn~=22.0.0
3
3
  uvicorn==0.30.1
4
- paramiko~=3.4.0
5
4
  flask_cors==4.0.1
6
5
  pydantic==2.7.3
7
6
  pydantic_core==2.18.4
@@ -16,9 +15,8 @@ wheel
16
15
  build
17
16
  PyYAML==6.0.2
18
17
  python-dotenv==1.0.1
19
- tt-perf-report==1.0.6
18
+ tt-perf-report==1.0.7
20
19
  zstd==1.5.7.0
21
20
 
22
21
  # Dev dependencies
23
22
  mypy
24
- types-paramiko