diff --git a/docs/autoplot_dock_widget.rst b/docs/autoplot_dock_widget.rst index e824bd50d..bcb601a34 100644 --- a/docs/autoplot_dock_widget.rst +++ b/docs/autoplot_dock_widget.rst @@ -56,6 +56,21 @@ The **left treewidget** is used to configure the automated plotting flights list - Flight, filename, and section parameters are configured in the **Top View**. - The vertical parameter is configured in the **Side View**. +The filename is taken from the flight track currently loaded. The treewidget shows the +file name alone, while the configuration file stores it with its path, so that +mssautoplot finds it independent of the directory it is started in. A flight track +which was never saved has no file yet, for that one only its name is stored and the +file is looked up in the current working directory. Because a flight track without a +file cannot be plotted, the **Download Plots Button** stops before it downloads anything +and tells you where the file was looked up: next to the selected configuration file +which named it, or in the working directory for a name stored without a directory. + +A configuration file selected with the **Select configuration file** button may name +its flight tracks without a path, or relative to its own directory, as long as it was +not written by this dockwidget. Such a name is resolved against the directory of the +selected configuration file, the working directory the GUI happens to be started in +is of no use here. + The **right treewidget** is used to configure the automated plotting flight sections, which are based on the view: - For **Top View**, it is `"automated_plotting_hsecs": [["URL", "Layer", "Styles", "Level"]]`. diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst index 726632719..5126ad398 100644 --- a/docs/mssautoplot.rst +++ b/docs/mssautoplot.rst @@ -15,6 +15,10 @@ The CLI tool has the following parameters: +--------------+-------+----------------------------------------------------------------------+ | ``--ftrack`` | TEXT | Flight track. | +--------------+-------+----------------------------------------------------------------------+ +| ``--fpath`` | TEXT | Directory of the flight track files. Replaces the directory | +| | | stored with the file name in the config file; | +| | | only the base name of the entry is kept. | ++--------------+-------+----------------------------------------------------------------------+ | ``--itime`` | TEXT | Initial time. | +--------------+-------+----------------------------------------------------------------------+ | ``--vtime`` | TEXT | Valid time. | @@ -50,6 +54,11 @@ For downloading plots of multiple flight tracks, specify the flight track and it ["flight1", "section1", "vertical1", "filename1", "init_time1", "time1"] ["flight2", "section2, "vertical2", "filename2", "init_time2", "time2"]] +The filename is stored together with its path, e.g. "/home/mss/flights/flight1.ftml" +or "~/flights/flight1.ftml". Only then the flight track is found independent of the +directory mssautoplot is called in. A bare file name without a path is looked up in +the current working directory. + 3. ``mssautoplot --cpath mssautoplot.json --stime="2019-09-01T00:00:00" --etime="2019-09-02T00:00:00" --intv=6`` The above command will download plots of the with/without flight track from start time "2019-09-01T00:00:00" to end time "2019-09-02T00:00:00". The user would need to compulsorily specify the init_time and time in mss_autoplot.json inorder to use this functionality. @@ -61,6 +70,18 @@ This command stores the data of topview as PNG for overlays without axis, titles This could be used in systems like PLANET. You may want to set large values in the layout of topview in the mssautoplot.json. +5. ``mssautoplot --cpath mssautoplot.json --fpath /home/mss/flights/campaign2`` + +This command reads the flight tracks from the given directory instead of the directory +stored in mss_autoplot.json. Only the base name of each stored flight track is kept, +any subdirectory stored with it is discarded, so the "/home/mss/campaign1/example.ftml" +of the configuration is loaded from "/home/mss/flights/campaign2/example.ftml". +This lets the same configuration be used for the flight tracks of several campaigns, +provided they share the file names. + +On Windows give the directory in full, e.g. ``--fpath C:\Users\mss\flights\campaign2``, +recent Windows shells do not expand "~" reliably. + Settings file -------------- diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index ed99d4b68..4a7ec6a15 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -41,6 +41,7 @@ import defusedxml.ElementTree as etree import PIL.Image import matplotlib +import matplotlib.backends.backend_agg from slugify import slugify import mslib @@ -70,6 +71,28 @@ mpl_logger = configure_mpl_logger() +def resolve_ftml_path(filename, fpath=None, directory=None): + """Resolve the flight track file of an "automated_plotting_flights" entry. + + The single place which defines where such an entry is looked up, used by the + CLI and by the dockwidget which writes the entries. + + The entry stores the path together with the file name, "~" is expanded. A + relative name is taken relative to , the directory of the + configuration file which named it, and relative to the current working + directory without one. , the --fpath option of the CLI, replaces the + directory of the entry altogether. + + Returns an absolute Path, which does not need to exist. + """ + path = Path(filename).expanduser() + if fpath: + path = Path(fpath).expanduser() / path.name + elif directory is not None and not path.is_absolute(): + path = Path(directory) / path + return path.resolve() + + def load_from_ftml(filename): """Load a flight track from an XML file at . """ @@ -187,7 +210,8 @@ def get_op_id(msc_url, token, op_name): class Plotting: - def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, password=None, pdlg=None, raw=False): + def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, password=None, pdlg=None, raw=False, + fpath=None): """ Initialize the Plotting object with the provided parameters. @@ -197,9 +221,12 @@ def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, p :msc_auth_password: Authentication password for MSColab service :username: User's username :password: User's password + :fpath: Directory of the flight track files, overrides the directory + stored in the configuration file """ read_config_file(cpath) self.pdlg = pdlg + self.fpath = fpath self.config = config_loader() self.num_interpolation_points = self.config["num_interpolation_points"] self.num_labels = self.config["num_labels"] @@ -224,14 +251,6 @@ def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, p if filename != "" and filename == flight: self.read_operation(flight, msc_url, msc_auth_password, username, password) elif filename != "": - # Todo add the dir to the file in the mssautoplot.json - dirpath = "./" - file_path = os.path.join(dirpath, filename) - exists = os.path.exists(file_path) - if not exists: - print("Filename {} doesn't exist".format(filename)) - self.pdlg.close() - raise SystemExit("Filename {} doesn't exist".format(filename)) self.read_ftml(filename) def setup(self): @@ -264,8 +283,25 @@ def update_path_ops(self, filename=None): self.plotter.update_from_waypoints(self.wp_model_data) self.plotter.redraw_path(waypoints_model_data=self.wp_model_data) + def ftml_file(self, filename): + """ + Flight track file of an "automated_plotting_flights" entry, checked for existence. + + Raises FileNotFoundError when the file is not there. draw() in main() catches it, + so a missing file skips the plots of that entry only and the run goes on with + the next one. + """ + file_path = resolve_ftml_path(filename, self.fpath) + if not file_path.exists(): + message = "Filename {} doesn't exist".format(file_path) + print(message) + if self.pdlg is not None: + self.pdlg.close() + raise FileNotFoundError(message) + return file_path + def read_ftml(self, filename): - self.wps, self.wp_model_data = load_from_ftml(filename) + self.wps, self.wp_model_data = load_from_ftml(self.ftml_file(filename)) self.wp_lats, self.wp_lons, self.wp_locs = [[x[i] for x in self.wps] for i in [0, 1, 3]] self.wp_press = [mslib.utils.thermolib.flightlevel2pressure(wp[2] * units.hft).to("Pa").m for wp in self.wps] self.path = [(wp[0], wp[1], datetime.now()) for wp in self.wps] @@ -290,8 +326,9 @@ def read_operation(self, op_name, msc_url, msc_auth_password, username, password class TopViewPlotting(Plotting): - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(TopViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(TopViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) self.pdlg = pdlg self.myfig = viewplotter.TopViewPlotter() self.myfig.fig.canvas.draw() @@ -359,8 +396,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, class SideViewPlotting(Plotting): - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(SideViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(SideViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) self.pdlg = pdlg self.myfig = viewplotter.SideViewPlotter() self.ax = self.myfig.ax @@ -475,8 +513,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, class LinearViewPlotting(Plotting): # ToDo Implement access of MSColab - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(LinearViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(LinearViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, + raw, fpath) self.pdlg = pdlg self.myfig = viewplotter.LinearViewPlotter() self.ax = self.myfig.ax @@ -573,6 +612,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, @click.option('--cpath', default=constants.MSS_AUTOPLOT, help='Path of the configuration file.') @click.option('--view', default="top", help='View of the plot (top/side/linear).') @click.option('--ftrack', default="", help='Flight track.') +@click.option('--fpath', default="", help='Directory of the flight track files. Replaces the directory stored ' + 'with the file name in the configuration file, ' + 'any subdirectory of that stored path is discarded.') @click.option('--itime', default="", help='Initial time.') @click.option('--vtime', default="", help='Valid time.') @click.option('--intv', default=0, help='Time interval.') @@ -580,7 +622,7 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, @click.option('--etime', default="", help='Ending time for downloading multiple plots with a fixed interval.') @click.option('--raw', default=False, help='Saves the raw image with its projection in topview') @click.pass_context -def main(ctx, cpath, view, ftrack, itime, vtime, intv, stime, etime, raw): +def main(ctx, cpath, view, ftrack, fpath, itime, vtime, intv, stime, etime, raw): pdlg = None def close_process_dialog(pdlg): @@ -627,13 +669,14 @@ def close_process_dialog(pdlg): # Choose view (top or side) if view == "top": - top_view = TopViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + top_view = TopViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, fpath) sec = "automated_plotting_hsecs" elif view == "side": - side_view = SideViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + side_view = SideViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, fpath) sec = "automated_plotting_vsecs" elif view == "linear": - linear_view = LinearViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + linear_view = LinearViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) sec = "automated_plotting_lsecs" else: print("Invalid view") diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index d6d05e31d..c8602c401 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -31,12 +31,13 @@ import json import logging from datetime import datetime +from pathlib import Path import click from PyQt5.QtWidgets import QWidget, QFileDialog, QTreeWidgetItem, QMessageBox from PyQt5 import QtCore -from mslib.autoplot import main as cli_tool +from mslib.autoplot import main as cli_tool, resolve_ftml_path from mslib.msui.qt5.ui_mss_autoplot import Ui_AutoplotDockWidget from mslib.utils import constants as const from mslib.utils.qt import get_save_filename @@ -69,6 +70,8 @@ def __init__(self, parent=None, parent2=None, view=None, config_settings=None): self.stime = "" self.etime = "" self.intv = "" + # flight track file -> (configuration file it came from, its name in there) + self.flighttrack_sources = {} self.refresh_sig(config_settings) @@ -165,6 +168,15 @@ def download_plots_cli(self, config_settings): "Cannot download empty treewidget" ) return + missing = self.missing_flighttrack(config_settings["automated_plotting_flights"]) + if missing is not None: + flight, entry, path = missing + QMessageBox.information( + self, + "WARNING", + self.missing_flighttrack_message(flight, entry, path, self.flighttrack_sources.get(entry)) + ) + return if self.intv == "": if self.stime and self.etime: QMessageBox.information( @@ -288,7 +300,9 @@ def configure_from_path(self, parent, config_settings): self.cpath = fileName with open(fileName, 'r') as file: configure = json.load(file) - autoplot_flights = configure["automated_plotting_flights"] + configured_flights = configure["automated_plotting_flights"] + autoplot_flights = self.resolve_flights_paths(configured_flights, Path(fileName).parent) + self.flighttrack_sources = self.flights_sources(fileName, configured_flights, autoplot_flights) autoplot_hsecs = configure["automated_plotting_hsecs"] autoplot_vsecs = configure["automated_plotting_vsecs"] autoplot_lsecs = configure["automated_plotting_lsecs"] @@ -301,6 +315,154 @@ def configure_from_path(self, parent, config_settings): parent.refresh_signal_emit.emit() self.resize_treewidgets() + @staticmethod + def flighttrack_entry(row): + """ + The flight track file an "automated_plotting_flights" entry names, None when it + does not name one. + + An entry of an operation carries the operation name instead of a file, an entry + without a flight track carries nothing. + """ + if len(row) > 3 and row[3] and row[3] != row[0]: + return row[3] + return None + + @classmethod + def resolve_flights_paths(cls, flights, directory): + """ + "automated_plotting_flights" entries of a configuration file, with their + flight track files resolved against the directory of that file. + + A configuration which the dockwidget did not write can name the flight track + without a path, or relative to the directory the configuration lives in. The + GUI is started from an arbitrary working directory, so resolving such a name + against the configuration file is the only lookup which finds the file. + Operations, which carry the operation name instead of a file, stay untouched. + """ + resolved = [] + for row in flights: + row = list(row) + entry = cls.flighttrack_entry(row) + if entry is not None: + row[3] = str(resolve_ftml_path(entry, directory=directory)) + resolved.append(row) + return resolved + + @classmethod + def flights_sources(cls, config_file, configured_flights, resolved_flights): + """ + Where the flight track file of a resolved entry came from: resolved file -> + (configuration file, name as that file stores it). + + Only used to explain a file which is not there, see + missing_flighttrack_message(). Two entries can name the same file, one with and + one without its directory. The first of them wins: both explanations name the + same missing file and the same configuration to correct it in, so which of the + two names is quoted does not change what the user has to do. + """ + sources = {} + for configured, resolved in zip(configured_flights, resolved_flights): + entry = cls.flighttrack_entry(resolved) + if entry is not None: + sources.setdefault(entry, (config_file, configured[3])) + return sources + + @classmethod + def missing_flighttrack(cls, flights): + """ + The first "automated_plotting_flights" entry whose flight track file is not + there, as a (flight, entry, path) triple, None when all of them are readable. + + is the file as the configuration stores it, the file mssautoplot + looks it up at. The entries of a selected configuration file are resolved + already, the ones of the configuration loaded at startup and the ones of a + flight track which was never saved are not, so the lookup goes through + resolve_ftml_path() again, the one place which defines it. Plots cannot be + drawn for a missing file, so the download stops before it opens its progress + dialog. + """ + for row in flights: + entry = cls.flighttrack_entry(row) + if entry is not None: + path = resolve_ftml_path(entry) + if not path.exists(): + return row[0], entry, path + return None + + @staticmethod + def missing_flighttrack_message(flight, entry, path, source=None): + """ + Why the flight track file of is looked up at , and what to do. + + The path a lookup fails at is rarely the path the user typed: a name without a + directory is resolved against a working directory the GUI user never chose, and + a name from a selected configuration file against the directory of that file. + , the (configuration file, name in there) pair of the entry, tells the + two apart. It is None for an entry which no selected configuration provided: + one the dockwidget wrote from a flight track, or one of the configuration + loaded at startup. Which of the two it is cannot be told from the entry, so a + name without a directory names both remedies instead of guessing one. + """ + text = [f"The flight track file of '{flight}' does not exist:", str(path), ""] + if source is not None: + config_file, name = source + if name != entry and Path(name).parent == Path("."): + text.append(f"The configuration {config_file} names it '{name}', without a " + "directory, so it is looked up next to that file.") + elif name != entry: + # A name with a directory changes on resolving too: "~" is expanded, a + # relative directory is joined onto the one of the configuration file + # and symlinks are followed. + text.append(f"The configuration {config_file} names it '{name}', which " + "resolves to this path.") + else: + text.append(f"This path is stored in the configuration {config_file}.") + text.append("Correct it there, or open the flight track in the MSUI, save it " + "and add the row again.") + elif Path(entry).parent == Path("."): + text.append(f"Only the name '{entry}' is stored, without a directory, so the file " + f"is looked up in the working directory {Path.cwd()}.") + text.append("Save the flight track in the MSUI and add the row again, or store the " + "name with its directory in the configuration file.") + else: + text.append("The file was moved or deleted after the row was added.") + text.append("Open the flight track in the MSUI, save it and add the row again.") + return "\n".join(text) + + @staticmethod + def flighttrack_filename(parent, name): + """ + Path and file name of the flight track currently shown in . + + mssautoplot needs the directory too, otherwise it only finds the flight + track when it is started in the directory the file lives in. A track + which was never saved has no file yet, for that one the name is all we + have to offer. + """ + filename = parent.waypoints_model.get_filename() + if not filename: + return f"{name}.ftml" + return str(Path(filename).expanduser().resolve()) + + @staticmethod + def filename_to_show(filename): + """ + The flight track as it is shown in the tree widget: the file name alone. + + The configuration stores it with its path, that one is only needed by + mssautoplot to find the file from any working directory. + """ + return Path(filename).name if filename else filename + + @staticmethod + def flights_row_to_show(row): + """An "automated_plotting_flights" entry as it is shown in the tree widget.""" + if len(row) > 3: + row = list(row) + row[3] = AutoplotDockWidget.filename_to_show(row[3]) + return row + def add_to_treewidget(self, parent, parent2, config_settings, treewidget, flight, sections, vertical, filename, itime, vtime, url, layer, styles, level): if treewidget.objectName() == "autoplotTreeWidget": @@ -316,8 +478,9 @@ def add_to_treewidget(self, parent, parent2, config_settings, treewidget, flight flight = "" else: if filename != parent2.mscolab.active_operation_name: - filename += ".ftml" - item = QTreeWidgetItem([flight, sections, vertical, filename, itime, vtime]) + filename = self.flighttrack_filename(parent, filename) + item = QTreeWidgetItem( + self.flights_row_to_show([flight, sections, vertical, filename, itime, vtime])) self.autoplotTreeWidget.addTopLevelItem(item) self.autoplotTreeWidget.setCurrentItem(item) config_settings["automated_plotting_flights"].append([flight, sections, vertical, filename, itime, vtime]) @@ -350,11 +513,11 @@ def update_treewidget(self, parent, parent2, config_settings, treewidget, flight flight = "" else: if filename != parent2.mscolab.active_operation_name: - filename += ".ftml" + filename = self.flighttrack_filename(parent, filename) if treewidget.objectName() == "autoplotTreeWidget": selected_item = self.autoplotTreeWidget.currentItem() selected_item.setText(0, flight) - selected_item.setText(3, filename) + selected_item.setText(3, self.filename_to_show(filename)) selected_item.setText(5, vtime) if self.view == "Top View": selected_item.setText(1, sections) @@ -415,7 +578,7 @@ def refresh_sig(self, config_settings): self.autoplotTreeWidget.clear() for row in autoplot_flights: - item = QTreeWidgetItem(row) + item = QTreeWidgetItem(self.flights_row_to_show(row)) self.autoplotTreeWidget.addTopLevelItem(item) self.autoplotSecsTreeWidget.clear() diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 144ac2096..163e38575 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -718,11 +718,17 @@ def style_changed(layer): break def leftrow_is_selected(self, vtime): + layer = self.multilayers.get_current_layer() + if layer is None: + # A row of the autoplot dockwidget can be selected before this widget is + # connected to a server, there is no layer to apply the time to then. + logging.debug("No layer selected, ignoring the selected row.") + return + if vtime is not None: self.cbValidTime.setCurrentText(vtime) self.valid_time_changed() - layer = self.multilayers.get_current_layer() crs = layer.get_allowed_crs() if crs and \ self.parent() is not None and \ diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 0cd8b8dd8..273a26a74 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -307,6 +307,8 @@ class MSUIDefaultConfig: fixed_dict_options = ["layout", "wms_prefetch", "topview", "sideview", "linearview"] # List options with fixed length fixed_list_options = ["MSCOLAB_timeout", ] + # List options whose string entries take any string, e.g. a flight track path + free_string_list_options = ["automated_plotting_flights", ] # Fixed key/value pair options key_value_options = [ @@ -420,6 +422,7 @@ class MSUIDefaultConfig: "list_option_structure", "key_value_options", "config_descriptions", + "free_string_list_options", ]: del default_options[key] @@ -655,7 +658,9 @@ def merge_dict(existing_dict, new_dict): if key not in MSUIDefaultConfig.fixed_list_options: for i in range(len(new_dict[key])): for los_key_item in los[key]: - data, match = compare_data(los_key_item, new_dict[key][i]) + data, match = compare_data(los_key_item, + new_dict[key][i], + any_string=key in MSUIDefaultConfig.free_string_list_options) if match: temp_data.append(data) break @@ -681,7 +686,7 @@ def merge_dict(existing_dict, new_dict): return existing_dict -def compare_data(default, user_data): +def compare_data(default, user_data, any_string=False): """ Recursively compares two dictionaries based on qt_json_view datatypes and returns default or user_data appropriately. @@ -694,6 +699,8 @@ def compare_data(default, user_data): if not isinstance(default, dict) and not isinstance(default, list): if isinstance(default, float) and isinstance(user_data, int): user_data = float(default) + if any_string and isinstance(default, str) and isinstance(user_data, str): + return user_data, True if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): return user_data, True if isinstance(match_type(default), type(match_type(user_data))): @@ -707,7 +714,7 @@ def compare_data(default, user_data): if isinstance(default, list) and isinstance(user_data, list): if len(default) == len(user_data): for i in range(len(default)): - data[i], match = compare_data(default[i], user_data[i]) + data[i], match = compare_data(default[i], user_data[i], any_string=any_string) matches.append(match) else: return default, False @@ -717,7 +724,7 @@ def compare_data(default, user_data): if default.keys() == user_data.keys(): for key in default: if key in user_data: - data[key], match = compare_data(default[key], user_data[key]) + data[key], match = compare_data(default[key], user_data[key], any_string=any_string) matches.append(match) else: matches.append(False) diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py new file mode 100644 index 000000000..fd907c7dc --- /dev/null +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_msui.test_autoplot_dockwidget + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides pytest functions to tests msui.autoplot_dockwidget + + This file is part of MSS. + + :copyright: Copyright 2026 Jan Eisermann + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import os +import shutil +from pathlib import Path + +from mslib.msui import flighttrack as ft +from mslib.msui.autoplot_dockwidget import AutoplotDockWidget + + +class FakeView: + """A view window, reduced to what the dockwidget reads from it.""" + def __init__(self, waypoints_model): + self.waypoints_model = waypoints_model + + +class TestFlighttrackFilename: + """ + mssautoplot only finds the flight track of an "automated_plotting_flights" entry + when the entry carries the directory too, so the dockwidget stores path + file name. + """ + def test_flighttrack_of_a_file(self, tmp_path): + example = tmp_path / "example.ftml" + shutil.copy(os.path.join(os.path.dirname(__file__), "..", "data", "example.ftml"), example) + view = FakeView(ft.WaypointsTableModel(filename=str(example))) + assert view.waypoints_model.name == "example" + assert AutoplotDockWidget.flighttrack_filename(view, "example") == str(example) + + def test_flighttrack_without_a_file(self): + view = FakeView(ft.WaypointsTableModel(name="flight1")) + assert AutoplotDockWidget.flighttrack_filename(view, "flight1") == "flight1.ftml" + + +class TestResolveFlightsPaths: + """ + A configuration file can name the flight track relative to its own directory, + the GUI is started from an arbitrary working directory and has to resolve it. + """ + def test_bare_filename_next_to_the_config(self, tmp_path): + flights = [["flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == [ + ["flight1", "01 SADPAP (stereo)", "", str(tmp_path.resolve() / "example.ftml"), "", ""]] + + def test_relative_directory(self, tmp_path): + flights = [["flight1", "", "", os.path.join("flights", "example.ftml"), "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + str(tmp_path.resolve() / "flights" / "example.ftml") + + def test_absolute_path_is_kept(self, tmp_path): + flights = [["flight1", "", "", "/home/mss/flights/example.ftml", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + "/home/mss/flights/example.ftml" + + def test_home_is_expanded(self, tmp_path): + flights = [["flight1", "", "", os.path.join("~", "example.ftml"), "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + str(Path.home().resolve() / "example.ftml") + + def test_operation_is_unchanged(self, tmp_path): + flights = [["operation1", "01 SADPAP (stereo)", "", "operation1", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == flights + + def test_entries_without_a_flighttrack(self, tmp_path): + flights = [["", "", "", "", "", ""], []] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == flights + + def test_the_configuration_is_not_modified(self, tmp_path): + flights = [["flight1", "", "", "example.ftml", "", ""]] + AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) + assert flights == [["flight1", "", "", "example.ftml", "", ""]] + + +class TestMissingFlighttrack: + """ + The download button stops on a flight track file which is not there, instead of + letting mssautoplot leave through SystemExit. + """ + def test_all_files_are_there(self, tmp_path): + example = tmp_path / "example.ftml" + shutil.copy(os.path.join(os.path.dirname(__file__), "..", "data", "example.ftml"), example) + flights = [["flight1", "", "", str(example), "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) is None + + def test_missing_file(self, tmp_path): + missing = tmp_path / "example.ftml" + flights = [["flight1", "", "", str(missing), "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) == ("flight1", str(missing), missing) + + def test_flighttrack_which_was_never_saved(self, tmp_path, monkeypatch): + # only the name of the track is stored, it is looked up in the working directory + monkeypatch.chdir(tmp_path) + flights = [["flight1", "", "", "flight1.ftml", "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) == ( + "flight1", "flight1.ftml", Path(tmp_path).resolve() / "flight1.ftml") + + def test_operation_and_empty_entries(self): + flights = [["operation1", "", "", "operation1", "", ""], ["", "", "", "", "", ""], []] + assert AutoplotDockWidget.missing_flighttrack(flights) is None + + +class TestMissingFlighttrackMessage: + """ + The path a lookup fails at is rarely the one the user typed, so the message says + where it comes from. + """ + def test_relative_name_of_a_configuration(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/example.ftml", Path("/home/mss/example.ftml"), + ("/home/mss/mssautoplot.json", "example.ftml")) + assert message == ( + "The flight track file of 'flight1' does not exist:\n" + "/home/mss/example.ftml\n" + "\n" + "The configuration /home/mss/mssautoplot.json names it 'example.ftml', " + "without a directory, so it is looked up next to that file.\n" + "Correct it there, or open the flight track in the MSUI, save it and add the row again.") + + def test_path_of_a_configuration(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/flights/example.ftml", Path("/home/mss/flights/example.ftml"), + ("/home/mss/mssautoplot.json", "/home/mss/flights/example.ftml")) + assert "This path is stored in the configuration /home/mss/mssautoplot.json." in message + assert "names it" not in message + + def test_name_without_a_directory(self, tmp_path, monkeypatch): + """ + Such an entry is either a flight track which was never saved, or a + configuration which names the file without a directory. The entry does not + tell which of the two, so the message names both remedies and does not + claim the track was never saved. + """ + monkeypatch.chdir(tmp_path) + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "flight1.ftml", Path(tmp_path).resolve() / "flight1.ftml") + assert "Only the name 'flight1.ftml' is stored, without a directory" in message + assert f"working directory {Path(tmp_path).resolve()}" in message + assert "Save the flight track in the MSUI and add the row again, or store the name " \ + "with its directory in the configuration file." in message + assert "never saved" not in message + + def test_file_of_a_row_the_dockwidget_wrote(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/flights/example.ftml", Path("/home/mss/flights/example.ftml")) + assert "The file was moved or deleted after the row was added." in message + + +class TestFlightsSources: + """ + Which configuration file named a flight track, and how, is kept to explain a file + which is not there. + """ + def test_name_and_path_of_the_configuration(self, tmp_path): + configured = [["flight1", "", "", "example.ftml", "", ""], + ["flight2", "", "", "/home/mss/other.ftml", "", ""]] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + sources = AutoplotDockWidget.flights_sources("/home/mss/mssautoplot.json", configured, resolved) + assert sources == { + str(tmp_path.resolve() / "example.ftml"): ("/home/mss/mssautoplot.json", "example.ftml"), + "/home/mss/other.ftml": ("/home/mss/mssautoplot.json", "/home/mss/other.ftml")} + + def test_entries_without_a_flighttrack_file_are_skipped(self, tmp_path): + configured = [["", "", "", "", "", ""], ["operation1", "", "", "operation1", "", ""], []] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + assert AutoplotDockWidget.flights_sources("mssautoplot.json", configured, resolved) == {} + + def test_two_entries_of_the_same_file(self, tmp_path): + """ + The first entry wins, both explanations name the same missing file and the same + configuration to correct it in. + """ + configured = [["flight1", "", "", "example.ftml", "", ""], + ["flight2", "", "", str(tmp_path / "example.ftml"), "", ""]] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + sources = AutoplotDockWidget.flights_sources("mssautoplot.json", configured, resolved) + assert sources == {str(tmp_path.resolve() / "example.ftml"): ("mssautoplot.json", "example.ftml")} + + +class TestRowToShow: + """ + The tree widget shows the file name alone, the path stays in the configuration. + """ + def test_path_is_hidden(self): + row = ["flight1", "01 SADPAP (stereo)", "", "/home/mss/flights/example.ftml", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == [ + "flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""] + # the entry itself keeps its path + assert row[3] == "/home/mss/flights/example.ftml" + + def test_bare_filename_is_unchanged(self): + row = ["flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == row + + def test_operation_is_unchanged(self): + row = ["operation1", "01 SADPAP (stereo)", "", "operation1", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == row + + def test_empty_rows(self): + assert AutoplotDockWidget.flights_row_to_show([]) == [] + assert AutoplotDockWidget.flights_row_to_show(["", "", "", "", "", ""]) == ["", "", "", "", "", ""] diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index bf097e9f5..c90ade15d 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -145,6 +145,15 @@ def test_connection_error(self, qtbot): self.query_server(qtbot, f"{self.scheme}://.....{self.host}:{self.port}") qtbot.wait_until(mock_critical.assert_called_once) + def test_leftrow_is_selected_without_a_layer(self, qtbot): + """ + A row of the autoplot dockwidget can be selected before this widget is connected + to a server. There is no layer to apply the valid time to then, which must not + end in an AttributeError on None. + """ + assert self.window.multilayers.get_current_layer() is None + self.window.leftrow_is_selected("2012-10-17T12:00:00Z") + @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") def test_forward_backward_clicks(self, qtbot): self.query_server(qtbot, self.url) diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 7d53093d4..1876ab0a8 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -31,6 +31,7 @@ from pathlib import Path from mslib import utils +from mslib.utils import constants from mslib.utils.config import MSUIDefaultConfig as mss_default from mslib.utils.config import config_loader, read_config_file, modify_config_file from mslib.utils.config import merge_dict @@ -286,3 +287,47 @@ def test_add_filepicker_default_to_plugins(self): users_options_dict = {"export_plugins": {"Text": ["txt", "mslib.plugins.io.text", "save_to_txt"]}} changed_dict = merge_dict(self.default_dict, users_options_dict) assert changed_dict["export_plugins"]["Text"] == ["txt", "mslib.plugins.io.text", "save_to_txt", "default"] + + def test_user_option_with_path(self): + """ + The entries of an option in free_string_list_options take any string, a path + included. The flight track of an "automated_plotting_flights" entry is stored + as path + file name, its default is "". + """ + users_options_dict = { + "automated_plotting_flights": [["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]], + } + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["automated_plotting_flights"] == [ + ["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]] + + def test_string_option_outside_free_string_list_needs_its_type(self): + """ + Only the options in free_string_list_options take any string. A plain string + option elsewhere keeps its type check, a path is not taken over. + """ + assert self.default_dict["filepicker_default"] == "default" + users_options_dict = {"filepicker_default": "/tmp/x"} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["filepicker_default"] == "default" + + def test_path_option_needs_a_path(self): + """ + An option whose default is a path keeps requiring one, a relative directory + is not taken over. + """ + assert self.default_dict["data_dir"] == str(constants.MSUI_DOCUMENTS_PATH) + users_options_dict = {"data_dir": "/home/mss/mssdata", "wms_cache": "wms_cache"} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["data_dir"] == "/home/mss/mssdata" + assert changed_dict["wms_cache"] == str(constants.MSUI_CACHE_PATH / "wms_cache") + + def test_url_option_needs_a_url(self): + """ + An option whose default is a url keeps requiring one, a path is not taken over. + A plain string stays accepted, e.g. a server which is named without a scheme. + """ + users_options_dict = {"default_WMS": ["/home/mss/wms"], "default_VSEC_WMS": ["localhost:8081"]} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["default_WMS"] == self.default_dict["default_WMS"] + assert changed_dict["default_VSEC_WMS"] == ["localhost:8081"] diff --git a/tests/_test_utils/test_mssautoplot.py b/tests/_test_utils/test_mssautoplot.py index c8e90c0f2..a144aedf1 100644 --- a/tests/_test_utils/test_mssautoplot.py +++ b/tests/_test_utils/test_mssautoplot.py @@ -27,8 +27,9 @@ import os +from pathlib import Path -from mslib.autoplot import load_from_ftml +from mslib.autoplot import load_from_ftml, resolve_ftml_path def test_load_from_ftml(): @@ -43,3 +44,51 @@ def test_load_from_ftml(): (63.74, 1.73, 0.0, 'C', 'Landing')] assert len(wp_list) == 5 assert type(wp_list[0]).__name__ == 'Waypoint' + + +class TestResolveFtmlPath: + """ + The flight track of an "automated_plotting_flights" entry is stored as path + file name, + --fpath overrides the directory of that entry. + """ + def test_absolute_path_is_kept(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml") == Path("/home/mss/flights/example.ftml") + + def test_bare_filename_relative_to_working_directory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml") == Path(tmp_path).resolve() / "example.ftml" + + def test_relative_path_relative_to_working_directory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path(os.path.join("flights", "example.ftml")) == \ + Path(tmp_path).resolve() / "flights" / "example.ftml" + + def test_user_directory_is_expanded(self): + resolved = resolve_ftml_path(os.path.join("~", "example.ftml")) + assert resolved == Path.home().resolve() / "example.ftml" + + def test_fpath_overrides_directory(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml", "/data/campaign") == \ + Path("/data/campaign/example.ftml") + + def test_fpath_overrides_bare_filename(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml", "/data/campaign") == Path("/data/campaign/example.ftml") + + def test_empty_fpath_is_ignored(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml", "") == Path("/home/mss/flights/example.ftml") + + def test_directory_anchors_a_relative_name(self, tmp_path, monkeypatch): + # the directory of the configuration file which named the flight track, + # not the working directory + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml", directory="/home/mss/flights") == \ + Path("/home/mss/flights/example.ftml") + + def test_directory_does_not_touch_an_absolute_path(self): + assert resolve_ftml_path("/home/mss/example.ftml", directory="/data/campaign") == \ + Path("/home/mss/example.ftml") + + def test_fpath_wins_over_directory(self): + assert resolve_ftml_path("example.ftml", fpath="/data/campaign", directory="/home/mss/flights") == \ + Path("/data/campaign/example.ftml")