sl-shared-assets 2.0.1__py3-none-any.whl → 3.0.0rc2__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.

Potentially problematic release.


This version of sl-shared-assets might be problematic. Click here for more details.

@@ -1,5 +1,6 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import field, dataclass
2
2
 
3
+ from _typeshed import Incomplete
3
4
  from ataraxis_data_structures import YamlConfig
4
5
 
5
6
  @dataclass()
@@ -50,11 +51,12 @@ class LickTrainingDescriptor(YamlConfig):
50
51
  experimenter: str
51
52
  mouse_weight_g: float
52
53
  dispensed_water_volume_ml: float
53
- minimum_reward_delay: int
54
+ minimum_reward_delay_s: int
54
55
  maximum_reward_delay_s: int
55
56
  maximum_water_volume_ml: float
56
57
  maximum_training_time_m: int
57
58
  maximum_unconsumed_rewards: int = ...
59
+ system_state_codes: dict[str, int] = field(default_factory=Incomplete)
58
60
  experimenter_notes: str = ...
59
61
  experimenter_given_water_volume_ml: float = ...
60
62
  incomplete: bool = ...
@@ -77,6 +79,7 @@ class RunTrainingDescriptor(YamlConfig):
77
79
  maximum_training_time_m: int
78
80
  maximum_unconsumed_rewards: int = ...
79
81
  maximum_idle_time_s: float = ...
82
+ system_state_codes: dict[str, int] = field(default_factory=Incomplete)
80
83
  experimenter_notes: str = ...
81
84
  experimenter_given_water_volume_ml: float = ...
82
85
  incomplete: bool = ...
@@ -89,6 +92,7 @@ class MesoscopeExperimentDescriptor(YamlConfig):
89
92
  mouse_weight_g: float
90
93
  dispensed_water_volume_ml: float
91
94
  maximum_unconsumed_rewards: int = ...
95
+ system_state_codes: dict[str, int] = field(default_factory=Incomplete)
92
96
  experimenter_notes: str = ...
93
97
  experimenter_given_water_volume_ml: float = ...
94
98
  incomplete: bool = ...
@@ -299,23 +299,21 @@ class ProcessedData:
299
299
  behavior_data_path: Path = Path()
300
300
  """Stores the path to the directory that contains the non-video and non-brain-activity data extracted from
301
301
  .npz log files by our in-house log parsing pipeline."""
302
- job_logs_path: Path = Path()
303
- """Stores the path to the directory that stores the standard output and standard error data collected during
304
- server-side data processing pipeline runtimes. This directory is primarily used when running data processing jobs
305
- on the remote server. However, it is possible to configure local runtimes to also redirect log data to files
306
- stored in this directory (by editing ataraxis-base-utilities 'console' variable)."""
307
302
  suite2p_processing_tracker_path: Path = Path()
308
303
  """Stores the path to the suite2p_processing_tracker.yaml tracker file. This file stores the current state of the
309
304
  sl-suite2p single-day data processing pipeline."""
310
- dataset_formation_tracker_path: Path = Path()
311
- """Same as suite2p_processing_tracker_path, but stores the current state of the dataset formation process that
312
- includes this session (communicates whether the session has been successfully added to any dataset(s))."""
313
305
  behavior_processing_tracker_path: Path = Path()
314
306
  """Stores the path to the behavior_processing_tracker.yaml file. This file stores the current state of the
315
307
  behavior (log) data processing pipeline."""
316
308
  video_processing_tracker_path: Path = Path()
317
309
  """Stores the path to the video_processing_tracker.yaml file. This file stores the current state of the video
318
310
  tracking (DeepLabCut) processing pipeline."""
311
+ p53_path: Path = Path()
312
+ """Stores the path to the p53.bin file. This file serves as a lock-in marker that determines whether the session is
313
+ in the processing or dataset mode. Specifically, if the file does not exist, the session data cannot be integrated
314
+ into any dataset, as it may be actively worked on by processing pipelines. Conversely, if the marker exists,
315
+ processing pipelines are not allowed to work with the session, as it may be actively integrated into one or more
316
+ datasets."""
319
317
 
320
318
  def resolve_paths(self, root_directory_path: Path) -> None:
321
319
  """Resolves all paths managed by the class instance based on the input root directory path.
@@ -333,11 +331,10 @@ class ProcessedData:
333
331
  self.camera_data_path = self.processed_data_path.joinpath("camera_data")
334
332
  self.mesoscope_data_path = self.processed_data_path.joinpath("mesoscope_data")
335
333
  self.behavior_data_path = self.processed_data_path.joinpath("behavior_data")
336
- self.job_logs_path = self.processed_data_path.joinpath("job_logs")
337
334
  self.suite2p_processing_tracker_path = self.processed_data_path.joinpath("suite2p_processing_tracker.yaml")
338
- self.dataset_formation_tracker_path = self.processed_data_path.joinpath("dataset_formation_tracker.yaml")
339
335
  self.behavior_processing_tracker_path = self.processed_data_path.joinpath("behavior_processing_tracker.yaml")
340
336
  self.video_processing_tracker_path = self.processed_data_path.joinpath("video_processing_tracker.yaml")
337
+ self.p53_path = self.processed_data_path.joinpath("p53.bin")
341
338
 
342
339
  def make_directories(self) -> None:
343
340
  """Ensures that all major subdirectories and the root directory exist, creating any missing directories."""
@@ -345,7 +342,6 @@ class ProcessedData:
345
342
  ensure_directory_exists(self.processed_data_path)
346
343
  ensure_directory_exists(self.camera_data_path)
347
344
  ensure_directory_exists(self.behavior_data_path)
348
- ensure_directory_exists(self.job_logs_path)
349
345
 
350
346
 
351
347
  @dataclass
@@ -132,11 +132,10 @@ class ProcessedData:
132
132
  camera_data_path: Path = ...
133
133
  mesoscope_data_path: Path = ...
134
134
  behavior_data_path: Path = ...
135
- job_logs_path: Path = ...
136
135
  suite2p_processing_tracker_path: Path = ...
137
- dataset_formation_tracker_path: Path = ...
138
136
  behavior_processing_tracker_path: Path = ...
139
137
  video_processing_tracker_path: Path = ...
138
+ p53_path: Path = ...
140
139
  def resolve_paths(self, root_directory_path: Path) -> None:
141
140
  """Resolves all paths managed by the class instance based on the input root directory path.
142
141
 
@@ -2,7 +2,7 @@
2
2
  and other compute servers. This package is also used across all Sun lab members private code to interface with the
3
3
  shared server."""
4
4
 
5
- from .job import Job
5
+ from .job import Job, JupyterJob
6
6
  from .server import Server, ServerCredentials, generate_server_credentials
7
7
 
8
- __all__ = ["Server", "ServerCredentials", "generate_server_credentials", "Job"]
8
+ __all__ = ["Server", "ServerCredentials", "generate_server_credentials", "Job", "JupyterJob"]
@@ -1,8 +1,11 @@
1
- from .job import Job as Job
1
+ from .job import (
2
+ Job as Job,
3
+ JupyterJob as JupyterJob,
4
+ )
2
5
  from .server import (
3
6
  Server as Server,
4
7
  ServerCredentials as ServerCredentials,
5
8
  generate_server_credentials as generate_server_credentials,
6
9
  )
7
10
 
8
- __all__ = ["Server", "ServerCredentials", "generate_server_credentials", "Job"]
11
+ __all__ = ["Server", "ServerCredentials", "generate_server_credentials", "Job", "JupyterJob"]
@@ -1,13 +1,51 @@
1
1
  """This module provides the core Job class, used as the starting point for all SLURM-managed job executed on lab compute
2
2
  server(s). Specifically, the Job class acts as a wrapper around the SLURM configuration and specific logic of each
3
3
  job. During runtime, Server class interacts with input job objects to manage their transfer and execution on the
4
- remote servers."""
4
+ remote servers.
5
+
6
+ Since version 3.0.0, this module also provides the specialized JupyterJob class used to launch remote Jupyter
7
+ notebook servers.
8
+ """
5
9
 
6
10
  # noinspection PyProtectedMember
11
+ import re
7
12
  from pathlib import Path
8
13
  import datetime
14
+ from dataclasses import dataclass
9
15
 
16
+ # noinspection PyProtectedMember
10
17
  from simple_slurm import Slurm # type: ignore
18
+ from ataraxis_base_utilities import LogLevel, console
19
+
20
+
21
+ @dataclass
22
+ class _JupyterConnectionInfo:
23
+ """Stores the data used to establish the connection with a Jupyter notebook server running under SLURM control on a
24
+ remote Sun lab server.
25
+
26
+ More specifically, this class is used to transfer the connection metadata collected on the remote server back to
27
+ the local machine that requested the server to be established.
28
+ """
29
+
30
+ compute_node: str
31
+ """The hostname of the compute node where Jupyter is running."""
32
+
33
+ port: int
34
+ """The port number on which Jupyter is listening for communication. Usually, this is the default port 8888 or 9999.
35
+ """
36
+
37
+ token: str
38
+ """The authentication token for the Jupyter server. This token is used to authenticate the user when establishing
39
+ communication with the Jupyter server."""
40
+
41
+ @property
42
+ def localhost_url(self) -> str:
43
+ """Returns the localhost URL for connecting to the server.
44
+
45
+ To use this URL, first set up an SSH tunnel to the server via the specific Jupyter communication port and the
46
+ remote server access credentials.
47
+ """
48
+ return f"http://localhost:{self.port}/?token={self.token}"
11
49
 
12
50
 
13
51
  class Job:
@@ -138,3 +176,193 @@ class Job:
138
176
 
139
177
  # Returns the script content to caller as a string
140
178
  return fixed_script_content
179
+
180
+
181
+ class JupyterJob(Job):
182
+ """Specialized Job instance designed to launch a Jupyter notebook server on SLURM.
183
+
184
+ This class extends the base Job class to include Jupyter-specific configuration and commands for starting a
185
+ notebook server in a SLURM environment. Using this specialized job allows users to set up remote Jupyter servers
186
+ while still benefitting from SLURM's job management and fair airtime policies.
187
+
188
+ Notes:
189
+ Jupyter servers directly compete for resources with headless data processing jobs. Therefore, it is important
190
+ to minimize the resource footprint and the runtime of each Jupyter server, if possible.
191
+
192
+ Args:
193
+ job_name: The descriptive name of the Jupyter SLURM job to be created. Primarily, this name is used in terminal
194
+ printouts to identify the job to human operators.
195
+ output_log: The absolute path to the .txt file on the processing server, where to store the standard output
196
+ data of the job.
197
+ error_log: The absolute path to the .txt file on the processing server, where to store the standard error
198
+ data of the job.
199
+ working_directory: The absolute path to the directory where temporary job files will be stored. During runtime,
200
+ classes from this library use that directory to store files such as the job's shell script. All such files
201
+ are automatically removed from the directory at the end of a non-errors runtime.
202
+ conda_environment: The name of the conda environment to activate on the server before running the job logic. The
203
+ environment should contain the necessary Python packages and CLIs to support running the job's logic. For
204
+ Jupyter jobs, this necessarily includes the Jupyter notebook and jupyterlab packages.
205
+ port: The connection port number for Jupyter server. Do not change the default value unless you know what you
206
+ are doing, as the server has most common communication ports closed for security reasons.
207
+ notebook_directory: The directory to use as Jupyter's root. During runtime, Jupyter will only have access to
208
+ items stored in or under this directory. For most runtimes, this should be set to the user's root data or
209
+ working directory.
210
+ cpus_to_use: The number of CPUs to allocate to the Jupyter server. Keep this value as small as possible to avoid
211
+ interfering with headless data processing jobs.
212
+ ram_gb: The amount of RAM, in GB, to allocate to the Jupyter server. Keep this value as small as possible to
213
+ avoid interfering with headless data processing jobs.
214
+ time_limit: The maximum Jupyter server uptime, in minutes. Set this to the expected duration of your jupyter
215
+ session.
216
+ jupyter_args: Stores additional arguments to pass to jupyter notebook initialization command.
217
+
218
+ Attributes:
219
+ port: Stores the connection port of the managed Jupyter server.
220
+ notebook_dir: Stores the absolute path to the directory used as Jupyter's root, relative to the remote server
221
+ root.
222
+ connection_info: Stores the JupyterConnectionInfo instance after the Jupyter server is instantiated.
223
+ host: Stores the hostname of the remote server.
224
+ user: Stores the username used to connect with the remote server.
225
+ connection_info_file: The absolute path to the file that stores connection information, relative to the remote
226
+ server root.
227
+ _command: Stores the shell command for launching the Jupyter server.
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ job_name: str,
233
+ output_log: Path,
234
+ error_log: Path,
235
+ working_directory: Path,
236
+ conda_environment: str,
237
+ notebook_directory: Path,
238
+ port: int = 9999, # Defaults to using port 9999
239
+ cpus_to_use: int = 2, # Defaults to 2 CPU cores
240
+ ram_gb: int = 32, # Defaults to 32 GB of RAM
241
+ time_limit: int = 120, # Defaults to 2 hours of runtime (120 minutes)
242
+ jupyter_args: str = "",
243
+ ) -> None:
244
+ # Initializes parent Job class
245
+ super().__init__(
246
+ job_name=job_name,
247
+ output_log=output_log,
248
+ error_log=error_log,
249
+ working_directory=working_directory,
250
+ conda_environment=conda_environment,
251
+ cpus_to_use=cpus_to_use,
252
+ ram_gb=ram_gb,
253
+ time_limit=time_limit,
254
+ )
255
+
256
+ # Saves important jupyter configuration parameters to class attributes
257
+ self.port = port
258
+ self.notebook_dir = notebook_directory
259
+
260
+ # Similar to job ID, these attributes initialize to None and are reconfigured as part of the job submission
261
+ # process.
262
+ self.connection_info: _JupyterConnectionInfo | None = None
263
+ self.host: str | None = None
264
+ self.user: str | None = None
265
+
266
+ # Resolves the server-side path to the jupyter server connection info file.
267
+ self.connection_info_file = working_directory.joinpath(f"{job_name}_connection.txt")
268
+
269
+ # Builds Jupyter launch command.
270
+ self._build_jupyter_command(jupyter_args)
271
+
272
+ def _build_jupyter_command(self, jupyter_args: str) -> None:
273
+ """Builds the command to launch Jupyter notebook server on the remote Sun lab server."""
274
+
275
+ # Gets the hostname of the compute node and caches it in the connection data file. Also caches the port name.
276
+ self.add_command('echo "COMPUTE_NODE: $(hostname)" > {}'.format(self.connection_info_file))
277
+ self.add_command('echo "PORT: {}" >> {}'.format(self.port, self.connection_info_file))
278
+
279
+ # Generates a random access token for security and caches it in the connection data file.
280
+ self.add_command("TOKEN=$(openssl rand -hex 24)")
281
+ self.add_command('echo "TOKEN: $TOKEN" >> {}'.format(self.connection_info_file))
282
+
283
+ # Builds Jupyter startup command.
284
+ jupyter_cmd = [
285
+ "jupyter lab",
286
+ "--no-browser",
287
+ f"--port={self.port}",
288
+ "--ip=0.0.0.0", # Listen on all interfaces
289
+ "--ServerApp.allow_origin='*'", # Allow connections from SSH tunnel
290
+ "--ServerApp.allow_remote_access=True", # Enable remote access
291
+ "--ServerApp.disable_check_xsrf=True", # Helps with proxy connections
292
+ f"--ServerApp.root_dir={self.notebook_dir}", # Root directory (not notebook-dir)
293
+ "--IdentityProvider.token=$TOKEN", # Token authentication
294
+ ]
295
+
296
+ # Adds any additional arguments.
297
+ if jupyter_args:
298
+ jupyter_cmd.append(jupyter_args)
299
+
300
+ # Adds resolved jupyter command to the list of job commands.
301
+ jupyter_cmd_str = " ".join(jupyter_cmd)
302
+ self.add_command(jupyter_cmd_str)
303
+
304
+ def parse_connection_info(self, info_file: Path) -> None:
305
+ """Parses the connection information file created by the Jupyter job on the server.
306
+
307
+ Use this method to parse the connection file fetched from the server to finalize setting up the Jupyter
308
+ server job.
309
+
310
+ Args:
311
+ info_file: The path to the .txt file generated by the remote server that stores the Jupyter connection
312
+ information to be parsed.
313
+ """
314
+
315
+ with open(info_file, "r") as f:
316
+ content = f.read()
317
+
318
+ # Extracts information using regex
319
+ compute_node_match = re.search(r"COMPUTE_NODE: (.+)", content)
320
+ port_match = re.search(r"PORT: (\d+)", content)
321
+ token_match = re.search(r"TOKEN: (.+)", content)
322
+
323
+ if not all([compute_node_match, port_match, token_match]):
324
+ message = f"Could not parse connection information file for the Jupyter server job with id {self.job_id}."
325
+ console.error(message, ValueError)
326
+
327
+ # Stores extracted data inside connection_info attribute as a JupyterConnectionInfo instance.
328
+ self.connection_info = _JupyterConnectionInfo(
329
+ compute_node=compute_node_match.group(1).strip(), # type: ignore
330
+ port=int(port_match.group(1)), # type: ignore
331
+ token=token_match.group(1).strip(), # type: ignore
332
+ )
333
+
334
+ def print_connection_info(self) -> None:
335
+ """Constructs and displays the command to set up the SSH tunnel to the server and the link to the localhost
336
+ server view in the terminal.
337
+
338
+ The SSH command should be used via a separate terminal or subprocess call to establish the secure SSH tunnel to
339
+ the Jupyter server. Once the SSH tunnel is established, the printed localhost url can be used to view the
340
+ server from the local machine.
341
+ """
342
+
343
+ # If connection information is not available, there is nothing to print
344
+ if self.connection_info is None:
345
+ console.echo(
346
+ message=(
347
+ f"No connection information is available for the job {self.job_name}, which indicates that the job "
348
+ f"has not been submitted to the server. Submit the job for execution to the remote Sun lab server "
349
+ f"to generate the connection information"
350
+ ),
351
+ level=LogLevel.WARNING,
352
+ )
353
+ return # No connection information available, so does not proceed with printing.
354
+
355
+ # Prints generic connection details to terminal
356
+ console.echo(f"Jupyter is running on: {self.connection_info.compute_node}")
357
+ console.echo(f"Port: {self.connection_info.port}")
358
+ console.echo(f"Token: {self.connection_info.token}")
359
+
360
+ # Constructs and displays the SSH tunnel command and the localhost url for connecting to the server
361
+ tunnel_cmd = (
362
+ f"ssh -N -L {self.connection_info.port}:{self.connection_info.compute_node}:{self.connection_info.port} "
363
+ f"{self.user}@{self.host}"
364
+ )
365
+ localhost_url = f"http://localhost:{self.connection_info.port}/?token={self.connection_info.token}"
366
+ print(f"\nTo access locally, run this in a terminal:")
367
+ print(tunnel_cmd)
368
+ print(f"\nThen open: {localhost_url}")
@@ -1,8 +1,29 @@
1
1
  from pathlib import Path
2
+ from dataclasses import dataclass
2
3
 
3
4
  from _typeshed import Incomplete
4
5
  from simple_slurm import Slurm
5
6
 
7
+ @dataclass
8
+ class _JupyterConnectionInfo:
9
+ """Stores the data used to establish the connection with a Jupyter notebook server running under SLURM control on a
10
+ remote Sun lab server.
11
+
12
+ More specifically, this class is used to transfer the connection metadata collected on the remote server back to
13
+ the local machine that requested the server to be established.
14
+ """
15
+
16
+ compute_node: str
17
+ port: int
18
+ token: str
19
+ @property
20
+ def localhost_url(self) -> str:
21
+ """Returns the localhost URL for connecting to the server.
22
+
23
+ To use this URL, first set up an SSH tunnel to the server via the specific Jupyter communication port and the
24
+ remote server access credentials.
25
+ """
26
+
6
27
  class Job:
7
28
  """Aggregates the data of a single SLURM-managed job to be executed on the Sun lab BioHPC cluster.
8
29
 
@@ -92,3 +113,93 @@ class Job:
92
113
  executed on the remote compute server. Do not call this method manually unless you know what you are doing.
93
114
  The returned string is safe to dump into a .sh (shell script) file and move to the BioHPC server for execution.
94
115
  """
116
+
117
+ class JupyterJob(Job):
118
+ """Specialized Job instance designed to launch a Jupyter notebook server on SLURM.
119
+
120
+ This class extends the base Job class to include Jupyter-specific configuration and commands for starting a
121
+ notebook server in a SLURM environment. Using this specialized job allows users to set up remote Jupyter servers
122
+ while still benefitting from SLURM's job management and fair airtime policies.
123
+
124
+ Notes:
125
+ Jupyter servers directly compete for resources with headless data processing jobs. Therefore, it is important
126
+ to minimize the resource footprint and the runtime of each Jupyter server, if possible.
127
+
128
+ Args:
129
+ job_name: The descriptive name of the Jupyter SLURM job to be created. Primarily, this name is used in terminal
130
+ printouts to identify the job to human operators.
131
+ output_log: The absolute path to the .txt file on the processing server, where to store the standard output
132
+ data of the job.
133
+ error_log: The absolute path to the .txt file on the processing server, where to store the standard error
134
+ data of the job.
135
+ working_directory: The absolute path to the directory where temporary job files will be stored. During runtime,
136
+ classes from this library use that directory to store files such as the job's shell script. All such files
137
+ are automatically removed from the directory at the end of a non-errors runtime.
138
+ conda_environment: The name of the conda environment to activate on the server before running the job logic. The
139
+ environment should contain the necessary Python packages and CLIs to support running the job's logic. For
140
+ Jupyter jobs, this necessarily includes the Jupyter notebook and jupyterlab packages.
141
+ port: The connection port number for Jupyter server. Do not change the default value unless you know what you
142
+ are doing, as the server has most common communication ports closed for security reasons.
143
+ notebook_directory: The directory to use as Jupyter's root. During runtime, Jupyter will only have access to
144
+ items stored in or under this directory. For most runtimes, this should be set to the user's root data or
145
+ working directory.
146
+ cpus_to_use: The number of CPUs to allocate to the Jupyter server. Keep this value as small as possible to avoid
147
+ interfering with headless data processing jobs.
148
+ ram_gb: The amount of RAM, in GB, to allocate to the Jupyter server. Keep this value as small as possible to
149
+ avoid interfering with headless data processing jobs.
150
+ time_limit: The maximum Jupyter server uptime, in minutes. Set this to the expected duration of your jupyter
151
+ session.
152
+ jupyter_args: Stores additional arguments to pass to jupyter notebook initialization command.
153
+
154
+ Attributes:
155
+ port: Stores the connection port of the managed Jupyter server.
156
+ notebook_dir: Stores the absolute path to the directory used as Jupyter's root, relative to the remote server
157
+ root.
158
+ connection_info: Stores the JupyterConnectionInfo instance after the Jupyter server is instantiated.
159
+ host: Stores the hostname of the remote server.
160
+ user: Stores the username used to connect with the remote server.
161
+ connection_info_file: The absolute path to the file that stores connection information, relative to the remote
162
+ server root.
163
+ _command: Stores the shell command for launching the Jupyter server.
164
+ """
165
+
166
+ port: Incomplete
167
+ notebook_dir: Incomplete
168
+ connection_info: _JupyterConnectionInfo | None
169
+ host: str | None
170
+ user: str | None
171
+ connection_info_file: Incomplete
172
+ def __init__(
173
+ self,
174
+ job_name: str,
175
+ output_log: Path,
176
+ error_log: Path,
177
+ working_directory: Path,
178
+ conda_environment: str,
179
+ notebook_directory: Path,
180
+ port: int = 9999,
181
+ cpus_to_use: int = 2,
182
+ ram_gb: int = 32,
183
+ time_limit: int = 120,
184
+ jupyter_args: str = "",
185
+ ) -> None: ...
186
+ def _build_jupyter_command(self, jupyter_args: str) -> None:
187
+ """Builds the command to launch Jupyter notebook server on the remote Sun lab server."""
188
+ def parse_connection_info(self, info_file: Path) -> None:
189
+ """Parses the connection information file created by the Jupyter job on the server.
190
+
191
+ Use this method to parse the connection file fetched from the server to finalize setting up the Jupyter
192
+ server job.
193
+
194
+ Args:
195
+ info_file: The path to the .txt file generated by the remote server that stores the Jupyter connection
196
+ information to be parsed.
197
+ """
198
+ def print_connection_info(self) -> None:
199
+ """Constructs and displays the command to set up the SSH tunnel to the server and the link to the localhost
200
+ server view in the terminal.
201
+
202
+ The SSH command should be used via a separate terminal or subprocess call to establish the secure SSH tunnel to
203
+ the Jupyter server. Once the SSH tunnel is established, the printed localhost url can be used to view the
204
+ server from the local machine.
205
+ """