From bc15f7eb0c8af1f545584d628b56f561909436d2 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Thu, 11 Dec 2025 17:58:13 +0000 Subject: [PATCH 1/3] First pass of removing files and attributes related to the Murfey TUI app --- pyproject.toml | 2 - src/murfey/client/customlogging.py | 24 - src/murfey/client/rsync.py | 13 - src/murfey/client/tui/__init__.py | 0 src/murfey/client/tui/app.py | 860 ------------------ src/murfey/client/tui/controller.css | 524 ----------- src/murfey/client/tui/launcher.css | 25 - src/murfey/client/tui/main.py | 353 -------- src/murfey/client/tui/progress.py | 159 ---- src/murfey/client/tui/screens.py | 1260 -------------------------- src/murfey/client/tui/status_bar.py | 67 -- src/murfey/client/watchdir.py | 12 +- tests/client/test_watchdir.py | 1 - tests/client/tui/test_main.py | 71 -- 14 files changed, 1 insertion(+), 3370 deletions(-) delete mode 100644 src/murfey/client/tui/__init__.py delete mode 100644 src/murfey/client/tui/app.py delete mode 100644 src/murfey/client/tui/controller.css delete mode 100644 src/murfey/client/tui/launcher.css delete mode 100644 src/murfey/client/tui/main.py delete mode 100644 src/murfey/client/tui/progress.py delete mode 100644 src/murfey/client/tui/screens.py delete mode 100644 src/murfey/client/tui/status_bar.py diff --git a/pyproject.toml b/pyproject.toml index 826df4c94..5b1c46a82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,6 @@ Documentation = "https://github.com/DiamondLightSource/python-murfey" GitHub = "https://github.com/DiamondLightSource/python-murfey" [project.scripts] "murfey.add_user" = "murfey.cli.add_user:run" -"murfey.client" = "murfey.client.tui.main:run" "murfey.create_db" = "murfey.cli.create_db:run" "murfey.db_sql" = "murfey.cli.murfey_db_sql:run" "murfey.decrypt_password" = "murfey.cli.decrypt_db_password:run" @@ -121,7 +120,6 @@ include-package-data = true zip-safe = false [tool.setuptools.package-data] -"murfey.client.tui" = ["*.css"] "murfey.util" = ["route_manifest.yaml"] [tool.setuptools.packages.find] diff --git a/src/murfey/client/customlogging.py b/src/murfey/client/customlogging.py index 5ced13e15..567cf7b7e 100644 --- a/src/murfey/client/customlogging.py +++ b/src/murfey/client/customlogging.py @@ -3,10 +3,6 @@ import json import logging -from rich.logging import RichHandler - -from murfey.client.tui.screens import LogBook - logger = logging.getLogger("murfey.client.customlogging") @@ -30,23 +26,3 @@ def emit(self, record): self._callback(self.prepare(record)) except Exception: self.handleError(record) - - -class DirectableRichHandler(RichHandler): - def __init__(self, text_log: LogBook | None = None, **kwargs): - super().__init__(**kwargs) - self.text_log = text_log - self.redirect = False - self._last_time = None - - def emit(self, record): - try: - if self.text_log: - message = self.format(record) - message_renderable = self.render_message(record, message) - log_renderable = self.render( - record=record, traceback=None, message_renderable=message_renderable - ) - self.text_log.post_message(self.text_log.Log(log_renderable)) - except Exception: - self.handleError(record) diff --git a/src/murfey/client/rsync.py b/src/murfey/client/rsync.py index b59321471..f9729cf0b 100644 --- a/src/murfey/client/rsync.py +++ b/src/murfey/client/rsync.py @@ -19,7 +19,6 @@ from typing import Awaitable, Callable, List, NamedTuple from urllib.parse import ParseResult -from murfey.client.tui.status_bar import StatusBar from murfey.util.client import Observer logger = logging.getLogger("murfey.client.rsync") @@ -59,7 +58,6 @@ def __init__( server_url: ParseResult, stop_callback: Callable = lambda *args, **kwargs: None, local: bool = False, - status_bar: StatusBar | None = None, do_transfer: bool = True, remove_files: bool = False, required_substrings_for_removal: List[str] = [], @@ -107,7 +105,6 @@ def __init__( ) self._stopping = False self._halt_thread = False - self._statusbar = status_bar def __repr__(self) -> str: return f" str: def from_rsyncer(cls, rsyncer: RSyncer, **kwargs): kwarguments_from_rsyncer = { "local": rsyncer._local, - "status_bar": rsyncer._statusbar, "do_transfer": rsyncer._do_transfer, "remove_files": rsyncer._remove_files, "notify": rsyncer._notify, } kwarguments_from_rsyncer.update(kwargs) assert isinstance(kwarguments_from_rsyncer["local"], bool) - if kwarguments_from_rsyncer["status_bar"] is not None: - assert isinstance(kwarguments_from_rsyncer["status_bar"], StatusBar) assert isinstance(kwarguments_from_rsyncer["do_transfer"], bool) assert isinstance(kwarguments_from_rsyncer["remove_files"], bool) assert isinstance(kwarguments_from_rsyncer["notify"], bool) @@ -134,7 +128,6 @@ def from_rsyncer(cls, rsyncer: RSyncer, **kwargs): rsyncer._rsync_module, rsyncer._server_url, local=kwarguments_from_rsyncer["local"], - status_bar=kwarguments_from_rsyncer["status_bar"], do_transfer=kwarguments_from_rsyncer["do_transfer"], remove_files=kwarguments_from_rsyncer["remove_files"], notify=kwarguments_from_rsyncer["notify"], @@ -398,12 +391,6 @@ def parse_stdout(line: str): return self._files_transferred += 1 - if self._statusbar: - with self._statusbar.lock: - self._statusbar.transferred = [ - self._statusbar.transferred[0] + 1, - self._statusbar.transferred[1], - ] current_outstanding = self.queue.unfinished_tasks - ( self._files_transferred - previously_transferred ) diff --git a/src/murfey/client/tui/__init__.py b/src/murfey/client/tui/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/murfey/client/tui/app.py b/src/murfey/client/tui/app.py deleted file mode 100644 index 3ab2cb616..000000000 --- a/src/murfey/client/tui/app.py +++ /dev/null @@ -1,860 +0,0 @@ -from __future__ import annotations - -import logging -import subprocess -from datetime import datetime -from functools import partial -from pathlib import Path -from queue import Queue -from typing import Awaitable, Callable, Dict, List, OrderedDict, TypeVar -from urllib.parse import urlparse - -from textual.app import App -from textual.reactive import reactive -from textual.widgets import Button, Input - -from murfey.client.analyser import Analyser -from murfey.client.context import ensure_dcg_exists -from murfey.client.contexts.spa import SPAModularContext -from murfey.client.contexts.tomo import TomographyContext -from murfey.client.destinations import determine_default_destination -from murfey.client.instance_environment import MurfeyInstanceEnvironment -from murfey.client.rsync import RSyncer, RSyncerUpdate, TransferResult -from murfey.client.tui.screens import ( - ConfirmScreen, - InputResponse, - MainScreen, - ProcessingForm, - SessionSelection, - VisitCreation, - VisitSelection, - WaitingScreen, -) -from murfey.client.tui.status_bar import StatusBar -from murfey.client.watchdir import DirWatcher -from murfey.client.watchdir_multigrid import MultigridDirWatcher -from murfey.util import posix_path -from murfey.util.client import ( - capture_delete, - capture_get, - capture_post, - get_machine_config_client, - read_config, - set_default_acquisition_output, -) - -log = logging.getLogger("murfey.tui.app") - -ReactiveType = TypeVar("ReactiveType") - -token = read_config()["Murfey"].get("token", "") -instrument_name = read_config()["Murfey"].get("instrument_name", "") - - -class MurfeyTUI(App): - CSS_PATH = "controller.css" - processing_btn: Button - processing_form: ProcessingForm - hover: List[str] - visits: List[str] - rsync_processes: Dict[Path, RSyncer] = {} - analysers: Dict[Path, Analyser] = {} - _form_values: dict = reactive({}) - - def __init__( - self, - environment: MurfeyInstanceEnvironment | None = None, - visits: List[str] | None = None, - queues: Dict[str, Queue] | None = None, - status_bar: StatusBar | None = None, - dummy_dc: bool = True, - do_transfer: bool = True, - gain_ref: Path | None = None, - redirected_logger=None, - force_mdoc_metadata: bool = False, - processing_enabled: bool = True, - skip_existing_processing: bool = False, - **kwargs, - ): - super().__init__(**kwargs) - self._environment = environment or MurfeyInstanceEnvironment( - urlparse("http://localhost:8000") - ) - self._environment.gain_ref = str(gain_ref) - self._sources = self._environment.sources or [Path(".")] - self._url = self._environment.url - self._default_destinations = self._environment.default_destinations - self.visits = visits or [] - self._queues = queues or {} - self._statusbar = status_bar or StatusBar() - self._request_destinations = False - self._register_dc: bool | None = None - self._tmp_responses: List[dict] = [] - self._visit = "" - self._dc_metadata: dict = {} - self._dummy_dc = dummy_dc - self._do_transfer = do_transfer - self._data_collection_form_complete = False - self._form_readable_labels: dict = {} - self._redirected_logger = redirected_logger - self._multigrid = False - self._processing_enabled = processing_enabled - self._multigrid_watcher: MultigridDirWatcher | None = None - self._force_mdoc_metadata = force_mdoc_metadata - self._skip_existing_processing = skip_existing_processing - self._machine_config = get_machine_config_client( - str(self._environment.url.geturl()), - token, - instrument_name=self._environment.instrument_name, - demo=self._environment.demo, - ) - self._data_suffixes = (".mrc", ".tiff", ".tif", ".eer") - self._data_substrings = [ - s - for val in self._machine_config["data_required_substrings"].values() - for ds in val.values() - for s in ds - ] - self.install_screen(MainScreen(), "main") - - def _launch_multigrid_watcher( - self, source: Path, destination_overrides: Dict[Path, str] | None = None - ): - log.info(f"Launching multigrid watcher for source {source}") - machine_config = get_machine_config_client( - str(self._environment.url.geturl()), - token, - instrument_name=self._environment.instrument_name, - demo=self._environment.demo, - ) - self._multigrid_watcher = MultigridDirWatcher( - source, - machine_config, - skip_existing_processing=self._skip_existing_processing, - ) - self._multigrid_watcher.subscribe( - partial( - self._start_rsyncer_multigrid, - destination_overrides=destination_overrides or {}, - ) - ) - self._multigrid_watcher.start() - - def _start_rsyncer_multigrid( - self, - source: Path, - extra_directory: str = "", - include_mid_path: bool = True, - use_suggested_path: bool = True, - destination_overrides: Dict[Path, str] | None = None, - remove_files: bool = False, - analyse: bool = True, - limited: bool = False, - **kwargs, - ): - log.info(f"starting multigrid rsyncer: {source}") - destination_overrides = destination_overrides or {} - machine_data = capture_get( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - if destination_overrides.get(source): - destination = destination_overrides[source] + f"/{extra_directory}" - else: - for k, v in destination_overrides.items(): - if Path(v).name in source.parts: - destination = str(k / extra_directory) - break - else: - self._environment.default_destinations[source] = ( - f"{datetime.now().year}" - ) - destination = determine_default_destination( - self._visit, - source, - self._default_destinations[source], - self._environment, - self.analysers, - token, - touch=True, - extra_directory=extra_directory, - include_mid_path=include_mid_path, - use_suggested_path=use_suggested_path, - ) - self._environment.sources.append(source) - self._start_rsyncer( - source, - destination, - force_metadata=self._processing_enabled, - # analyse=not extra_directory and use_suggested_path and analyse, - analyse=analyse, - remove_files=remove_files, - limited=limited, - transfer=machine_data.get("data_transfer_enabled", True), - rsync_url=machine_data.get("rsync_url", ""), - ) - - def _start_rsyncer( - self, - source: Path, - destination: str, - visit_path: str = "", - force_metadata: bool = False, - analyse: bool = True, - remove_files: bool = False, - limited: bool = False, - transfer: bool = True, - rsync_url: str = "", - ): - log.info(f"starting rsyncer: {source}") - if transfer: - # Always make sure the destination directory exists - capture_post( - base_url=str(self._url.geturl()), - router_name="file_io_instrument.router", - function_name="make_rsyncer_destination", - token=token, - session_id=self._environment.murfey_session, - data={"destination": destination}, - ) - if self._environment: - self._environment.default_destinations[source] = destination - if self._environment.gain_ref and visit_path: - # Set up rsync command - rsync_cmd = [ - "rsync", - f"{posix_path(Path(self._environment.gain_ref))!r}", - f"{self._url.hostname}::{self._machine_config.get('rsync_module', 'data')}/{visit_path}/processing", - ] - # Encase in bash shell - cmd = [ - "bash", - "-c", - " ".join(rsync_cmd), - ] - # Run rsync subprocess - gain_rsync = subprocess.run(cmd) - if gain_rsync.returncode: - log.warning( - f"Gain reference file {posix_path(Path(self._environment.gain_ref))!r} was not successfully transferred to {visit_path}/processing" - ) - if transfer: - self.rsync_processes[source] = RSyncer( - source, - basepath_remote=Path(destination), - rsync_module=self._machine_config.get("rsync_module", "data"), - server_url=urlparse(rsync_url) if rsync_url else self._url, - # local=self._environment.demo, - status_bar=self._statusbar, - do_transfer=self._do_transfer, - required_substrings_for_removal=self._data_substrings, - remove_files=remove_files, - ) - - def rsync_result(update: RSyncerUpdate): - if not update.base_path: - raise ValueError("No base path from rsyncer update") - if not self.rsync_processes.get(update.base_path): - raise ValueError("TUI rsync process does not exist") - if update.outcome is TransferResult.SUCCESS: - log.debug( - f"Succesfully transferred file {str(update.file_path)!r} ({update.file_size} bytes)" - ) - # pass - else: - log.warning(f"Failed to transfer file {str(update.file_path)!r}") - self.rsync_processes[update.base_path].enqueue(update.file_path) - - self.rsync_processes[source].subscribe(rsync_result) - self.rsync_processes[source].subscribe( - partial( - self._increment_transferred_files_prometheus, - destination=destination, - source=str(source), - ) - ) - self.rsync_processes[source].subscribe( - partial( - self._increment_transferred_files, - destination=destination, - source=str(source), - ), - secondary=True, - ) - rsyncer_data = { - "source": str(source), - "destination": destination, - "session_id": self._environment.murfey_session, - "transferring": self._do_transfer, - } - capture_post( - base_url=str(self._url.geturl()), - router_name="session_control.router", - function_name="register_rsyncer", - token=token, - session_id=self._environment.murfey_session, - data=rsyncer_data, - ) - - self._environment.watchers[source] = DirWatcher(source, settling_time=30) - - if not self.analysers.get(source) and analyse: - log.info(f"Starting analyser for {source}") - self.analysers[source] = Analyser( - source, - token, - environment=self._environment if not self._dummy_dc else None, - force_mdoc_metadata=self._force_mdoc_metadata, - limited=limited, - ) - if force_metadata: - self.analysers[source].subscribe( - partial(self._start_dc, from_form=True) - ) - else: - self.analysers[source].subscribe(self._data_collection_form) - self.analysers[source].start() - if transfer: - self.rsync_processes[source].subscribe(self.analysers[source].enqueue) - - if transfer: - self.rsync_processes[source].start() - - if self._environment: - if self._environment.watchers.get(source): - if transfer: - self._environment.watchers[source].subscribe( - self.rsync_processes[source].enqueue - ) - else: - - def _rsync_update_converter(p: Path) -> None: - self.analysers[source].enqueue( - RSyncerUpdate( - file_path=p, - file_size=0, - outcome=TransferResult.SUCCESS, - transfer_total=0, - queue_size=0, - base_path=source, - ) - ) - return None - - self._environment.watchers[source].subscribe( - _rsync_update_converter - ) - self._environment.watchers[source].subscribe( - partial( - self._increment_file_count, - destination=destination, - source=str(source), - ), - secondary=True, - ) - self._environment.watchers[source].start() - - def _increment_file_count( - self, observed_files: List[Path], source: str, destination: str - ): - if len(observed_files): - num_data_files = len( - [ - f - for f in observed_files - if f.suffix in self._data_suffixes - and any(substring in f.name for substring in self._data_substrings) - ] - ) - data = { - "source": source, - "destination": destination, - "session_id": self._environment.murfey_session, - "increment_count": len(observed_files), - "increment_data_count": num_data_files, - } - capture_post( - base_url=str(self._url.geturl()), - router_name="prometheus.router", - function_name="increment_rsync_file_count", - token=token, - visit_name=self._visit, - data=data, - ) - - # Prometheus can handle higher traffic so update for every transferred file rather - # than batching as we do for the Murfey database updates in _increment_transferred_files - def _increment_transferred_files_prometheus( - self, update: RSyncerUpdate, source: str, destination: str - ): - if update.outcome is TransferResult.SUCCESS: - data_files = ( - [update] - if update.file_path.suffix in self._data_suffixes - and any( - substring in update.file_path.name - for substring in self._data_substrings - ) - else [] - ) - data = { - "source": source, - "destination": destination, - "session_id": self._environment.murfey_session, - "increment_count": 1, - "bytes": update.file_size, - "increment_data_count": len(data_files), - "data_bytes": sum(f.file_size for f in data_files), - } - capture_post( - base_url=str(self._url.geturl()), - router_name="prometheus.router", - function_name="increment_rsync_transferred_files_prometheus", - token=token, - visit_name=self._visit, - data=data, - ) - - def _increment_transferred_files( - self, updates: List[RSyncerUpdate], source: str, destination: str - ): - checked_updates = [ - update for update in updates if update.outcome is TransferResult.SUCCESS - ] - if not checked_updates: - return - data_files = [ - u - for u in updates - if u.file_path.suffix in self._data_suffixes - and any( - substring in u.file_path.name for substring in self._data_substrings - ) - ] - data = { - "source": source, - "destination": destination, - "session_id": self._environment.murfey_session, - "increment_count": len(checked_updates), - "bytes": sum(f.file_size for f in checked_updates), - "increment_data_count": len(data_files), - "data_bytes": sum(f.file_size for f in data_files), - } - capture_post( - base_url=str(self._url.geturl()), - router_name="prometheus.router", - function_name="increment_rsync_transferred_files", - token=token, - visit_name=self._visit, - data=data, - ) - - def _set_register_dc(self, response: str): - if response == "y": - self._register_dc = True - for r in self._tmp_responses: - self._queues["input"].put_nowait( - InputResponse( - question="Data collection parameters:", - form=r.get("form", OrderedDict({})), - model=getattr(self.analyser, "parameters_model", None), - callback=self.app._start_dc_confirm_prompt, - ) - ) - self._dc_metadata = r.get("form", OrderedDict({})) - elif response == "n": - self._register_dc = False - self._tmp_responses = [] - - def _data_collection_form(self, response: dict): - log.info("data collection form ready") - if self._data_collection_form_complete: - return - if self._register_dc and response.get("form"): - self._form_values = {k: str(v) for k, v in response.get("form", {}).items()} - log.info( - f"gain reference is set to {self._form_values.get('gain_ref')}, {self._environment.gain_ref}" - ) - if self._form_values.get("gain_ref") in (None, "None"): - self._form_values["gain_ref"] = self._environment.gain_ref - self.processing_btn.disabled = False - self._data_collection_form_complete = True - elif self._register_dc is None: - self._tmp_responses.append(response) - self._data_collection_form_complete = True - - def _start_dc_confirm_prompt(self, json: dict): - self._queues["input"].put_nowait( - InputResponse( - question="Would you like to start processing with chosen parameters?", - allowed_responses=["y", "n"], - callback=partial(self._start_dc_confirm, json=json), - ) - ) - - def _start_dc(self, metadata_json, from_form: bool = False): - if self._dummy_dc: - return - # for multigrid the analyser sends the message straight to _start_dc by-passing user input - # it is then necessary to extract the data from the message - if from_form: - metadata_json = metadata_json.get("form", {}) - metadata_json = { - k: v if v is None else str(v) for k, v in metadata_json.items() - } - self._environment.dose_per_frame = metadata_json.get("dose_per_frame") - self._environment.gain_ref = metadata_json.get("gain_ref") - self._environment.symmetry = metadata_json.get("symmetry") - self._environment.eer_fractionation = metadata_json.get("eer_fractionation") - source = Path(metadata_json["source"]) - context = self.analysers[source]._context - if context: - context.data_collection_parameters = { - k: None if v == "None" else v for k, v in metadata_json.items() - } - if isinstance(context, TomographyContext): - source = Path(metadata_json["source"]) - context.register_tomography_data_collections( - file_extension=metadata_json["file_extension"], - image_directory=str(self._environment.default_destinations[source]), - environment=self._environment, - ) - - log.info("Registering tomography processing parameters") - if context.data_collection_parameters.get("num_eer_frames"): - eer_response = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="file_io_instrument.router", - function_name="write_eer_fractionation_file", - token=token, - visit_name=self.app._environment.visit, - session_id=self.app._environment.murfey_session, - data={ - "num_frames": context.data_collection_parameters[ - "num_eer_frames" - ], - "fractionation": self.app._environment.eer_fractionation, - "dose_per_frame": self.app._environment.dose_per_frame, - "fractionation_file_name": "eer_fractionation_tomo.txt", - }, - ) - eer_fractionation_file = eer_response.json()["eer_fractionation_file"] - metadata_json.update({"eer_fractionation_file": eer_fractionation_file}) - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.tomo_router", - function_name="register_tomo_proc_params", - token=token, - session_id=self.app._environment.murfey_session, - data=metadata_json, - ) - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.tomo_router", - function_name="flush_tomography_processing", - token=token, - visit_name=self._visit, - session_id=self.app._environment.murfey_session, - data={"rsync_source": str(source)}, - ) - log.info("Tomography processing flushed") - elif isinstance(context, SPAModularContext): - if self._environment.visit in source.parts: - metadata_source = source - else: - metadata_source_as_str = ( - "/".join(source.parts[:-2]) - + f"/{self._environment.visit}/" - + source.parts[-2] - ) - metadata_source = Path(metadata_source_as_str.replace("//", "/")) - ensure_dcg_exists( - collection_type="spa", - metadata_source=metadata_source, - environment=self._environment, - token=self.token, - ) - if from_form: - data = { - "voltage": metadata_json["voltage"], - "pixel_size_on_image": metadata_json["pixel_size_on_image"], - "experiment_type": metadata_json["experiment_type"], - "image_size_x": metadata_json["image_size_x"], - "image_size_y": metadata_json["image_size_y"], - "file_extension": metadata_json["file_extension"], - "acquisition_software": metadata_json["acquisition_software"], - "image_directory": str( - self._environment.default_destinations[source] - ), - "tag": str(source), - "source": str(source), - "magnification": metadata_json["magnification"], - "total_exposed_dose": metadata_json.get("total_exposed_dose"), - "c2aperture": metadata_json.get("c2aperture"), - "exposure_time": metadata_json.get("exposure_time"), - "slit_width": metadata_json.get("slit_width"), - "phase_plate": metadata_json.get("phase_plate", False), - } - capture_post( - base_url=str(self._url.geturl()), - router_name="workflow.router", - function_name="start_dc", - token=token, - visit_name=self._visit, - session_id=self._environment.murfey_session, - data=data, - ) - for recipe in ( - "em-spa-preprocess", - "em-spa-extract", - "em-spa-class2d", - "em-spa-class3d", - "em-spa-refine", - ): - capture_post( - base_url=str(self._url.geturl()), - router_name="workflow.router", - function_name="register_proc", - token=token, - visit_name=self._visit, - session_id=self._environment.murfey_session, - data={ - "tag": str(source), - "source": str(source), - "recipe": recipe, - }, - ) - log.info(f"Posting SPA processing parameters: {metadata_json}") - response = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.spa_router", - function_name="register_spa_proc_params", - token=token, - session_id=self.app._environment.murfey_session, - data={ - **{ - k: None if v == "None" else v - for k, v in metadata_json.items() - }, - "tag": str(source), - }, - ) - if response is None: - log.error( - "Could not reach Murfey server to insert SPA processing parameters" - ) - return None - if not str(response.status_code).startswith("2"): - log.warning(f"{response.reason}") - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.spa_router", - function_name="flush_spa_processing", - token=token, - visit_name=self.app._environment.visit, - session_id=self.app._environment.murfey_session, - data={"tag": str(source)}, - ) - - def _set_request_destination(self, response: str): - if response == "y": - self._request_destinations = True - - async def on_load(self, event): - self.bind("q", "quit", description="Quit", show=True) - self.bind("p", "process", description="Allow processing", show=True) - self.bind( - "d", "remove_session", description="Quit and remove session", show=True - ) - - def _install_processing_form(self): - self.processing_form = ProcessingForm(self._form_values) - self.install_screen(self.processing_form, "processing-form") - - def on_input_submitted(self, event: Input.Submitted): - event.input.has_focus = False - self.screen.focused = None - - async def on_button_pressed(self, event: Button.Pressed): - if event.button._id == "processing-btn": - self._install_processing_form() - self.push_screen("processing-form") - elif event.button._id == "new-visit-btn": - await self.reset() - - async def on_mount(self) -> None: - exisiting_sessions = capture_get( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="get_sessions", - token=token, - ).json() - if self.visits: - self.install_screen(VisitSelection(self.visits), "visit-select-screen") - self.push_screen("visit-select-screen") - else: - self.install_screen(VisitCreation(), "visit-creation-screen") - self.push_screen("visit-creation-screen") - if exisiting_sessions: - self.install_screen( - SessionSelection( - [ - f"{s['session']['id']}: {s['session']['name']}" - for s in exisiting_sessions - ], - [ - f"{s['session']['id']}: {s['session']['name']}" - for s in exisiting_sessions - if s["clients"] - ], - ), - "session-select-screen", - ) - self.push_screen("session-select-screen") - else: - session_name = "Client connection" - resp = capture_post( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="link_client_to_session", - token=token, - instrument_name=self._environment.instrument_name, - client_id=self._environment.client_id, - data={"session_id": None, "session_name": session_name}, - ) - if resp: - self._environment.murfey_session = resp.json() - - def on_log_book_log(self, message): - self.log_book.write(message.renderable) - - async def reset(self): - machine_config = get_machine_config_client( - str(self._environment.url.geturl()), - token, - instrument_name=self._environment.instrument_name, - demo=self._environment.demo, - ) - if self.rsync_processes and machine_config.get("allow_removal"): - sources = "\n".join(str(k) for k in self.rsync_processes.keys()) - prompt = f"Remove files from the following:\n {sources} \n" - rsync_instances = capture_get( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="get_rsyncers_for_session", - token=token, - session_id=self._environment.murfey_session, - ).json() - prompt += f"Copied {sum(r['files_counted'] for r in rsync_instances)} / {sum(r['files_transferred'] for r in rsync_instances)}" - self.install_screen( - WaitingScreen(prompt, sum(r["files_counted"] for r in rsync_instances)), - "waiting", - ) - self.push_screen("waiting") - - async def action_quit(self) -> None: - log.info("quitting app") - - if self.rsync_processes: - for rp in self.rsync_processes.values(): - rp.stop() - if self.analysers: - for a in self.analysers.values(): - a.stop() - if self._multigrid_watcher: - self._multigrid_watcher.stop() - self.exit() - - async def action_remove_session(self) -> None: - capture_delete( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="remove_session", - token=token, - session_id=self._environment.murfey_session, - ) - if self.rsync_processes: - for rp in self.rsync_processes.values(): - rp.stop() - if self.analysers: - for a in self.analysers.values(): - a.stop() - if self._multigrid_watcher: - self._multigrid_watcher.stop() - self.exit() - - def clean_up_quit(self) -> None: - capture_delete( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="remove_session", - token=token, - session_id=self._environment.murfey_session, - ) - self.exit() - - async def action_clear(self) -> None: - machine_config = get_machine_config_client( - str(self._environment.url.geturl()), - token, - instrument_name=self._environment.instrument_name, - demo=self._environment.demo, - ) - if self.rsync_processes and machine_config.get("allow_removal"): - sources = "\n".join(str(k) for k in self.rsync_processes.keys()) - prompt = f"Remove files from the following: {sources}" - self.install_screen( - ConfirmScreen( - prompt, - pressed_callback=self._remove_data, - button_names={"launch": "Yes", "quit": "No"}, - ), - "clear-confirm", - ) - self.push_screen("clear-confirm") - - def _remove_data(self, listener: Callable[..., Awaitable[None] | None], **kwargs): - new_rsyncers = [] - if self.rsync_processes or self._environment.demo: - for k, rp in self.rsync_processes.items(): - rp.stop() - if self.analysers.get(k): - self.analysers[k].stop() - removal_rp = RSyncer.from_rsyncer(rp, remove_files=True, notify=False) - removal_rp.subscribe(listener) - new_rsyncers.append(removal_rp) - log.info( - f"Starting to remove data files {self._environment.demo}, {len(self.rsync_processes)}" - ) - for removal_rp in new_rsyncers: - removal_rp.start() - for f in k.absolute().glob("**/*"): - removal_rp.queue.put(f) - removal_rp.stop() - log.info(f"rsyncer {rp} rerun with removal") - capture_post( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="register_processing_success_in_ispyb", - token=token, - session_id=self._environment.murfey_session, - ) - capture_delete( - base_url=str(self._environment.url.geturl()), - router_name="session_control.router", - function_name="remove_session", - token=token, - session_id=self._environment.murfey_session, - ) - self.exit() - - async def action_process(self) -> None: - self.processing_btn.disabled = False - - def _set_default_acquisition_directories(self, default_dir: Path): - set_default_acquisition_output( - default_dir, self._machine_config["software_settings_output_directories"] - ) diff --git a/src/murfey/client/tui/controller.css b/src/murfey/client/tui/controller.css deleted file mode 100644 index d88bad4f0..000000000 --- a/src/murfey/client/tui/controller.css +++ /dev/null @@ -1,524 +0,0 @@ -Screen { - layout: grid; - grid-size: 6; - padding: 2; - outline: hidden; -} - -LaunchScreen { - layout: grid; - grid-size: 3; - padding: 2; - border: hidden; -} - -GainReference { - layout: grid; - grid-size: 2; - padding: 2; - border: hidden; -} - -ConfirmScreen { - layout: grid; - grid-size: 2; - padding: 2; - border: hidden; -} - -UpstreamDownloads { - layout: grid; - grid-size: 2; - padding: 2; - border: hidden; -} - -WaitingScreen { - layout: grid; - grid_size: 4; - padding: 2; - border: hidden; -} - -SessionSelection { - layout: grid; - grid-size: 4; - border: hidden; -} - -VisitCreation { - layout: grid; - grid-size: 2; - border: hidden; -} - -VisitSelection { - layout: grid; - grid-size: 4; - border: hidden; -} - -DirectorySelection { - layout: grid; - grid-size: 2; - border: hidden; -} - -ProcessingForm { - layout: grid; - grid-size: 1; - border: hidden; -} - -DestinationSelect { - layout: grid; - grid-size: 7; - border: hidden; -} - -Input { - background: black; - border: solid teal; -} - -RadioSet { - width: 100%; -} - -#quit { - width: 100%; - height: 100%; - column-span: 1; - background: teal; - border: solid black; -} - -#quit:hover { - background: deeppink; -} - -#waiting-quit { - width: 100%; - height: 100%; - column-span: 2; - background: teal; - border: solid black; -} - -#waiting-quit:hover { - background: deeppink; -} - -#add { - width: 100%; - height: 100%; - column-span: 1; - background: teal; - border: solid black; -} - -#add:hover { - background: purple; -} - -#launch { - width: 100%; - height: 100%; - column-span: 1; - background: teal; - border: solid black; -} - -#launch:hover { - background: purple; -} - -#waiting-launch { - width: 100%; - height: 100%; - column-span: 2; - background: teal; - border: solid black; -} - -#waiting-launch:hover { - background: purple; -} - -#skip-gain { - width: 100%; - height: 100%; - column-span: 1; - background: teal; - border: solid black; -} - -#skip-gain:hover { - background: purple; -} - -#suggested-gain-ref { - width: 100%; - height: 90fr; - column-span: 2; - background: deeppink; - border: solid black; -} - -#suggested-gain-ref:hover { - background: purple; -} - -#suggested-gain-ref:focus { - background: darkslateblue; -} - -#clear { - width: 100%; - height: 20%; - column-span: 1; - background: teal; - border: solid black; -} - -#clear:hover { - background: purple; -} - -#dir-select { - width: 100%; - height: 100%; - column-span: 2; - row-span: 2; -} - -#launch-text { - width: 100%; - height: 100%; - column-span: 2; - text-align: center; -} - -#prompt { - width: 100%; - height: 100%; - column-span: 2; - text-align: center; -} - -#waiting-prompt { - width: 100%; - height: 100%; - column-span: 4; - text-align: center; -} - -#progress { - width: 100%; - height: 100%; - column-span: 4; - align: center middle; -} - -#input-grid { - width: 100%; - column-span: 2; - row-span: 1; -} - -.btn-visit { - width: 100%; - height: 90fr; - background: teal; - border: solid black; -} - -.btn-visit:hover { - background: purple; -} - -.btn-visit:focus { - background: darkslateblue; -} - -.btn-visit-create { - width: 100%; - height: 90fr; - column-span: 2; - background: teal; - border: solid black; -} - -.btn-visit-create:hover { - background: purple; -} - -.btn-visit-create:focus { - background: darkslateblue; -} - -.btn-session { - width: 100%; - height: 20%; - column-span: 4; - row-span: 3; - background: teal; - border: solid black; -} - -.btn-session:hover { - background: purple; -} - -.btn-session:focus { - background: darkslateblue; -} - -.btn-directory { - width: 100%; - height: 90fr; - background: teal; - border: solid black; -} - -.btn-directory:hover { - background: purple; -} - -.btn-directory:focus { - background: darkslateblue; -} - -#confirm-btn { - width: 100%; - background: teal; - border: solid black; -} - -#confirm-btn:hover { - background: purple; -} - -#confirm-btn:focus { - background: darkslateblue; -} - -#destination-btn { - width: 100%; - height: 90fr; - column-span: 7; - background: teal; - border: solid black; -} - -#destination-btn:hover { - background: purple; -} - -#destination-btn:focus { - background: darkslateblue; -} - -#processing-btn { - width: 100%; - height: 90fr; - column-span: 2; - background: teal; - border: solid black; -} - -#processing-btn:hover { - background: purple; -} - -#new-visit-btn { - width: 100%; - height: 90fr; - column-span: 1; - background: teal; - border: solid black; -} - -#new-visit-btn:hover { - background: purple; -} - -#input { - width: 100%; - height: 100%; - column-span: 2; - row-span: 1; - content-align: left middle; - text-style: bold; - background: blueviolet; -} - -#monitoring { - width: 100%; - height: 90fr; - column-span: 1; -} - -.monitoring-switch { - width: 75%; -} - -#input:hover { - background: blue; -} - -#info { - width: 100%; - height: 100%; - column-span: 3; - row-span: 5; - content-align: left bottom; - text-style: bold; - background: blueviolet; -} - -#select-visit { - width: 100%; - height: 100%; - column-span: 4; - row-span: 3; - content-align: left top; - background: blueviolet; -} - -#switch-visit { - width: 100%; - height: 100%; - column-span: 3; - row-span: 1; -} - -#label-visit { - width: 100%; - height: 100%; - column-span: 1; - row-span: 1; - content-align: center middle; - text-style: bold; -} - -#select-session { - width: 100%; - height: 100%; - column-span: 4; - row-span: 3; - background: blueviolet; -} - -#label-session { - width: 100%; - height: 100%; - column-span: 1; - row-span: 1; - content-align: center middle; - text-style: bold; -} - -#select-directory { - width: 100%; - height: 100%; - column-span: 4; - row-span: 3; - content-align: left top; - background: gray; -} - -#modality-select { - width: 100%; - height: 10%; -} - -#selected-directories { - width: 100%; - height: 65%; - column-span: 1; - row-span: 2; -} - -#switch-directory { - width: 100%; - height: 100%; - column-span: 3; - row-span: 1; -} - -#label-directory { - width: 100%; - height: 100%; - column-span: 1; - row-span: 1; - content-align: center middle; - text-style: bold; -} - -#input-form { - width: 100%; - height: 100%; - background: black; -} - -#selected-directories-vert { - width: 100%; - height: 100%; - row-span: 2; - background: black; -} - -#destination-holder { - width: 100%; - height: 100%; - column-span: 7; - row-span: 3; - background: black; -} - -#user-params { - width: 100%; - height: 100%; - column-span: 7; - row-span: 3; - background: black; -} - -.input-destination { - width: 100%; - background: black; -} - -.input-visit-name { - width: 100%; - height: 100%; - column-span: 2; - row-span: 1; - content-align: left middle; - text-style: bold; - background: blueviolet; -} - -#log_book { - width: 100%; - height: 100%; - column-span: 3; - row-span: 6; - content-align: right top; - text-style: bold; -} - -.label { - width: 100%; - content-align: center middle; -} - -.input { - width: 100%; -} - -#gain-select { - width: 100%; - column-span: 2; -} diff --git a/src/murfey/client/tui/launcher.css b/src/murfey/client/tui/launcher.css deleted file mode 100644 index a204572bd..000000000 --- a/src/murfey/client/tui/launcher.css +++ /dev/null @@ -1,25 +0,0 @@ -Screen { - layout: grid; - grid-size: 2; - padding: 2; - border: hidden; -} - -#quit { - width: 100%; - height: 100%; - column-span: 1; - background: aquamarine; -} - -#launch { - width: 100%; - height: 100%; - column-span: 1; -} - -#launch-text { - width: 100%; - height: 100%; - column-span: 2; -} diff --git a/src/murfey/client/tui/main.py b/src/murfey/client/tui/main.py deleted file mode 100644 index d5949879c..000000000 --- a/src/murfey/client/tui/main.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations - -import argparse -import configparser -import logging -import os -import platform -import shutil -import sys -import time -import webbrowser -from pathlib import Path -from pprint import pprint -from queue import Queue -from typing import Literal -from urllib.parse import ParseResult, urlparse - -from rich.prompt import Confirm - -import murfey.client.update -import murfey.client.watchdir -import murfey.client.websocket -from murfey.client.customlogging import CustomHandler, DirectableRichHandler -from murfey.client.instance_environment import MurfeyInstanceEnvironment -from murfey.client.tui.app import MurfeyTUI -from murfey.client.tui.status_bar import StatusBar -from murfey.util.client import capture_get, read_config -from murfey.util.models import Visit - -log = logging.getLogger("murfey.client") - -token = read_config()["Murfey"].get("token", "") - - -def _get_visit_list(api_base: ParseResult, instrument_name: str): - proxy_path = api_base.path.rstrip("/") - get_visits_url = api_base._replace(path=f"{proxy_path}") - server_reply = capture_get( - base_url=str(get_visits_url.geturl()), - router_name="session_control.router", - function_name="get_current_visits", - token=token, - instrument_name=instrument_name, - ) - if server_reply.status_code != 200: - raise ValueError(f"Server unreachable ({server_reply.status_code})") - return [Visit.model_validate(v) for v in server_reply.json()] - - -def write_config(config: configparser.ConfigParser): - mcch = os.environ.get("MURFEY_CLIENT_CONFIG_HOME") - murfey_client_config_home = Path(mcch) if mcch else Path.home() - with open(murfey_client_config_home / ".murfey", "w") as configfile: - config.write(configfile) - - -def main_loop( - source_watchers: list[murfey.client.watchdir.DirWatcher], - appearance_time: float, - transfer_all: bool, -): - log.info( - f"Murfey {murfey.__version__} on Python {'.'.join(map(str, sys.version_info[0:3]))} entering main loop" - ) - if appearance_time > 0: - modification_time: float | None = time.time() - appearance_time * 3600 - else: - modification_time = None - while True: - for sw in source_watchers: - sw.scan(modification_time=modification_time, transfer_all=transfer_all) - time.sleep(15) - - -def _enable_webbrowser_in_cygwin(): - """Helper function to make webbrowser.open() work in CygWin""" - if "cygwin" in platform.system().lower() and shutil.which("cygstart"): - webbrowser.register("cygstart", None, webbrowser.GenericBrowser("cygstart")) - - -def _check_for_updates( - server: ParseResult, install_version: None | Literal[True] | str -): - if install_version is True: - # User requested installation of the newest version - try: - murfey.client.update.check(server, force=True) - print("\nYou are already running the newest version of Murfey") - exit() - except Exception as e: - exit(f"Murfey update check failed with {e}") - - if install_version: - # User requested installation of a specific version - if murfey.client.update.install_murfey(server, install_version): - print(f"\nMurfey has been updated to version {install_version}") - exit() - else: - exit("Error occurred while updating Murfey") - - # Otherwise run a routine update check to ensure client and server are compatible - try: - murfey.client.update.check(server) - except Exception as e: - print(f"Murfey update check failed with {e}") - - -def run(): - # Load client config and server information - config = read_config() - instrument_name = config["Murfey"]["instrument_name"] - try: - server_routing = config["ServerRouter"] - except KeyError: - server_routing = {} - server_routing_prefix_found = False - if server_routing: - for path_prefix, server in server_routing.items(): - if str(Path.cwd()).startswith(path_prefix): - known_server = server - server_routing_prefix_found = True - break - else: - known_server = None - else: - known_server = config["Murfey"].get("server") - - # Set up argument parser with dynamic defaults based on client config - parser = argparse.ArgumentParser(description="Start the Murfey client") - parser.add_argument( - "--server", - metavar="HOST:PORT", - type=str, - help=f"Murfey server to connect to ({known_server})", - default=known_server, - ) - parser.add_argument("--visit", help="Name of visit") - parser.add_argument( - "--source", help="Directory to transfer files from", type=Path, default="." - ) - parser.add_argument( - "--destination", - help="Directory to transfer files to (syntax: 'data/2022/cm31093-2/tmp/murfey')", - ) - parser.add_argument( - "--update", - metavar="VERSION", - nargs="?", - default=None, - const=True, - help="Update Murfey to the newest or to a specific version", - ) - parser.add_argument( - "--demo", - action="store_true", - ) - parser.add_argument( - "--appearance-time", - type=float, - default=-1, - help="Only consider top level directories that have appeared more recently than this many hours ago", - ) - parser.add_argument( - "--fake-dc", - action="store_true", - default=False, - help="Do not perform data collection related calls to API (avoids database inserts)", - ) - parser.add_argument( - "--time-based-transfer", - action="store_true", - help="Transfer new files", - ) - parser.add_argument( - "--no-transfer", - action="store_true", - help="Avoid actually transferring files", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Turn on debugging logs", - ) - parser.add_argument( - "--local", - action="store_true", - default=False, - help="Perform rsync transfers locally rather than remotely", - ) - parser.add_argument( - "--ignore-mdoc-metadata", - action="store_true", - default=False, - help="Do not attempt to read metadata from all mdoc files", - ) - parser.add_argument( - "--remove-files", - action="store_true", - default=False, - help="Remove source files immediately after their transfer", - ) - parser.add_argument( - "--name", - type=str, - default="", - help="Name of Murfey session to be created", - ) - parser.add_argument( - "--skip-existing-processing", - action="store_true", - default=False, - help="Do not trigger processing for any data directories currently on disk (you may have started processing for them in a previous murfey run)", - ) - args = parser.parse_args() - - # Logic to exit early based on parsed args - if not args.server: - exit("Murfey server not set. Please run with --server host:port") - if not args.server.startswith(("http://", "https://")): - if "://" in args.server: - exit("Unknown server protocol. Only http:// and https:// are allowed") - args.server = f"http://{args.server}" - if args.remove_files: - remove_prompt = Confirm.ask( - f"Are you sure you want to remove files from {args.source or Path('.').absolute()}?" - ) - if not remove_prompt: - exit("Exiting") - - # If a new server URL is provided, save info to config file - murfey_url = urlparse(args.server, allow_fragments=False) - if args.server != known_server: - # New server specified. Verify that it is real - print(f"Attempting to connect to new server {args.server}") - try: - murfey.client.update.check(murfey_url, install=False) - except Exception as e: - exit(f"Could not reach Murfey server at {args.server!r} - {e}") - - # If server is reachable then update the configuration - config["Murfey"]["server"] = args.server - write_config(config) - - # If user requested installation of a specific or a newer version then - # make that happen, otherwise ensure client and server are compatible and - # update if necessary. - _check_for_updates(server=murfey_url, install_version=args.update) - - if args.no_transfer: - log.info("No files will be transferred as --no-transfer flag was specified") - - # Check ISPyB (if set up) for ongoing visits - ongoing_visits = [] - if args.visit: - ongoing_visits = [args.visit] - elif server_routing_prefix_found: - for part in Path.cwd().parts: - if "-" in part: - ongoing_visits = [part] - break - if not ongoing_visits: - print("Ongoing visits:") - ongoing_visits = _get_visit_list(murfey_url, instrument_name) - pprint(ongoing_visits) - ongoing_visits = [v.name for v in ongoing_visits] - - _enable_webbrowser_in_cygwin() - - # Set up additional log handlers - log.setLevel(logging.DEBUG) - log_queue = Queue() - input_queue = Queue() - - # Rich-based console handler - rich_handler = DirectableRichHandler(enable_link_path=False) - rich_handler.setLevel(logging.DEBUG if args.debug else logging.INFO) - - # Set up websocket app and handler - client_id_response = capture_get( - base_url=str(murfey_url.geturl()), - router_name="session_control.router", - function_name="new_client_id", - token=token, - ) - if client_id_response.status_code == 401: - exit( - "This instrument is not authorised to run the TUI app; please use the " - "Murfey web UI instead" - ) - elif client_id_response.status_code != 200: - exit( - "Unable to establish connection to Murfey server: \n" - f"{client_id_response.json()}" - ) - client_id: dict = client_id_response.json() - ws = murfey.client.websocket.WSApp( - server=args.server, - id=client_id["new_id"], - ) - ws_handler = CustomHandler(ws.send) - - # Add additional handlers and set logging levels - logging.getLogger().addHandler(rich_handler) - logging.getLogger().addHandler(ws_handler) - logging.getLogger("murfey").setLevel(logging.INFO) - logging.getLogger("websocket").setLevel(logging.WARNING) - - log.info("Starting Websocket connection") - - # Load machine data for subsequent sections - machine_data = capture_get( - base_url=str(murfey_url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - gain_ref: Path | None = None - - # Set up Murfey environment instance and map it to websocket app - instance_environment = MurfeyInstanceEnvironment( - url=murfey_url, - client_id=ws.id, - instrument_name=instrument_name, - software_versions=machine_data.get("software_versions", {}), - demo=args.demo, - processing_only_mode=server_routing_prefix_found, - rsync_url=( - urlparse(machine_data["rsync_url"]).hostname - if machine_data.get("rsync_url") - else "" - ), - ) - ws.environment = instance_environment - - # Set up and run Murfey TUI app - status_bar = StatusBar() - rich_handler.redirect = True - app = MurfeyTUI( - environment=instance_environment, - visits=ongoing_visits, - queues={"input": input_queue, "logs": log_queue}, - status_bar=status_bar, - dummy_dc=args.fake_dc, - do_transfer=not args.no_transfer, - gain_ref=gain_ref, - redirected_logger=rich_handler, - force_mdoc_metadata=not args.ignore_mdoc_metadata, - processing_enabled=machine_data.get("processing_enabled", True), - skip_existing_processing=args.skip_existing_processing, - ) - app.run() - rich_handler.redirect = False diff --git a/src/murfey/client/tui/progress.py b/src/murfey/client/tui/progress.py deleted file mode 100644 index f23155a49..000000000 --- a/src/murfey/client/tui/progress.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -A progressbar widget that uses block characters rather than a thin -horizontal line. This makes it more visible on Windows. -""" - -from __future__ import annotations - -import math -from functools import lru_cache -from typing import List - -import rich.progress -import rich.progress_bar -from rich.color import Color, blend_rgb -from rich.color_triplet import ColorTriplet -from rich.console import Console, ConsoleOptions, RenderResult -from rich.segment import Segment -from rich.style import Style - -PULSE_SIZE = rich.progress_bar.PULSE_SIZE - - -class BlockProgressBar(rich.progress_bar.ProgressBar): - @lru_cache(maxsize=16) - def _get_pulse_segments( - self, - fore_style: Style, - back_style: Style, - color_system: str, - no_color: bool, - ascii: bool = False, - ) -> List[Segment]: - """Get a list of segments to render a pulse animation. - - Returns: - List[Segment]: A list of segments, one segment per character. - """ - bar = "-" if ascii else "█" - segments: List[Segment] = [] - if color_system not in ("standard", "eight_bit", "truecolor") or no_color: - segments += [Segment(bar, fore_style)] * (PULSE_SIZE // 2) - segments += [Segment(" " if no_color else bar, back_style)] * ( - PULSE_SIZE - (PULSE_SIZE // 2) - ) - return segments - - append = segments.append - fore_color = ( - fore_style.color.get_truecolor() - if fore_style.color - else ColorTriplet(255, 0, 255) - ) - back_color = ( - back_style.color.get_truecolor() - if back_style.color - else ColorTriplet(0, 0, 0) - ) - cos = math.cos - pi = math.pi - _Segment = Segment - _Style = Style - from_triplet = Color.from_triplet - - for index in range(PULSE_SIZE): - position = index / PULSE_SIZE - fade = 0.5 + cos(position * pi * 2) / 2.0 - color = blend_rgb(fore_color, back_color, cross_fade=fade) - append(_Segment(bar, _Style(color=from_triplet(color)))) - return segments - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - width = min(self.width or options.max_width, options.max_width) - ascii = options.ascii_only - if self.pulse: - yield from self._render_pulse(console, width, ascii=ascii) - return - - completed = min(self.total, max(0, self.completed)) - - bar = "-" if ascii else "█" - half_bar_right = " " if ascii else "▌" - half_bar_left = " " if ascii else "▐" - complete_halves = ( - int(width * 2 * completed / self.total) if self.total else width * 2 - ) - bar_count = complete_halves // 2 - half_bar_count = complete_halves % 2 - style = console.get_style(self.style) - complete_style = console.get_style( - self.complete_style if self.completed < self.total else self.finished_style - ) - _Segment = Segment - if bar_count: - yield _Segment(bar * bar_count, complete_style) - if half_bar_count: - yield _Segment(half_bar_right * half_bar_count, complete_style) - - if not console.no_color: - remaining_bars = width - bar_count - half_bar_count - if remaining_bars and console.color_system is not None: - if not half_bar_count and bar_count: - yield _Segment(half_bar_left, style) - remaining_bars -= 1 - if remaining_bars: - yield _Segment(bar * remaining_bars, style) - - -class BlockBarColumn(rich.progress.BarColumn): - def render(self, task: rich.progress.Task) -> rich.progress_bar.ProgressBar: - """Gets a progress bar widget for a task.""" - return BlockProgressBar( - total=max(0, task.total), - completed=max(0, task.completed), - width=None if self.bar_width is None else max(1, self.bar_width), - pulse=not task.started, - animation_time=task.get_time(), - style=self.style, - complete_style=self.complete_style, - finished_style=self.finished_style, - pulse_style=self.pulse_style, - ) - - -if __name__ == "__main__": # pragma: no cover - from rich.theme import Theme - - custom_theme = Theme( - { - "bar.complete": "rgb(249,38,249)", - "bar.finished": "rgb(31,156,31)", - "bar.pulse": "rgb(249,38,249)", - } - ) - console = Console(theme=custom_theme) - steps = 400 - arguments = { - "width": 50, - } - bars = [ - BlockProgressBar(**arguments, total=steps), - rich.progress_bar.ProgressBar(**arguments, total=steps), - BlockProgressBar(**arguments, pulse=True), - rich.progress_bar.ProgressBar(**arguments, pulse=True), - ] - - import time - - console.show_cursor(False) - for n in range(0, steps + 1, 1): - for bar in bars: - bar.update(n) - console.print(bar) - console.print(" ", end="") - console.file.write("\r") - time.sleep(3 / steps) - console.show_cursor(True) - console.print() diff --git a/src/murfey/client/tui/screens.py b/src/murfey/client/tui/screens.py deleted file mode 100644 index a30261cad..000000000 --- a/src/murfey/client/tui/screens.py +++ /dev/null @@ -1,1260 +0,0 @@ -from __future__ import annotations - -# import contextlib -import logging -import subprocess -from datetime import datetime -from functools import partial -from pathlib import Path -from typing import ( - Any, - Callable, - Dict, - List, - NamedTuple, - Optional, - OrderedDict, - Type, - TypeVar, -) - -from pydantic import BaseModel, ValidationError -from rich.box import SQUARE -from rich.panel import Panel -from textual.app import ScreenStackError -from textual.containers import VerticalScroll -from textual.message import Message -from textual.reactive import reactive -from textual.screen import Screen -from textual.widget import Widget -from textual.widgets import ( - Button, - DataTable, - DirectoryTree, - Footer, - Header, - Input, - Label, - ProgressBar, - RadioButton, - RadioSet, - RichLog, - Static, - Switch, - Tree, -) -from werkzeug.utils import secure_filename - -from murfey.client.contexts.spa import SPAModularContext -from murfey.client.contexts.tomo import TomographyContext -from murfey.client.destinations import determine_default_destination -from murfey.client.gain_ref import determine_gain_ref -from murfey.client.rsync import RSyncer -from murfey.util import posix_path -from murfey.util.client import ( - capture_delete, - capture_get, - capture_post, - get_machine_config_client, - read_config, -) -from murfey.util.models import ProcessingParametersSPA, ProcessingParametersTomo - -log = logging.getLogger("murfey.tui.screens") - -ReactiveType = TypeVar("ReactiveType") - -token = read_config()["Murfey"].get("token", "") -instrument_name = read_config()["Murfey"].get("instrument_name", "") - - -class InputResponse(NamedTuple): - question: str - allowed_responses: List[str] | None = None - default: str = "" - callback: Callable | None = None - key_change_callback: Callable | None = None - kwargs: dict | None = None - form: OrderedDict[str, Any] | None = None - model: BaseModel | None = None - - -class LogBook(RichLog): - class Log(Message): - def __init__(self, log_renderable): - self.renderable = log_renderable - super().__init__() - - -class InfoWidget(Widget): - text: reactive[str] = reactive("") - - def __init__(self, text: str, **kwargs): - super().__init__(**kwargs) - self.text = text - - def render(self) -> Panel: - return Panel(self.text, style=("on dark_magenta"), box=SQUARE) - - def _key_change(self, input_char: str | None): - if input_char is None: - self.text = self.text[:-1] - return - self.text += input_char - - -class QuickPrompt: - def __init__(self, text: str, options: List[str]): - self._text = text - self._options = options - self.warn = False - - def __repr__(self): - return repr(self._text) - - def __str__(self): - return self._text - - def __iter__(self): - return iter(self._options) - - def __bool__(self): - return bool(self._text) - - -def validate_form(form: dict, model: BaseModel) -> bool: - try: - convert = lambda x: None if x == "None" else x - validated = model(**{k: convert(v) for k, v in form.items()}) - log.info(validated.model_dump()) - return True - except (AttributeError, ValidationError) as e: - log.warning(f"Form validation failed: {str(e)}") - return False - - -class _DirectoryTree(DirectoryTree): - valid_selection = reactive(False) - - def __init__(self, *args, data_directories: List[Path] | None = None, **kwargs): - super().__init__(*args, **kwargs) - self._selected_path = self.path - self._data_directories = data_directories or [] - - def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: - event.stop() - dir_entry = event.node.data.path - if dir_entry is None: - return - if dir_entry.is_dir(): - self._selected_path = dir_entry - if not self._data_directories: - self.valid_selection = True - return - for d in self._data_directories: - if Path(self._selected_path).absolute().is_relative_to(d.absolute()): - self.valid_selection = True - break - else: - self.valid_selection = False - else: - self.valid_selection = False - - -class _DirectoryTreeGain(DirectoryTree): - valid_selection = reactive(False) - - def __init__(self, gain_reference: Path, *args, **kwargs): - super().__init__(*args, **kwargs) - self._gain_reference = gain_reference - - def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: - event.stop() - dir_entry = event.node.data.path - if dir_entry is None: - return - if not dir_entry.is_dir(): - self.valid_selection = True - self._gain_reference = dir_entry - else: - self.valid_selection = False - - -class LaunchScreen(Screen): - _launch_btn: Button | None = None - - def __init__( - self, *args, basepath: Path = Path("."), add_basepath: bool = False, **kwargs - ): - super().__init__(*args, **kwargs) - self._selected_dir = basepath - self._add_basepath = add_basepath - self._context: Type[SPAModularContext] | Type[TomographyContext] - self._context = SPAModularContext - - def compose(self): - machine_data = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - self._dir_tree = _DirectoryTree( - str(self._selected_dir), - data_directories=machine_data.get("data_directories", []), - id="dir-select", - ) - - yield self._dir_tree - text_log = RichLog(id="selected-directories") - widgets = [text_log, Button("Clear", id="clear")] - text_log_block = VerticalScroll(*widgets, id="selected-directories-vert") - yield text_log_block - - text_log.write("Selected directories:\n") - btn_disabled = True - for d in machine_data.get("data_directories", []): - if ( - Path(self._dir_tree._selected_path) - .absolute() - .is_relative_to(Path(d).absolute()) - or self.app._environment.processing_only_mode - ): - btn_disabled = False - break - self._launch_btn = Button("Launch", id="launch", disabled=btn_disabled) - self._add_btn = Button("Add directory", id="add", disabled=btn_disabled) - self.watch(self._dir_tree, "valid_selection", self._check_valid_selection) - yield self._add_btn - yield self._launch_btn - yield Button("Quit", id="quit") - - def on_mount(self): - if self._add_basepath: - self._add_directory(str(self._selected_dir)) - - def _check_valid_selection(self, valid: bool): - if self._add_btn: - if valid: - self._add_btn.disabled = False - else: - self._add_btn.disabled = True - - def _add_directory(self, directory: str, add_destination: bool = True): - source = Path(self._dir_tree.path).absolute() / directory - if add_destination: - for s in self.app._environment.sources: - if source.is_relative_to(s): - return - self.app._environment.sources.append(source) - self.app._default_destinations[source] = f"{datetime.now().year}" - if self._launch_btn: - self._launch_btn.disabled = False - self.query_one("#selected-directories").write(str(source) + "\n") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "quit": - self.app.clean_up_quit() - elif event.button.id == "add": - self._add_directory(self._dir_tree._selected_path) - elif event.button.id == "launch": - text = self.app._visit - visit_path = "" - transfer_routes = {} - for s, defd in self.app._default_destinations.items(): - _default = determine_default_destination( - self.app._visit, - s, - defd, - self.app._environment, - self.app.analysers, - token, - touch=True, - ) - visit_path = defd + f"/{text}" - if self.app._environment.processing_only_mode: - self.app._start_rsyncer( - Path(_default), _default, visit_path=visit_path - ) - transfer_routes[s] = _default - self.app.install_screen( - DestinationSelect(transfer_routes, self._context), - "destination-select-screen", - ) - self.app.pop_screen() - self.app.push_screen("destination-select-screen") - elif event.button.id == "clear": - sel_dir = self.query_one("#selected-directories") - for line in sel_dir.lines[1:]: - source = Path(line.text) - if source in self.app._environment.sources: - self.app._environment.sources.remove(source) - if self.app._default_destinations.get(source): - del self.app._default_destinations[source] - sel_dir.clear() - sel_dir.write("Selected directories:\n") - - -class ConfirmScreen(Screen): - def __init__( - self, - prompt: str, - *args, - params: dict | None = None, - pressed_callback: Callable | None = None, - button_names: dict | None = None, - push: str = "main", - **kwargs, - ): - super().__init__(*args, **kwargs) - self._prompt = prompt - self._params = params or {} - self._callback = pressed_callback - self._button_names = button_names or {} - self._push = push - - def compose(self): - if self._params: - dt = DataTable(id="prompt") - keys = list(self._params.keys()) - dt.add_columns(*keys) - dt.add_rows([[self._params[k] for k in keys]]) - yield dt - else: - yield Static(self._prompt, id="prompt") - yield Button(self._button_names.get("launch") or "Launch", id="launch") - yield Button(self._button_names.get("quit") or "Back", id="quit") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "quit": - self.app.pop_screen() - self.app.uninstall_screen("confirm") - else: - while True: - try: - if self.app.screen._name == "main": - break - self.app.pop_screen() - except ScreenStackError: - break - if self._push: - log.info(f"Pushing screen {self._push}") - self.app.push_screen(self._push) - self.app.uninstall_screen("confirm") - if self._callback and event.button.id == "launch": - self._callback(params=self._params) - - -class ProcessingForm(Screen): - _form = reactive({}) - _vert = None - - def __init__( - self, - form: dict, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self._form = form - self._inputs: Dict[Input, str] = {} - - def compose(self): - inputs = [] - analyser = list(self.app.analysers.values())[0] - for k in analyser._context.user_params + analyser._context.metadata_params: - t = k.label - inputs.append(Label(t, classes="label")) - if self._form.get(k.name) in ("true", "True", True): - i = Switch(value=True, classes="input", id=f"switch_{k.name}") - elif self._form.get(k.name) in ("false", "False", False): - i = Switch(value=False, classes="input", id=f"switch_{k.name}") - else: - i = Input(placeholder=t, classes="input", id=f"input_{k.name}") - default = self._form.get(k.name, str(k.default)) - i.value = "None" if default is None else default - self._inputs[i] = k.name - inputs.append(i) - confirm_btn = Button("Confirm", id="confirm-btn") - if self._form.get("motion_corr_binning") == "2": - self._vert = VerticalScroll( - *inputs, - Label("Collected in counting mode:"), - Switch(id="superres", value=True, classes="input"), - confirm_btn, - id="input-form", - ) - else: - self._vert = VerticalScroll(*inputs, confirm_btn, id="input-form") - yield self._vert - - def _write_params( - self, - params: dict | None = None, - model: ProcessingParametersTomo | ProcessingParametersSPA | None = None, - ): - if params: - try: - analyser = [a for a in self.app.analysers.values() if a._context][0] - except IndexError: - return - for k in analyser._context.user_params + analyser._context.metadata_params: - self.app.query_one("#info").write(f"{k.label}: {params.get(k.name)}") - self.app._start_dc(params) - if model == ProcessingParametersTomo: - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.tomo_router", - function_name="register_tomo_proc_params", - token=token, - session_id=self.app._environment.murfey_session, - data=params, - ) - elif model == ProcessingParametersSPA: - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.spa_router", - function_name="register_spa_proc_params", - token=token, - session_id=self.app._environment.murfey_session, - data=params, - ) - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="workflow.spa_router", - function_name="flush_spa_processing", - token=token, - visit_name=self.app._environment.visit, - session_id=self.app._environment.murfey_session, - ) - - def on_switch_changed(self, event): - if event.switch.id == "superres": - pix_size = self.query_one("#input_pixel_size_on_image") - motion_corr_binning = self.query_one("#input_motion_corr_binning") - if event.value: - pix_size.value = str(float(pix_size.value) / 2) - motion_corr_binning.value = "2" - else: - pix_size.value = str(float(pix_size.value) * 2) - motion_corr_binning.value = "1" - else: - k = self._inputs[event.switch] - self._form[k] = event.value - - def on_input_changed(self, event): - k = self._inputs[event.input] - self._form[k] = event.value - - def on_button_pressed(self, event): - model = None - if self.app.analysers.get(Path(self._form.get("source", ""))): - if model := self.app.analysers[Path(self._form["source"])].parameters_model: - valid = validate_form(self._form, model) - if not valid: - return - if "confirm" not in self.app._installed_screens: - self.app.install_screen( - ConfirmScreen( - "Launch processing?", - params=self._form, - pressed_callback=partial(self._write_params, model=model), - ), - "confirm", - ) - self.app.push_screen("confirm") - - -class SwitchSelection(Screen): - def __init__( - self, - name: str, - elements: List[str], - switch_label: str, - switch_status: bool = True, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self._elements = elements - self._switch_status = switch_status - self._switch_label = switch_label - self._name = name - - def compose(self): - hovers = ( - [ - Button(e, id=f"btn-{self._name}-{e}", classes=f"btn-{self._name}") - for e in self._elements - ] - if self._elements - else [Button("No elements found")] - ) - yield VerticalScroll(*hovers, id=f"select-{self._name}") - yield Static(self._switch_label, id=f"label-{self._name}") - yield Switch(id=f"switch-{self._name}", value=self._switch_status) - - def on_switch_changed(self, event): - self._switch_status = event.value - - -class SessionSelection(Screen): - def __init__( - self, sessions: List[str], sessions_with_client: List[str], *args, **kwargs - ): - super().__init__(*args, **kwargs) - self._sessions = sessions - self._sessions_with_client = sessions_with_client - self._name = "session" - - def compose(self): - hovers = ( - [ - Button(e, id=f"btn-{self._name}-{e}", classes=f"btn-{self._name}") - for e in self._sessions - ] - if self._sessions - else [Button("No elements found")] - ) - deletes = ( - [ - Button( - f"Remove {e}", - id=f"btn-{self._name}-{e}-del", - classes=f"btn-{self._name}", - ) - for e in self._sessions - ] - if self._sessions - else [Button("No elements found")] - ) - yield VerticalScroll( - *[v for pair in zip(hovers, deletes) for v in pair], - id=f"select-{self._name}", - ) - yield Button( - "New session", id=f"btn-{self._name}-new", classes=f"btn-{self._name}" - ) - - def on_button_pressed(self, event: Button.Pressed): - if event.button.id.endswith("new"): - session_id = None - self.app.pop_screen() - elif event.button.id.endswith("del"): - session_id = int( - str(event.button.label.split(":")[0]).replace("Remove ", "") - ) - self.app.pop_screen() - self.app.install_screen( - ConfirmScreen( - ( - f"Remove session {session_id} [WARNING: there are clients already using this session]" - if str(event.button.label) in self._sessions_with_client - else f"Remove session {session_id}" - ), - pressed_callback=partial(self._remove_session, session_id), - button_names={"launch": "Yes"}, - push="visit-select-screen", - ), - "confirm", - ) - self.app.push_screen("confirm") - return - else: - self.app._environment.murfey_session = int( - str(event.button.label.split(":")[0]) - ) - session_id = self.app._environment.murfey_session - self.app.pop_screen() - session_name = "Client connection" - self.app._environment.murfey_session = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="link_client_to_session", - token=token, - instrument_name=self.app._environment.instrument_name, - client_id=self.app._environment.client_id, - data={"session_id": session_id, "session_name": session_name}, - ).json() - - def _remove_session(self, session_id: int, **kwargs): - capture_delete( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="remove_session", - token=token, - session_id=session_id, - ) - exisiting_sessions = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="get_sessions", - token=token, - ).json() - self.app.uninstall_screen("session-select-screen") - if exisiting_sessions: - self.app.install_screen( - SessionSelection( - [ - f"{s['session']['id']}: {s['session']['name']}" - for s in exisiting_sessions - ], - [ - f"{s['session']['id']}: {s['session']['name']}" - for s in exisiting_sessions - if s["clients"] - ], - ), - "session-select-screen", - ) - self.app.push_screen("session-select-screen") - else: - session_name = "Client connection" - resp = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="link_client_to_session", - token=token, - instrument_name=self.app._environment.instrument_name, - client_id=self.app._environment.client_id, - data={"session_id": None, "session_name": session_name}, - ) - if resp: - self.app._environment.murfey_session = resp.json() - - -class VisitSelection(SwitchSelection): - def __init__(self, visits: List[str], *args, **kwargs): - super().__init__( - "visit", - visits, - "Create visit directory (suggested)", - *args, - **kwargs, - ) - - def on_button_pressed(self, event: Button.Pressed): - text = str(event.button.label) - self.app._visit = text - self.app._environment.visit = text - response = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="register_client_to_visit", - token=token, - visit_name=text, - data={"id": self.app._environment.client_id}, - ) - log.info(f"Posted visit registration: {response.status_code}") - machine_data = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - - if self._switch_status: - self.app.install_screen( - DirectorySelection( - [ - path - for path in machine_data.get("data_directories", []) - if Path(path).exists() - ] - ), - "directory-select", - ) - self.app.pop_screen() - - if machine_data.get("gain_reference_directory"): - self.app.install_screen( - GainReference( - determine_gain_ref(Path(machine_data["gain_reference_directory"])), - self._switch_status, - ), - "gain-ref-select", - ) - self.app.push_screen("gain-ref-select") - else: - if self._switch_status: - self.app.push_screen("directory-select") - else: - self.app.install_screen(LaunchScreen(basepath=Path("./")), "launcher") - self.app.push_screen("launcher") - - if machine_data.get("upstream_data_directories"): - upstream_downloads: dict[str, dict[str, Path]] = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.correlative_router", - function_name="find_upstream_visits", - token=token, - session_id=self.app._environment.murfey_session, - ).json() - # Pass flattened dict for backwards compatibility - self.app.install_screen( - UpstreamDownloads( - { - visit_name: visit_dir - for _, upstream_visits in upstream_downloads.items() - for visit_name, visit_dir in upstream_visits.items() - } - ), - "upstream-downloads", - ) - self.app.push_screen("upstream-downloads") - - -class VisitCreation(Screen): - # This allows for the manual creation of a visit name when there is no LIMS system to provide it - # Shares a lot of code with VisitSelection, should be neatened up at some point - visit_name: reactive[str] = reactive("") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def compose(self): - yield Input(placeholder="Visit name", classes="input-visit-name") - yield Button("Create visit", classes="btn-visit-create") - - def on_input_changed(self, event): - self.visit_name = event.value - - def on_button_pressed(self, event: Button.Pressed): - text = str(self.visit_name) - self.app._visit = text - self.app._environment.visit = text - response = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="register_client_to_visit", - token=token, - visit_name=text, - data={"id": self.app._environment.client_id}, - ) - log.info(f"Posted visit registration: {response.status_code}") - machine_data = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - - self.app.install_screen( - DirectorySelection( - [ - path - for path in machine_data.get("data_directories", []) - if Path(path).exists() - ] - ), - "directory-select", - ) - self.app.pop_screen() - - if machine_data.get("gain_reference_directory"): - self.app.install_screen( - GainReference( - determine_gain_ref(Path(machine_data["gain_reference_directory"])), - True, - ), - "gain-ref-select", - ) - self.app.push_screen("gain-ref-select") - else: - self.app.push_screen("directory-select") - - if machine_data.get("upstream_data_directories"): - upstream_downloads: dict[str, dict[str, Path]] = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.correlative_router", - function_name="find_upstream_visits", - token=token, - session_id=self.app._environment.murfey_session, - ).json() - # Pass a flattened dict for backwards compatibility - self.app.install_screen( - UpstreamDownloads( - { - visit_name: visit_dir - for _, upstream_visits in upstream_downloads.items() - for visit_name, visit_dir in upstream_visits.items() - } - ), - "upstream-downloads", - ) - self.app.push_screen("upstream-downloads") - - -class UpstreamDownloads(Screen): - def __init__(self, connected_visits: Dict[str, Path], *args, **kwargs): - super().__init__(*args, **kwargs) - self._connected_visits = connected_visits - - def compose(self): - visit_buttons = [ - Button(cv, classes="btn-directory") for cv in self._connected_visits.keys() - ] - yield VerticalScroll(*visit_buttons) - yield Button("Skip", classes="btn-directory") - - def on_button_pressed(self, event: Button.Pressed): - machine_data = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="machine_info_by_instrument", - token=token, - instrument_name=instrument_name, - ).json() - if machine_data.get("upstream_data_download_directory"): - # Create the directory locally to save files to - download_dir = Path(machine_data["upstream_data_download_directory"]) / str( - event.button.label - ) - download_dir.mkdir(exist_ok=True) - - # Get the paths to the TIFF files generated previously under the same session ID - upstream_tiff_paths_response = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.correlative_router", - function_name="gather_upstream_tiffs", - token=token, - visit_name=event.button.label, - session_id=self.app._environment.murfey_session, - ) - upstream_tiff_paths = upstream_tiff_paths_response.json() or [] - - # Request to download the TIFF files found - for tp in upstream_tiff_paths: - (download_dir / tp).parent.mkdir(exist_ok=True, parents=True) - # Write TIFF to the specified file path - stream_response = capture_get( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.correlative_router", - function_name="get_tiff_file", - token=token, - visit_name=event.button.label, - session_id=self.app._environment.murfey_session, - tiff_path=tp, - ) - # Write the file chunk-by-chunk to avoid hogging memory - with open(download_dir / tp, "wb") as utiff: - for chunk in stream_response.iter_content(chunk_size=32 * 1024**2): - utiff.write(chunk) - self.app.pop_screen() - - -class GainReference(Screen): - def __init__(self, gain_reference: Path, switch_status: bool, *args, **kwargs): - super().__init__(*args, **kwargs) - self._gain_reference = gain_reference - self._switch_status = switch_status - - def compose(self): - self._dir_tree = _DirectoryTreeGain( - self._gain_reference, - str(self._gain_reference.parent.parent), - id="gain-select", - ) - yield self._dir_tree - self._launch_btn = Button("Launch", id="launch") - self.watch(self._dir_tree, "valid_selection", self._check_valid_selection) - yield Button( - f"Suggested gain reference: {self._gain_reference.parent / self._gain_reference.name}", - id="suggested-gain-ref", - ) - yield self._launch_btn - yield Button("No gain", id="skip-gain") - - def _check_valid_selection(self, valid: bool): - if self._launch_btn: - if valid: - self._launch_btn.disabled = False - else: - self._launch_btn.disabled = True - - def on_button_pressed(self, event): - if event.button.id == "skip-gain": - self.app.pop_screen() - else: - if event.button.id == "suggested-gain-ref": - self._dir_tree._gain_reference = self._gain_reference - visit_path = f"{datetime.now().year}/{self.app._environment.visit}" - # Set up rsync command - rsync_cmd = [ - "rsync", - f"{posix_path(self._dir_tree._gain_reference)!r}", - f"{self.app._environment.rsync_url or self.app._environment.url.hostname}::{self.app._machine_config.get('rsync_module', 'data')}/{visit_path}/processing/{secure_filename(self._dir_tree._gain_reference.name)}", - ] - # Encase in bash shell - cmd = ["bash", "-c", " ".join(rsync_cmd)] - if self.app._environment.demo: - log.info(f"Would perform {' '.join(cmd)}") - else: - # Run rsync subprocess - gain_rsync = subprocess.run(cmd) - if gain_rsync.returncode: - log.warning( - f"Gain reference file {posix_path(self._dir_tree._gain_reference)!r} was not successfully transferred to {visit_path}/processing" - ) - process_gain_response = capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="file_io_instrument.router", - function_name="process_gain", - token=token, - session_id=self.app._environment.murfey_session, - data={ - "gain_ref": str(self._dir_tree._gain_reference), - "eer": bool( - self.app._machine_config.get("external_executables_eer") - ), - }, - ) - if str(process_gain_response.status_code).startswith("4"): - log.warning( - f"Gain processing failed: status code {process_gain_response.status_code}" - ) - else: - log.info( - f"Gain reference file {process_gain_response.json().get('gain_ref')}" - ) - self.app._environment.gain_ref = process_gain_response.json().get( - "gain_ref" - ) - if self._switch_status: - self.app.push_screen("directory-select") - else: - self.app.install_screen(LaunchScreen(basepath=Path("./")), "launcher") - self.app.push_screen("launcher") - - -class DirectorySelection(SwitchSelection): - def __init__(self, directories: List[str], *args, **kwargs): - super().__init__( - "directory", - directories, - "Automatically transfer and trigger processing for new directories (recommended)", - *args, - **kwargs, - ) - - def on_button_pressed(self, event: Button.Pressed): - self.app._multigrid = self._switch_status - visit_dir = Path(str(event.button.label)).absolute() / self.app._visit - visit_dir.mkdir(exist_ok=True) - self.app._set_default_acquisition_directories(visit_dir) - machine_config = get_machine_config_client( - str(self.app._environment.url.geturl()), - token, - instrument_name=self.app._environment.instrument_name, - demo=self.app._environment.demo, - ) - for dir in machine_config["create_directories"]: - (visit_dir / dir).mkdir(exist_ok=True) - self.app.install_screen( - LaunchScreen(basepath=visit_dir, add_basepath=True), "launcher" - ) - self.app.pop_screen() - self.app.push_screen("launcher") - - -class DestinationSelect(Screen): - def __init__( - self, - transfer_routes: Dict[Path, str], - context: Type[SPAModularContext] | Type[TomographyContext], - *args, - destination_overrides: Optional[Dict[Path, str]] = None, - use_transfer_routes: bool = False, - **kwargs, - ): - super().__init__(*args, **kwargs) - self._transfer_routes = transfer_routes - self._destination_overrides: Dict[Path, str] = destination_overrides or {} - self._user_params: Dict[str, str] = {} - self._inputs: Dict[Input, str] = {} - self._context = context - self._use_transfer_routes = use_transfer_routes - - def compose(self): - bulk = [] - with RadioSet(): - yield RadioButton("SPA", value=self._context is SPAModularContext) - yield RadioButton("Tomography", value=self._context is TomographyContext) - if self.app._multigrid: - machine_config = get_machine_config_client( - str(self.app._environment.url.geturl()), - token, - instrument_name=self.app._environment.instrument_name, - ) - destinations = [] - if self._destination_overrides: - for k, v in self._destination_overrides.items(): - destinations.append(v) - bulk.append(Label(f"Copy the source {k} to:")) - bulk.append( - Input( - value=v, - id=f"destination-{str(k)}", - classes="input-destination", - ) - ) - else: - for s in self._transfer_routes.keys(): - for d in s.glob("*"): - if ( - d.is_dir() - and d.name not in machine_config["create_directories"] - ): - dest = determine_default_destination( - self.app._visit, - s, - f"{datetime.now().year}", - self.app._environment, - self.app.analysers, - token, - touch=True, - ) - if dest and dest in destinations: - dest_path = Path(dest) - name_root = "" - dest_num = 0 - for i, st in enumerate(dest_path.name): - if st.isnumeric(): - dest_num = int(dest_path.name[i:]) - break - name_root += st - if dest_num: - dest = str( - dest_path.parent / f"{name_root}{dest_num + 1}" - ) - else: - dest = str(dest_path.parent / f"{name_root}2") - destinations.append(dest) - bulk.append(Label(f"Copy the source {d} to:")) - bulk.append( - Input( - value=dest, - id=f"destination-{str(d)}", - classes="input-destination", - ) - ) - else: - machine_config = get_machine_config_client( - str(self.app._environment.url.geturl()), - token, - instrument_name=self.app._environment.instrument_name, - ) - for s, d in self._transfer_routes.items(): - if Path(d).name not in machine_config["create_directories"]: - bulk.append(Label(f"Copy the source {s} to:")) - bulk.append( - Input( - value=d, - id=f"destination-{str(s)}", - classes="input-destination", - ) - ) - yield VerticalScroll(*bulk, id="destination-holder") - params_bulk = [] - if self.app._multigrid and self.app._processing_enabled: - for k in self._context.user_params: - params_bulk.append(Label(k.label)) - val = getattr(self.app._environment, k.name, str(k.default)) - self._user_params[k.name] = val - if val in ("true", "True", True): - i = Switch(value=True, id=k.name, classes="input-destination") - elif val in ("false", "False", False): - i = Switch(value=False, id=k.name, classes="input-destination") - else: - i = Input(value=val, id=k.name, classes="input-destination") - params_bulk.append(i) - self._inputs[i] = k.name - machine_config = get_machine_config_client( - str(self.app._environment.url.geturl()), - token, - instrument_name=self.app._environment.instrument_name, - demo=self.app._environment.demo, - ) - if machine_config.get("superres"): - params_bulk.append( - Label("Collected in super resoultion mode unbinned:") - ) - params_bulk.append( - Switch( - value=False, - id="superres-multigrid", - classes="input-destination", - ) - ) - self.app._environment.superres = False - yield VerticalScroll( - *params_bulk, - id="user-params", - ) - yield Button("Confirm", id="destination-btn") - - def on_switch_changed(self, event): - if event.switch.id == "superres-multigrid": - self.app._environment.superres = event.value - else: - for k in self._context.user_params: - if event.switch.id == k.name: - self._user_params[k.name] = event.value - - def on_radio_set_changed(self, event: RadioSet.Changed) -> None: - if event.index == 0: - self._context = SPAModularContext - else: - self._context = TomographyContext - self.app.pop_screen() - self.app.uninstall_screen("destination-select-screen") - self.app.install_screen( - DestinationSelect( - self._transfer_routes, - self._context, - destination_overrides=self._destination_overrides, - use_transfer_routes=True, - ), - "destination-select-screen", - ) - self.app.push_screen("destination-select-screen") - - def on_input_changed(self, event): - if event.input.id.startswith("destination-"): - if not self.app._multigrid: - self._transfer_routes[Path(event.input.id[12:])] = event.value - else: - self._destination_overrides[Path(event.input.id[12:])] = event.value - else: - for k in self._context.user_params: - if event.input.id == k.name: - self._user_params[k.name] = event.value - - def on_button_pressed(self, event): - if self.app._multigrid and self.app._processing_enabled: - if self._context == TomographyContext: - valid = validate_form(self._user_params, ProcessingParametersTomo.Base) - else: - valid = validate_form(self._user_params, ProcessingParametersSPA.Base) - if not valid: - return - for s, d in self._transfer_routes.items(): - self.app._default_destinations[s] = d - self.app._register_dc = True - if self.app._multigrid: - for k, v in self._destination_overrides.items(): - self.app._environment.destination_registry[k.name] = v - self.app._launch_multigrid_watcher( - s, destination_overrides=self._destination_overrides - ) - else: - self.app._start_rsyncer(s, d) - for k, v in self._user_params.items(): - setattr(self.app._environment, k, v) - self.app.pop_screen() - self.app.push_screen("main") - - -class WaitingScreen(Screen): - def __init__( - self, - prompt: str, - num_files: int, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self._num_files = 0 - self._prompt = prompt - self._new_rsyncers: List[RSyncer] = [] - - def compose(self): - yield Static(self._prompt, id="waiting-prompt") - yield ProgressBar(id="progress") - yield Button("Start", id="waiting-launch") - yield Button("Back", id="waiting-quit") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "waiting-quit": - self.app.pop_screen() - self.app.uninstall_screen("waiting") - else: - sources = [] - if self.app.rsync_processes or self.app._environment.demo: - for k, rp in self.app.rsync_processes.items(): - rp.stop() - if self.app.analysers.get(k): - self.app.analysers[k].stop() - removal_rp = RSyncer.from_rsyncer(rp, remove_files=True) - removal_rp._listeners = [self.file_copied] - self._new_rsyncers.append(removal_rp) - sources.append(k) - log.info( - f"Starting to remove data files {self.app._environment.demo}, {len(self.app.rsync_processes)}" - ) - self.query_one(ProgressBar).update(total=self._num_files) - for k, removal_rp in zip(sources, self._new_rsyncers): - for f in k.absolute().glob("**/*"): - if f.is_file(): - self._num_files += 1 - removal_rp.queue.put(f) - removal_rp.queue.put(None) - self.query_one(ProgressBar).update(total=self._num_files) - for removal_rp in self._new_rsyncers: - removal_rp.start() - - def file_copied(self, *args, **kwargs): - self.query_one(ProgressBar).advance(1) - if self.query_one(ProgressBar).progress == self.query_one(ProgressBar).total: - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="register_processing_success_in_ispyb", - token=token, - session_id=self.app._environment.murfey_session, - ) - capture_delete( - base_url=str(self.app._environment.url.geturl()), - router_name="session_control.router", - function_name="remove_session", - token=token, - session_id=self.app._environment.murfey_session, - ) - self.app.exit() - - -class MainScreen(Screen): - def compose(self): - self.app.log_book = LogBook(id="log_book", wrap=True, max_lines=200) - if self.app._redirected_logger: - log.info("connecting logger") - self.app._redirected_logger.text_log = self.app.log_book - log.info("logger connected") - self.app.hovers = ( - [Button(v, id="visit-btn") for v in self.app.visits] - if len(self.app.visits) - else [Button("No ongoing visits found")] - ) - self.app.processing_form = ProcessingForm(self.app._form_values) - yield Header() - info_widget = RichLog(id="info", markup=True) - yield info_widget - yield self.app.log_book - info_widget.write( - f"[bold]Welcome to Murfey ({self.app._environment.visit})[/bold]" - ) - yield Button("Visit complete", id="new-visit-btn") - yield Footer() - - def on_mount(self, event): - capture_post( - base_url=str(self.app._environment.url.geturl()), - router_name="prometheus.router", - function_name="change_monitoring_status", - token=token, - visit_name=self.app._environment.visit, - on=1, - ) diff --git a/src/murfey/client/tui/status_bar.py b/src/murfey/client/tui/status_bar.py deleted file mode 100644 index 7e761e8e4..000000000 --- a/src/murfey/client/tui/status_bar.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import functools -import logging -import time -from threading import RLock - -from rich.box import SQUARE -from rich.panel import Panel -from rich.progress import ( - Progress, - SpinnerColumn, - TextColumn, - TimeRemainingColumn, - TransferSpeedColumn, -) -from rich.table import Column -from textual.reactive import Reactive -from textual.widget import Widget - -from murfey.client.tui.progress import BlockBarColumn - -log = logging.getLogger("murfey.client.tui.status_bar") - - -class StatusBar(Widget): - transferred = Reactive([0, 0]) - _current_progress = 0 - lock: RLock = RLock() - - @functools.lru_cache() - def get_progress(self): - text_column = TextColumn("{task.description}", table_column=Column(ratio=1)) - bar_column = BlockBarColumn(bar_width=None, table_column=Column(ratio=3)) - progress = Progress( - text_column, - bar_column, - TransferSpeedColumn(), - SpinnerColumn(), - TimeRemainingColumn(), - expand=True, - ) - - task1 = progress.add_task("[red]Transferring...", total=self.transferred[1]) - task2 = None # progress.add_task("[green]Processing...", total=1000) - task3 = None # progress.add_task("[cyan]Cooking...", total=1000) - return (progress, task1, task2, task3) - - def render(self) -> Panel: - progress, task1, task2, task3 = self.get_progress() - # elapsed = (time.time() - self.start) * 100 - - log.info(f"For transfer {self.transferred[1]}") - advance = self.transferred[0] - self._current_progress - self._current_progress += advance - log.info(f"Advance: {advance}, {self.transferred[0]}") - progress.update(task1, completed=self.transferred[0], total=self.transferred[1]) - # progress.update(task2, completed=max(0, min(1000, elapsed - 1000))) - # progress.update(task3, completed=max(0, min(1000, elapsed - 2000))) - return Panel(progress.make_tasks_table(progress.tasks), height=5, box=SQUARE) - - def on_mount(self): - self.start = time.time() - self.set_interval(0.3, self.tick) - - def tick(self): - self.refresh() diff --git a/src/murfey/client/watchdir.py b/src/murfey/client/watchdir.py index 07bbebcc2..fdb3d363b 100644 --- a/src/murfey/client/watchdir.py +++ b/src/murfey/client/watchdir.py @@ -14,7 +14,6 @@ from pathlib import Path from typing import List, NamedTuple, Optional -from murfey.client.tui.status_bar import StatusBar from murfey.util.client import Observer log = logging.getLogger("murfey.client.watchdir") @@ -32,15 +31,13 @@ def __init__( path: str | os.PathLike, settling_time: float = 60, appearance_time: float | None = None, - substrings_blacklist: dict[str, dict] = {}, + substrings_blacklist: dict[str, list[str]] = {}, transfer_all: bool = True, - status_bar: StatusBar | None = None, ): super().__init__() self._basepath = os.fspath(path) self._lastscan: dict[str, _FileInfo] | None = {} self._file_candidates: dict[str, _FileInfo] = {} - self._statusbar = status_bar self.settling_time = settling_time self._appearance_time = appearance_time self._substrings_blacklist = substrings_blacklist @@ -216,13 +213,6 @@ def _notify_for_transfer(self, file_candidate: str) -> bool: removes it from the file candidates list. """ log.debug(f"File {Path(file_candidate).name!r} is ready to be transferred") - if self._statusbar: - # log.info("Increasing number to be transferred") - with self._statusbar.lock: - self._statusbar.transferred = [ - self._statusbar.transferred[0], - self._statusbar.transferred[1] + 1, - ] # Check that it's not a hidden file, ".", "..", or still downloading transfer_check = not Path(file_candidate).name.startswith(".") and not Path( diff --git a/tests/client/test_watchdir.py b/tests/client/test_watchdir.py index c1ada825e..38c860a74 100644 --- a/tests/client/test_watchdir.py +++ b/tests/client/test_watchdir.py @@ -15,7 +15,6 @@ def test_dirwatcher_initialises(tmp_path: Path): assert watcher._basepath == os.fspath(str(tmp_path)) assert watcher._lastscan == {} assert watcher._file_candidates == {} - assert watcher._statusbar is None assert watcher.settling_time == 60 assert watcher._appearance_time is None assert watcher._substrings_blacklist == {} diff --git a/tests/client/tui/test_main.py b/tests/client/tui/test_main.py index 82b006990..e69de29bb 100644 --- a/tests/client/tui/test_main.py +++ b/tests/client/tui/test_main.py @@ -1,71 +0,0 @@ -from unittest import mock -from unittest.mock import Mock -from urllib.parse import urlparse - -import pytest - -from murfey.client.tui.main import _get_visit_list -from murfey.util.models import Visit - -test_get_visit_list_params_matrix = ( - ("http://0.0.0.0:8000",), - ("http://0.0.0.0:8000/api",), - ("http://murfey_server",), - ("http://murfey_server/api",), - ("http://murfey_server.com",), -) - - -@pytest.mark.parametrize("test_params", test_get_visit_list_params_matrix) -@mock.patch("murfey.client.tui.main.capture_get") -def test_get_visit_list( - mock_request_get, - test_params: tuple[str], - mock_client_configuration, -): - # Unpack test params and set up other params - (server_url,) = test_params - instrument_name = mock_client_configuration["Murfey"]["instrument_name"] - - # Construct the expected request response - example_visits = [ - { - "start": "1999-09-09T09:00:00", - "end": "1999-09-11T09:00:00", - "session_id": 123456789, - "name": "cm12345-0", - "beamline": "murfey", - "proposal_title": "Commissioning Session 1", - }, - { - "start": "1999-09-09T09:00:00", - "end": "1999-09-11T09:00:00", - "session_id": 246913578, - "name": "cm23456-1", - "beamline": "murfey", - "proposal_title": "Cryo-cycle 1999", - }, - ] - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = example_visits - mock_request_get.return_value = mock_response - - # read_config() has to be patched using fixture, so has to be done in function - with mock.patch("murfey.util.client.read_config", mock_client_configuration): - visits = _get_visit_list(urlparse(server_url), instrument_name) - - # Check that request was sent with the correct URL - mock_request_get.assert_called_once_with( - base_url=server_url, - router_name="session_control.router", - function_name="get_current_visits", - token=mock.ANY, - instrument_name=instrument_name, - ) - - # Check that expected outputs are correct (order-sensitive) - for v, visit in enumerate(visits): - assert ( - visit.model_dump() == Visit.model_validate(example_visits[v]).model_dump() - ) From 761ff3562cbffe9dc263267f2997abe681d311c3 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Thu, 11 Dec 2025 18:37:08 +0000 Subject: [PATCH 2/3] Simplified optional dependency keys used in 'pyproject.toml'; 'instrument-server' as a key is no longer needed --- Dockerfiles/murfey-instrument-server | 2 +- pyproject.toml | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Dockerfiles/murfey-instrument-server b/Dockerfiles/murfey-instrument-server index f90054b56..085c451aa 100644 --- a/Dockerfiles/murfey-instrument-server +++ b/Dockerfiles/murfey-instrument-server @@ -32,7 +32,7 @@ RUN apt-get update && \ pip \ build \ importlib-metadata && \ - /venv/bin/python -m pip install /python-murfey[client,instrument-server] + /venv/bin/python -m pip install /python-murfey[client] # Transfer completed Murfey build to base image diff --git a/pyproject.toml b/pyproject.toml index 5b1c46a82..250a9b734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,12 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ + "aiohttp", "defusedxml", # For safely parsing XML files + "fastapi[standard-no-fastapi-cloud-cli]>=0.116.0", "pydantic>=2", "pydantic-settings", + "python-jose", "requests", "rich", "werkzeug", @@ -42,7 +45,6 @@ cicd = [ "pytest-cov", # Used for generating PyTest coverage reports ] client = [ - "textual==0.42.0", "websocket-client", ] developer = [ @@ -52,15 +54,8 @@ developer = [ "pytest", # Test code functionality "pytest-mock", # Additional mocking tools for unit tests ] -instrument-server = [ - "aiohttp", - "fastapi[standard-no-fastapi-cloud-cli]>=0.116.0", - "python-jose", -] server = [ - "aiohttp", "cryptography", - "fastapi[standard-no-fastapi-cloud-cli]>=0.116.0", "graypy", "ispyb>=10.2.4", # Responsible for setting requirements for SQLAlchemy and mysql-connector-python; "jinja2", @@ -70,7 +65,6 @@ server = [ "passlib", "pillow", "prometheus_client", - "python-jose[cryptography]", "sqlalchemy[postgresql]", # Add as explicit dependency "sqlmodel", "stomp-py>8.1.1", # 8.1.1 (released 2024-04-06) doesn't work with our project From a5b2ee8691f83d4390a8a1dac1388352ea326755 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Thu, 11 Dec 2025 18:39:19 +0000 Subject: [PATCH 3/3] Removed 'tui' folder from tests --- tests/client/tui/__init__.py | 0 tests/client/tui/test_main.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/client/tui/__init__.py delete mode 100644 tests/client/tui/test_main.py diff --git a/tests/client/tui/__init__.py b/tests/client/tui/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/client/tui/test_main.py b/tests/client/tui/test_main.py deleted file mode 100644 index e69de29bb..000000000