From 6cff7581a70059d2769ba7a800627c8d864e2a17 Mon Sep 17 00:00:00 2001 From: Aliaksandr Yakutovich Date: Fri, 6 May 2022 11:32:08 +0200 Subject: [PATCH] ComputationalResourcesWidget refresh things after every update (#306) fixes #301. In this PR the ComputationalResourcesWidget's behaviour is improved: After each computer setup the code widget is refreshed. After each code setup, the list of available codes is refreshed. Additionally, there were a few changes to achieve the things mentioned above: - ComputationalResourcesWidget refresh things after each setup. - Simplify ComputationalResourcesWidget by the use of refresh() method defined in the subwidgets. - Simplify `ssh_copy_id` function that raises a RuntimeError in case the connection to a remote computer couldn't be established. The exception is then handled outside. - Define `refresh` method as a part of AiidaCodeSetup. - Use `on_` methods to append function to an internal list. - Implement ssh connection states in `SshComputerSetup` widget. Introduce `ssh_connection_state` trait that contains the current status of the SSH connection. The widget acts according to the value of the trait. Co-authored-by: Carl Simon Adorf Co-authored-by: Jusong Yu --- .../computational_resources.py | 482 ++++++++++-------- aiidalab_widgets_base/databases.py | 2 + aiidalab_widgets_base/utils/__init__.py | 77 +-- notebooks/setup_computer.ipynb | 2 +- 4 files changed, 332 insertions(+), 231 deletions(-) diff --git a/aiidalab_widgets_base/computational_resources.py b/aiidalab_widgets_base/computational_resources.py index 10627bc7d..ce74d6543 100644 --- a/aiidalab_widgets_base/computational_resources.py +++ b/aiidalab_widgets_base/computational_resources.py @@ -1,5 +1,7 @@ +import enum import os import subprocess +import threading from copy import copy from pathlib import Path @@ -14,7 +16,7 @@ from IPython.display import clear_output, display from .databases import ComputationalResourcesDatabaseWidget -from .utils import yield_for_change +from .utils import StatusHTML STYLE = {"description_width": "180px"} LAYOUT = {"width": "400px"} @@ -53,7 +55,7 @@ def __init__(self, description="Select code:", path_to_root="../", **kwargs): description (str): Description to display before the dropdown. """ self.output = ipw.HTML() - + self.setup_message = StatusHTML() self.code_select_dropdown = ipw.Dropdown( description=description, disabled=True, value=None ) @@ -82,35 +84,56 @@ def __init__(self, description="Select code:", path_to_root="../", **kwargs): ) self.ssh_computer_setup = SshComputerSetup() + ipw.dlink( + (self.ssh_computer_setup, "message"), + (self.setup_message, "message"), + ) + ipw.dlink( (self.comp_resources_database, "ssh_config"), (self.ssh_computer_setup, "ssh_config"), ) self.aiida_computer_setup = AiidaComputerSetup() + ipw.dlink( + (self.aiida_computer_setup, "message"), + (self.setup_message, "message"), + ) ipw.dlink( (self.comp_resources_database, "computer_setup"), (self.aiida_computer_setup, "computer_setup"), ) + # Set up AiiDA code. self.aiida_code_setup = AiidaCodeSetup() + ipw.dlink( + (self.aiida_code_setup, "message"), + (self.setup_message, "message"), + ) ipw.dlink( (self.comp_resources_database, "code_setup"), (self.aiida_code_setup, "code_setup"), ) + self.aiida_code_setup.on_setup_code_success(self.refresh) + + # After a successfull computer setup the codes widget should be refreshed. + # E.g. the list of available computers needs to be updated. + self.aiida_computer_setup.on_setup_computer_success( + self.aiida_code_setup.refresh + ) + # Quick setup. quick_setup_button = ipw.Button(description="Quick Setup") quick_setup_button.on_click(self.quick_setup) quick_setup = ipw.VBox( children=[ self.ssh_computer_setup.username, quick_setup_button, - self.ssh_computer_setup.setup_ssh_out, - self.aiida_computer_setup.setup_compupter_out, self.aiida_code_setup.setup_code_out, ] ) + # Detailed setup. detailed_setup = ipw.Accordion( children=[ self.ssh_computer_setup, @@ -129,16 +152,11 @@ def __init__(self, description="Select code:", path_to_root="../", **kwargs): self.refresh() def quick_setup(self, _=None): - def setup_code(): - self.aiida_code_setup.computer.refresh() - self.aiida_code_setup.computer.value = self.aiida_computer_setup.label.value - self.aiida_code_setup.on_setup_code(on_success=self.refresh) - - def setup_code_and_computer(): - self.aiida_computer_setup.on_setup_computer(on_success=setup_code) - + """Go through all the setup steps automatically.""" with self.hold_trait_notifications(): - self.ssh_computer_setup.on_setup_ssh(on_success=setup_code_and_computer) + self.ssh_computer_setup._on_setup_ssh_button_pressed() + if self.aiida_computer_setup.on_setup_computer(): + self.aiida_code_setup.on_setup_code() def _get_codes(self): """Query the list of available codes.""" @@ -216,6 +234,8 @@ def _setup_new_code(self, _=None): """Please select the computer/code from a database to pre-fill the fields below.""" ), self.comp_resources_database, + self.setup_message, + self.ssh_computer_setup.password_box, self.output_tab, ) else: @@ -225,12 +245,58 @@ def _setup_new_code(self, _=None): } +class SshConnectionState(enum.Enum): + waiting_for_input = -1 + enter_password = 0 + success = 1 + keys_already_present = 2 + do_you_want_to_continue = 3 + no_keys = 4 + unknown_hostname = 5 + connection_refused = 6 + end_of_file = 7 + + class SshComputerSetup(ipw.VBox): - """Setup password-free access to a computer.""" + """Setup a passwordless access to a computer.""" ssh_config = traitlets.Dict() + ssh_connection_state = traitlets.UseEnum( + SshConnectionState, allow_none=True, default_value=None + ) + SSH_POSSIBLE_RESPONSES = [ + # order matters! the index will return by pexpect and compare + # with SshConnectionState + "[Pp]assword:", # 0 + "Now try logging into", # 1 + "All keys were skipped because they already exist on the remote system", # 2 + "Are you sure you want to continue connecting (yes/no)?", # 3 + "ERROR: No identities found", # 4 + "Could not resolve hostname", # 5 + "Connection refused", # 6 + pexpect.EOF, # 7 + ] + message = traitlets.Unicode() + password_message = traitlets.Unicode("The passwordless enabling log.") def __init__(self, **kwargs): + + self._ssh_connection_message = None + self._password_message = ipw.HTML() + ipw.dlink((self, "password_message"), (self._password_message, "value")) + self._ssh_password = ipw.Password(layout={"width": "150px"}, disabled=True) + self._continue_with_password_button = ipw.Button( + description="Continue", layout={"width": "100px"}, disabled=True + ) + self._continue_with_password_button.on_click(self._send_password) + + self.password_box = ipw.VBox( + [ + self._password_message, + ipw.HBox([self._ssh_password, self._continue_with_password_button]), + ] + ) + # Username. self.username = ipw.Text( description="SSH username:", layout=LAYOUT, style=STYLE @@ -294,8 +360,7 @@ def __init__(self, **kwargs): # Setup ssh button and output. btn_setup_ssh = ipw.Button(description="Setup ssh") - btn_setup_ssh.on_click(self.on_setup_ssh) - self.setup_ssh_out = ipw.Output() + btn_setup_ssh.on_click(self._on_setup_ssh_button_pressed) children = [ self.hostname, @@ -306,13 +371,12 @@ def __init__(self, **kwargs): self._verification_mode, self._verification_mode_output, btn_setup_ssh, - self.setup_ssh_out, ] super().__init__(children, **kwargs) - @staticmethod - def _ssh_keygen(): + def _ssh_keygen(self): """Generate ssh key pair.""" + self.message = "Generating SSH key pair." fpath = Path.home() / ".ssh" / "id_rsa" keygen_cmd = [ "ssh-keygen", @@ -358,7 +422,7 @@ def _is_in_config(self): def _write_ssh_config(self, private_key_abs_fname=None): """Put host information into the config file.""" fpath = Path.home() / ".ssh" / "config" - print(f"Adding section to {fpath}") + self.message = f"Adding {self.hostname.value} section to {fpath}" with open(fpath, "a") as file: file.write(f"Host {self.hostname.value}\n") file.write(f" User {self.username.value}\n") @@ -375,126 +439,132 @@ def _write_ssh_config(self, private_key_abs_fname=None): file.write(f" IdentityFile {private_key_abs_fname}\n") file.write(" ServerAliveInterval 5\n") - def on_setup_ssh(self, _=None, on_success=None): - with self.setup_ssh_out: - clear_output() - - # Always start by generating a key pair if they are not present. - self._ssh_keygen() - - # If hostname & username are not provided - do not do anything. - if self.hostname.value == "": # check hostname - print("Please specify the computer hostname.") - return - - if self.username.value == "": # check username - print("Please specify your SSH username.") - return - - private_key_abs_fname = None - if self._verification_mode.value == "private_key": - # unwrap private key file and setting temporary private_key content - private_key_abs_fname, private_key_content = self._private_key - if private_key_abs_fname is None: # check private key file - print("Please upload your private key file.") - return - - # write private key in ~/.ssh/ and use the name of upload file, - # if exist, generate random string and append to filename then override current name. - self._add_private_key(private_key_abs_fname, private_key_content) - - if not self._is_in_config(): - self._write_ssh_config(private_key_abs_fname=private_key_abs_fname) - - # sending public key to the main host - @yield_for_change(self._continue_button, "value") - def ssh_copy_id(): - timeout = 30 - print(f"Sending public key to {self.hostname.value}... ", end="") - str_ssh = f"ssh-copy-id {self.hostname.value}" - child = pexpect.spawn(str_ssh) - - expectations = [ - "assword:", # 0 - "Now try logging into", # 1 - "All keys were skipped because they already exist on the remote system", # 2 - "Are you sure you want to continue connecting (yes/no)?", # 3 - "ERROR: No identities found", # 4 - "Could not resolve hostname", # 5 - "Connection refused", # 6 - pexpect.EOF, - ] + def _on_setup_ssh_button_pressed(self, _=None): + # Always start by generating a key pair if they are not present. + self._ssh_keygen() - previous_message, message = None, None - while True: - try: - index = child.expect( - expectations, - timeout=timeout, - ) - - except pexpect.TIMEOUT: - print(f"Exceeded {timeout} s timeout") - return False - - if index == 0: - message = child.before.splitlines()[-1] + child.after - if previous_message != message: - previous_message = message - pwd = ipw.Password(layout={"width": "100px"}) - display( - ipw.HBox( - [ - ipw.HTML(message), - pwd, - self._continue_button, - ] - ) - ) - yield - child.sendline(pwd.value) - - elif index == 1: - print("Success.") - if on_success: - on_success() - break - - elif index == 2: - print("Keys are already present on the remote machine.") - if on_success: - on_success() - break - - elif index == 3: # Adding a new host. - child.sendline("yes") - - elif index == 4: - print( - "Failed\nLooks like the key pair is not present in ~/.ssh folder." - ) - break - - elif index == 5: - print("Failed\nUnknown hostname.") - break - - elif index == 6: - print("Failed\nConnection refused.") - break + # If hostname & username are not provided - do not do anything. + if self.hostname.value == "": # check hostname + self.message = "Please specify the computer hostname." + return False - else: - print("Failed\nUnknown problem.") - print(child.before, child.after) - break - child.close() - yield + if self.username.value == "": # check username + self.message = "Please specify your SSH username." + return False + private_key_abs_fname = None + if self._verification_mode.value == "private_key": + # unwrap private key file and setting temporary private_key content + private_key_abs_fname, private_key_content = self._private_key + if private_key_abs_fname is None: # check private key file + self.message = "Please upload your private key file." + return False + + # Write private key in ~/.ssh/ and use the name of upload file, + # if exist, generate random string and append to filename then override current name. + self._add_private_key(private_key_abs_fname, private_key_content) + + if not self._is_in_config(): + self._write_ssh_config(private_key_abs_fname=private_key_abs_fname) + + # Copy public key on the remote computer. + ssh_connection_thread = threading.Thread(target=self._ssh_copy_id) + ssh_connection_thread.start() + + def _ssh_copy_id(self): + """Run the ssh-copy-id command and follow it until it is completed.""" + timeout = 30 + self.password_message = f"Sending public key to {self.hostname.value}... " + self._ssh_connection_process = pexpect.spawn( + f"ssh-copy-id {self.hostname.value}" + ) + while True: try: - ssh_copy_id() - except StopIteration: - print(f"Unsuccessful attempt to connect to {self.hostname.value}.") - return + idx = self._ssh_connection_process.expect( + self.SSH_POSSIBLE_RESPONSES, + timeout=timeout, + ) + self.ssh_connection_state = SshConnectionState(idx) + except pexpect.TIMEOUT: + self._ssh_password.disabled = True + self._continue_with_password_button.disabled = True + self.password_message = ( + f"Exceeded {timeout} s timeout. Please start again." + ) + break + + # Terminating the process when nothing else can be done. + if self.ssh_connection_state in ( + SshConnectionState.success, + SshConnectionState.keys_already_present, + SshConnectionState.no_keys, + SshConnectionState.unknown_hostname, + SshConnectionState.connection_refused, + SshConnectionState.end_of_file, + ): + break + + self._ssh_connection_message = None + self._ssh_connection_process = None + + def _send_password(self, _=None): + self._ssh_password.disabled = True + self._continue_with_password_button.disabled = True + self._ssh_connection_process.sendline(self._ssh_password.value) + + @traitlets.observe("ssh_connection_state") + def _observe_ssh_connnection_state(self, _=None): + """Observe the ssh connection state and act according to the changes.""" + if self.ssh_connection_state is SshConnectionState.waiting_for_input: + return + if self.ssh_connection_state is SshConnectionState.success: + self.password_message = ( + "The passwordless connection has been set up successfully." + ) + return + if self.ssh_connection_state is SshConnectionState.keys_already_present: + self.password_message = "The passwordless connection has already been setup. Nothing to be done." + return + if self.ssh_connection_state is SshConnectionState.no_keys: + self.password_message = ( + " Failed\nLooks like the key pair is not present in ~/.ssh folder." + ) + return + if self.ssh_connection_state is SshConnectionState.unknown_hostname: + self.password_message = "Failed\nUnknown hostname." + return + if self.ssh_connection_state is SshConnectionState.connection_refused: + self.password_message = "Failed\nConnection refused." + return + if self.ssh_connection_state is SshConnectionState.end_of_file: + self.password_message = ( + "Didn't manage to connect. Please check your username/password." + ) + return + if self.ssh_connection_state is SshConnectionState.enter_password: + self._handle_ssh_password() + elif self.ssh_connection_state is SshConnectionState.do_you_want_to_continue: + self._ssh_connection_process.sendline("yes") + + def _handle_ssh_password(self): + """Send a password to a remote computer.""" + message = ( + self._ssh_connection_process.before.splitlines()[-1] + + self._ssh_connection_process.after + ) + if self._ssh_connection_message == message: + self._ssh_connection_process.sendline(self._ssh_password.value) + else: + self.password_message = ( + f"Please enter {self.username.value}@{self.hostname.value}'s password:" + if message == b"Password:" + else f"Please enter {message.decode('utf-8')}" + ) + self._ssh_password.disabled = False + self._continue_with_password_button.disabled = False + self._ssh_connection_message = message + + self.ssh_connection_state = SshConnectionState.waiting_for_input def _on_verification_mode_change(self, change): """which verification mode is chosen.""" @@ -514,7 +584,7 @@ def _on_verification_mode_change(self, change): @property def _private_key(self): - """unwrap private key file and setting filename and file content""" + """Unwrap private key file and setting filename and file content.""" if self._inp_private_key.value: (fname, _value), *_ = self._inp_private_key.value.items() content = copy(_value["content"]) @@ -569,9 +639,12 @@ class AiidaComputerSetup(ipw.VBox): """Inform AiiDA about a computer.""" computer_setup = traitlets.Dict(allow_none=True) + message = traitlets.Unicode() def __init__(self, **kwargs): + self._on_setup_computer_success = [] + # List of widgets to be displayed. self.label = ipw.Text( value="", @@ -671,7 +744,6 @@ def __init__(self, **kwargs): self.setup_button.on_click(self.on_setup_computer) test_button = ipw.Button(description="Test computer") test_button.on_click(self.test) - self.setup_compupter_out = ipw.Output(layout=LAYOUT) self._test_out = ipw.Output(layout=LAYOUT) # Organize the widgets @@ -690,7 +762,6 @@ def __init__(self, **kwargs): self.prepend_text, self.append_text, self.setup_button, - self.setup_compupter_out, test_button, self._test_out, ] @@ -736,58 +807,59 @@ def _configure_computer(self, computer): computer.configure(**authparams) return True - def on_setup_computer(self, _=None, on_success=None): + def on_setup_computer(self, _=None): """Create a new computer.""" - with self.setup_compupter_out: - clear_output() - - if self.label.value == "": # check hostname - print("Please specify the computer name (for AiiDA)") - return - try: - computer = orm.Computer.objects.get(label=self.label.value) - print(f"A computer called {self.label.value} already exists.") - if on_success: - on_success() - return - except common.NotExistent: - pass - - items_to_configure = [ - "label", - "hostname", - "description", - "work_dir", - "mpirun_command", - "mpiprocs_per_machine", - "transport", - "scheduler", - "prepend_text", - "append_text", - "shebang", - ] - kwargs = {key: getattr(self, key).value for key in items_to_configure} + if self.label.value == "": # check hostname + self.message = "Please specify the computer name (for AiiDA)" + return False + try: + computer = orm.Computer.objects.get(label=self.label.value) + self.message = f"A computer called {self.label.value} already exists." + for function in self._on_setup_computer_success: + function() + return True + except common.NotExistent: + pass + + items_to_configure = [ + "label", + "hostname", + "description", + "work_dir", + "mpirun_command", + "mpiprocs_per_machine", + "transport", + "scheduler", + "prepend_text", + "append_text", + "shebang", + ] + kwargs = {key: getattr(self, key).value for key in items_to_configure} + + computer_builder = ComputerBuilder(**kwargs) + try: + computer = computer_builder.new() + except ( + ComputerBuilder.ComputerValidationError, + common.exceptions.ValidationError, + ) as err: + self.message = f"{type(err).__name__}: {err}" + return False - computer_builder = ComputerBuilder(**kwargs) - try: - computer = computer_builder.new() - except ( - ComputerBuilder.ComputerValidationError, - common.exceptions.ValidationError, - ) as err: - print(f"{type(err).__name__}: {err}") - return + try: + computer.store() + except common.exceptions.ValidationError as err: + self.message = f"Unable to store the computer: {err}." + return False - try: - computer.store() - except common.exceptions.ValidationError as err: - print(f"Unable to store the computer: {err}.") - return + if self._configure_computer(computer): + for function in self._on_setup_computer_success: + function() + self.message = f"Computer<{computer.pk}> {computer.label} created" + return True - if self._configure_computer(computer): - if on_success: - on_success() - print(f"Computer<{computer.pk}> {computer.label} created") + def on_setup_computer_success(self, function): + self._on_setup_computer_success.append(function) def test(self, _=None): with self._test_out: @@ -834,9 +906,12 @@ class AiidaCodeSetup(ipw.VBox): """Class that allows to setup AiiDA code""" code_setup = traitlets.Dict(allow_none=True) + message = traitlets.Unicode() def __init__(self, path_to_root="../", **kwargs): + self._on_setup_code_success = [] + # Code label. self.label = ipw.Text( description="AiiDA code label:", @@ -909,14 +984,14 @@ def _validate_input_plugin(self, proposal): plugin = proposal["value"] return plugin if plugin in self.input_plugin.options else None - def on_setup_code(self, _=None, on_success=None): + def on_setup_code(self, _=None): """Setup an AiiDA code.""" with self.setup_code_out: clear_output() if not self.computer.value: - print("Please select an existing computer.") - return + self.message = "Please select an existing computer." + return False items_to_configure = [ "label", @@ -940,28 +1015,33 @@ def on_setup_code(self, _=None, on_success=None): orm.Code, with_computer="computer", filters={"label": kwargs["label"]} ) if qb.count() > 0: - print( + self.message = ( f"Code {kwargs['label']}@{kwargs['computer'].label} already exists." ) - return + return False try: code = CodeBuilder(**kwargs).new() except (common.exceptions.InputValidationError, KeyError) as exception: - print(f"Invalid inputs: {exception}") - return + self.message = f"Invalid inputs: {exception}" + return False try: code.store() code.reveal() except common.exceptions.ValidationError as exception: - print(f"Unable to store the Code: {exception}") - return + self.message = f"Unable to store the Code: {exception}" + return False + + for function in self._on_setup_code_success: + function() - if on_success: - on_success() + self.message = f"Code<{code.pk}> {code.full_label} created" - print(f"Code<{code.pk}> {code.full_label} created") + return True + + def on_setup_code_success(self, function): + self._on_setup_code_success.append(function) def _reset(self): self.label.value = "" @@ -971,6 +1051,10 @@ def _reset(self): self.prepend_text.value = "" self.append_text.value = "" + def refresh(self): + self.computer.refresh() + self._observe_code_setup() + @traitlets.observe("code_setup") def _observe_code_setup(self, _=None): # Setup. diff --git a/aiidalab_widgets_base/databases.py b/aiidalab_widgets_base/databases.py index f2b9331d9..55f7f1ec3 100644 --- a/aiidalab_widgets_base/databases.py +++ b/aiidalab_widgets_base/databases.py @@ -558,6 +558,8 @@ def _computer_changed(self, _=None): "configure": config, } + self._code_changed() + def _code_changed(self, _=None): """Update code settings.""" with self.hold_trait_notifications(): diff --git a/aiidalab_widgets_base/utils/__init__.py b/aiidalab_widgets_base/utils/__init__.py index a50b8b71d..90e489a4e 100644 --- a/aiidalab_widgets_base/utils/__init__.py +++ b/aiidalab_widgets_base/utils/__init__.py @@ -1,8 +1,10 @@ """Some utility functions used acrross the repository.""" -from functools import wraps +import threading +import ipywidgets as ipw import more_itertools as mit import numpy as np +import traitlets from ase.io import read @@ -86,36 +88,6 @@ def string_range_to_list(strng, shift=-1): return singles, True -def yield_for_change(widget, attribute): - """Pause a generator to wait for a widget change event. - - Taken from: https://ipywidgets.readthedocs.io/en/7.6.5/examples/Widget%20Asynchronous.html#Generator-approach - - This is a decorator for a generator function which pauses the generator on yield - until the given widget attribute changes. The new value of the attribute is - sent to the generator and is the value of the yield. - """ - - def f(iterator): - @wraps(iterator) - def inner(): - i = iterator() - - def next_i(change): - try: - i.send(change.new) - except StopIteration: - widget.unobserve(next_i, attribute) - - widget.observe(next_i, attribute) - # start the generator - next(i) - - return inner - - return f - - class PinholeCamera: def __init__(self, matrix): self.matrix = np.reshape(matrix, (4, 4)).transpose() @@ -131,3 +103,46 @@ def screen_to_vector(self, move_vector): @property def inverse_matrix(self): return np.linalg.inv(self.matrix) + + +class _StatusWidgetMixin(traitlets.HasTraits): + """Show temporary messages for example for status updates. + This is a mixin class that is meant to be part of an inheritance + tree of an actual widget with a 'value' traitlet that is used + to convey a status message. See the non-private classes below + for examples. + """ + + message = traitlets.Unicode(default_value="", allow_none=True) + + def __init__(self, *args, **kwargs): + self._clear_timer = None + self._message_stack = [] + super().__init__(*args, **kwargs) + + def _clear_value(self): + """Set widget .value to be an empty string.""" + if self._message_stack: + self._message_stack.pop(0) + self.value = "
".join(self._message_stack) + else: + self.value = "" + + def show_temporary_message(self, value, clear_after=3): + """Show a temporary message and clear it after the given interval.""" + self._message_stack.append(value) + self.value = "
".join(self._message_stack) + + # Start new timer that will clear the value after the specified interval. + self._clear_timer = threading.Timer(clear_after, self._clear_value) + self._clear_timer.start() + + +class StatusHTML(_StatusWidgetMixin, ipw.HTML): + """Show temporary HTML messages for example for status updates.""" + + # This method should be part of _StatusWidgetMixin, but that does not work + # for an unknown reason. + @traitlets.observe("message") + def _observe_message(self, change): + self.show_temporary_message(change["new"]) diff --git a/notebooks/setup_computer.ipynb b/notebooks/setup_computer.ipynb index b961c9018..883d17a21 100644 --- a/notebooks/setup_computer.ipynb +++ b/notebooks/setup_computer.ipynb @@ -125,7 +125,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.7" + "version": "3.7.9" } }, "nbformat": 4,