diff --git a/artemis.py b/artemis.py index 67fd660..5fb3f7b 100644 --- a/artemis.py +++ b/artemis.py @@ -41,7 +41,8 @@ from utilities import (checksum_ok, is_undef_freq, is_undef_band, format_numbers, - resource_path,) + resource_path, + safe_cast) # import default_imgs_rc @@ -51,8 +52,10 @@ Ui_MainWindow, _ = uic.loadUiType(qt_creator_file) class Artemis(QMainWindow, Ui_MainWindow): + """Main application class.""" def __init__(self): + """Set all connections of the application.""" super().__init__() self.setupUi(self) self.set_initial_size() @@ -481,8 +484,8 @@ class Artemis(QMainWindow, Ui_MainWindow): # Left list widget and search bar. self.search_bar.textChanged.connect(self.display_signals) - self.result_list.currentItemChanged.connect(self.display_specs) - self.result_list.itemDoubleClicked.connect( + self.signals_list.currentItemChanged.connect(self.display_specs) + self.signals_list.itemDoubleClicked.connect( lambda: self.main_tab.setCurrentWidget(self.signal_properties_tab) ) self.audio_widget = AudioPlayer( @@ -536,18 +539,31 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def start_update_forecast(self): + """Start the update of the 3-day forecast screen. + + Start the corresponding thread. + """ if not self.forecast_data.is_updating: self.update_forecast_bar.set_updating() self.forecast_data.update() @pyqtSlot() def start_update_space_weather(self): + """Start the update of the space weather screen. + + Start the corresponding thread. + """ if not self.space_weather_data.is_updating: self.update_now_bar.set_updating() self.space_weather_data.update() @pyqtSlot(bool) def update_forecast(self, status_ok): + """Update the 3-day forecast screen after a successful download. + + If the download was not successful throw a warning. In any case remove + the downloaded data. + """ self.update_forecast_bar.set_idle() if status_ok: self.forecast_data.update_all_labels() @@ -558,9 +574,14 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot(bool) def update_space_weather(self, status_ok): + """Update the space weather screen after a successful download. + + If the download was not successful throw a warning. In any case remove + the downloaded data. + """ self.update_now_bar.set_idle() if status_ok: - xray_long = float(self.space_weather_data.xray[-1][7]) + xray_long = safe_cast(self.space_weather_data.xray[-1][7], float) format_text = lambda letter, power: letter + f"{xray_long * 10**power:.1f}" if xray_long < 1e-8 and xray_long != -1.00e+05: self.peak_flux_lbl.setText(format_text("= 10 and pro10 < 100: @@ -608,9 +629,13 @@ class Artemis(QMainWindow, Ui_MainWindow): elif pro10 == -1.00e+05: self.switchable_s_labels.switch_off_all() - k_index = int(self.space_weather_data.ak_index[8][11].replace('.', '')) + k_index = safe_cast( + self.space_weather_data.ak_index[8][11].replace('.', ''), int + ) self.k_index_lbl.setText(str(k_index)) - a_index = int(self.space_weather_data.ak_index[7][7].replace('.', '')) + a_index = safe_cast( + self.space_weather_data.ak_index[7][7].replace('.', ''), int + ) self.a_index_lbl.setText(str(a_index)) if k_index == 0: @@ -669,7 +694,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.a_storm_labels.switch_on(self.a_sev_storm_lbl) index = self.space_weather_data.geo_storm[6].index("was") + 1 - k_index_24_hmax = int(self.space_weather_data.geo_storm[6][index]) + k_index_24_hmax = safe_cast( + self.space_weather_data.geo_storm[6][index], int + ) if k_index_24_hmax == 0: self.switchable_g_today_labels.switch_on(self.g0_today_lbl) elif k_index_24_hmax == 1: @@ -691,12 +718,18 @@ class Artemis(QMainWindow, Ui_MainWindow): elif k_index_24_hmax == 9: self.switchable_g_today_labels.switch_on(self.g5_today_lbl) - val = int(self.space_weather_data.ak_index[7][2].replace('.', '')) + val = safe_cast( + self.space_weather_data.ak_index[7][2].replace('.', ''), int + ) self.sfi_lbl.setText(f"{val}") - val = int([x[4] for x in self.space_weather_data.sgas if "SSN" in x][0]) + val = safe_cast( + [x[4] for x in self.space_weather_data.sgas + if "SSN" in x][0], int + ) self.sn_lbl.setText(f"{val:d}") - for label, pixmap in zip(self.space_weather_labels, self.space_weather_data.images): + for label, pixmap in zip(self.space_weather_labels, + self.space_weather_data.images): label.pixmap = pixmap label.make_transparent() label.apply_pixmap() @@ -707,6 +740,12 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def go_to_gfd(self, by): + """Open a browser tab with the GFD site. + + Make the search by frequency or location. + Argument: + by -- either GfdType.FREQ or GfdType.LOC. + """ query = "/?q=" if by is GfdType.FREQ: value_in_mhz = self.freq_gfd.value() \ @@ -722,23 +761,37 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot(QListWidgetItem) def remove_if_unselected_modulation(self, item): + """If an item is unselected from the modulations list, remove the item.""" if not item.isSelected(): self.show_matching_modulations(self.search_bar_modulation.text()) @pyqtSlot(QListWidgetItem) def remove_if_unselected_location(self, item): + """If an item is unselected from the locations list, remove the item.""" if not item.isSelected(): self.show_matching_locations(self.search_bar_location.text()) @pyqtSlot(str) def show_matching_modulations(self, text): + """Show the modulations which matches 'text'. + + The match criterion is defined in 'show_matching_strings'.""" self.show_matching_strings(self.modulation_list, text) @pyqtSlot(str) def show_matching_locations(self, text): + """Show the locations which matches 'text'. + + The match criterion is defined in 'show_matching_strings'.""" self.show_matching_strings(self.locations_list, text) def show_matching_strings(self, list_elements, text): + """Show all elements of QListWidget the matches (even partially) a target text. + + Arguments: + list_elements -- the QListWidget + text -- the target text. + """ for index in range(list_elements.count()): item = list_elements.item(index) if text.lower() in item.text().lower() or item.isSelected(): @@ -747,6 +800,7 @@ class Artemis(QMainWindow, Ui_MainWindow): item.setHidden(True) def set_mode_tree_widget(self): + """Construct the QTreeWidget for the 'Mode' screen.""" for parent, children in Constants.MODES.items(): iparent = QTreeWidgetItem([parent]) self.mode_tree_widget.addTopLevelItem(iparent) @@ -756,6 +810,10 @@ class Artemis(QMainWindow, Ui_MainWindow): self.mode_tree_widget.expandAll() def manage_mode_selections(self): + """Rules the selection of childs items of the 'Mode' QTreeWidget. + + If a parent is selected all its children will be selected as well. + """ selected_items = self.mode_tree_widget.selectedItems() parents = Constants.MODES.keys() for parent in parents: @@ -765,10 +823,12 @@ class Artemis(QMainWindow, Ui_MainWindow): item.child(i).setSelected(True) def set_initial_size(self): - """Function to handle high resolution screens. The function sets bigger - sizes for all the relevant fixed-size widgets. - Also by default it sets the size to 3/4 of the available space - both vertically and horizontally.""" + """Handle high resolution screens. + + Set bigger sizes for all the relevant fixed-size widgets. + Also by default set the size to 3/4 of the available space both + vertically and horizontally. + """ d = QDesktopWidget().availableGeometry() w = d.width() h = d.height() @@ -796,6 +856,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.freq_gfd.setFixedWidth(200) self.unit_freq_gfd.setFixedWidth(120) + self.mode_tree_widget.setMinimumWidth(500) + self.modulation_list.setMinimumWidth(500) + self.audio_progress.setFixedHeight(20) self.volume.setStyleSheet(""" QSlider::groove:horizontal { @@ -815,12 +878,23 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def download_db(self): + """Start the database download. + + Do nothing if already downloading. + """ if not self.download_window.isVisible(): self.download_window.start_download() self.download_window.show() @pyqtSlot() def ask_if_download(self): + """Check if the database is at its latest version. + + If a new database is available automatically start the download. + If not ask if should download it anyway. + If already downloading do nothing. + Handle possible connection errors. + """ if not self.download_window.isVisible(): db_path = os.path.join(Constants.DATA_FOLDER, Database.NAME) try: @@ -848,6 +922,13 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def check_db_ver(self): + """Check if the database is at its latest version. + + If a new database version is available, ask if it should be downloaded. + If not display a message. + If already downloading do nothing. + Handle possible connection errors. + """ if not self.download_window.isVisible(): db_path = os.path.join(Constants.DATA_FOLDER, Database.NAME) answer = None @@ -881,11 +962,17 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def show_downloaded_signals(self): + """Load and display the database signal list.""" self.search_bar.setEnabled(True) self.load_db() self.display_signals() def load_db(self): + """Load the database from file. + + Populate the signals list and set the total number of signals. + Handle possible missing file. + """ names = Database.NAMES try: self.db = read_csv(os.path.join(Constants.DATA_FOLDER, Database.NAME), @@ -908,9 +995,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.db.fillna(Constants.UNKNOWN, inplace=True) self.db[Signal.WIKI_CLICKED] = False self.update_status_tip(self.total_signals) - self.result_list.clear() - self.result_list.addItems(self.signal_names) - self.result_list.setCurrentItem(None) + self.signals_list.clear() + self.signals_list.addItems(self.signal_names) + self.signals_list.setCurrentItem(None) self.modulation_list.addItems( self.collect_list( Signal.MODULATION @@ -923,6 +1010,13 @@ class Artemis(QMainWindow, Ui_MainWindow): ) def collect_list(self, list_property, separator=';'): + """Collect all the entrys of a QListWidget. + + Handle multiple entries in one item seprated by a separator. + + Keyword argument: + seprator -- the separator character for multiple-entries items. + """ values = self.db[list_property] values = list( set([ @@ -939,6 +1033,9 @@ class Artemis(QMainWindow, Ui_MainWindow): lower_spin_box, upper_combo_box, upper_spin_box): + """Forbid to a lower limit to be greater than the corresponding upper one. + + Used for frequency and bandwidth screens.""" if lower_spin_box.isEnabled(): unit_conversion = {'Hz' : ['kHz', 'MHz', 'GHz'], 'kHz': ['MHz', 'GHz'], @@ -979,6 +1076,9 @@ class Artemis(QMainWindow, Ui_MainWindow): upper_unit, upper_confidence, range_lbl): + """Display the actual range applied for the signal's property search. + + Used for frequency and bandwidth screens.""" activate_low = False activate_high = False color = self.inactive_color @@ -1020,6 +1120,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def set_acf_interval_label(self): + """Display the actual acf interval for the search.""" tolerance = self.acf_spinbox.value() * self.acf_confidence.value() / 100 if tolerance > 0: val = round(self.acf_spinbox.value() - tolerance, Constants.MAX_DIGITS) @@ -1032,12 +1133,17 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def activate_if_toggled(self, radio_btn, *widgets): + """If radio_btn is toggled, activate all *widgets. + + Do nothing otherwise. + """ toggled = radio_btn.isChecked() for w in widgets[:-1]: # Neglect the bool coming from the emitted signal. w.setEnabled(toggled) @pyqtSlot() def display_signals(self): + """Display all the signal names which matches the applied filters.""" text = self.search_bar.text() available_signals = 0 for index, signal_name in enumerate(self.signal_names): @@ -1049,15 +1155,16 @@ class Artemis(QMainWindow, Ui_MainWindow): self.modulation_filters_ok(signal_name) , self.location_filters_ok(signal_name) , self.acf_filters_ok(signal_name)]): - self.result_list.item(index).setHidden(False) + self.signals_list.item(index).setHidden(False) available_signals += 1 else: - self.result_list.item(index).setHidden(True) + self.signals_list.item(index).setHidden(True) # Remove selected item. - self.result_list.setCurrentItem(None) + self.signals_list.setCurrentItem(None) self.update_status_tip(available_signals) def update_status_tip(self, available_signals): + """Display the number of displayed signals in the status tip.""" if available_signals < self.total_signals: self.statusbar.setStyleSheet(f'color: {self.active_color}') else: @@ -1068,6 +1175,10 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_fb_filters(self, ftype): + """Reset the Frequency or Bandwidth depending og 'ftype'. + + ftype can be either Ftype.FREQ or Ftype.BAND. + """ if ftype != Ftype.FREQ and ftype != Ftype.BAND: raise ValueError("Wrong ftype in function 'reset_fb_filters'") @@ -1102,6 +1213,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_cat_filters(self): + """Reset the category filter screen.""" uncheck_and_emit(self.apply_remove_cat_filter_btn) for f in self.cat_filter_btns: if f.isChecked(): @@ -1110,6 +1222,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_mode_filters(self): + """Reset the mode filter screen.""" uncheck_and_emit(self.apply_remove_mode_filter_btn) parents = Constants.MODES.keys() selected_children = [] @@ -1125,6 +1238,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_modulation_filters(self): + """Reset the modulation filter screen.""" uncheck_and_emit(self.apply_remove_modulation_filter_btn) self.search_bar_modulation.setText('') self.show_matching_strings( @@ -1137,6 +1251,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_location_filters(self): + """Reset the location filter screen.""" uncheck_and_emit(self.apply_remove_location_filter_btn) self.search_bar_location.setText('') self.show_matching_strings( @@ -1149,6 +1264,7 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_acf_filters(self): + """Reset the acf filter screen.""" uncheck_and_emit(self.apply_remove_acf_filter_btn) if self.include_undef_acf.isChecked(): self.include_undef_acf.setChecked(False) @@ -1156,6 +1272,7 @@ class Artemis(QMainWindow, Ui_MainWindow): self.acf_confidence.setValue(0) def frequency_filters_ok(self, signal_name): + """Evalaute if the a signal matches the frequency filters.""" if not self.apply_remove_freq_filter_btn.isChecked(): return True undef_freq = is_undef_freq(self.db.loc[signal_name]) @@ -1166,8 +1283,8 @@ class Artemis(QMainWindow, Ui_MainWindow): return False signal_freqs = ( - int(self.db.at[signal_name, Signal.INF_FREQ]), - int(self.db.at[signal_name, Signal.SUP_FREQ]) + safe_cast(self.db.at[signal_name, Signal.INF_FREQ], int), + safe_cast(self.db.at[signal_name, Signal.SUP_FREQ], int) ) band_filter_ok = False @@ -1195,6 +1312,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return lower_limit_ok and upper_limit_ok def band_filters_ok(self, signal_name): + """Evalaute if the a signal matches the band filters.""" if not self.apply_remove_band_filter_btn.isChecked(): return True undef_band = is_undef_band(self.db.loc[signal_name]) @@ -1205,8 +1323,8 @@ class Artemis(QMainWindow, Ui_MainWindow): return False signal_bands = ( - int(self.db.at[signal_name, Signal.INF_BAND]), - int(self.db.at[signal_name, Signal.SUP_BAND]) + safe_cast(self.db.at[signal_name, Signal.INF_BAND], int), + safe_cast(self.db.at[signal_name, Signal.SUP_BAND], int) ) lower_limit_ok = True @@ -1224,6 +1342,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return lower_limit_ok and upper_limit_ok def category_filters_ok(self, signal_name): + """Evalaute if the a signal matches the category filters.""" if not self.apply_remove_cat_filter_btn.isChecked(): return True cat_code = self.db.at[signal_name, Signal.CATEGORY_CODE] @@ -1240,6 +1359,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return cat_checked == positive_cases and cat_checked > 0 def mode_filters_ok(self, signal_name): + """Evalaute if the a signal matches the mode filters.""" if not self.apply_remove_mode_filter_btn.isChecked(): return True signal_mode = self.db.at[signal_name, Signal.MODE] @@ -1263,6 +1383,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return any(ok) def modulation_filters_ok(self, signal_name): + """Evalaute if the a signal matches the modulation filters.""" if not self.apply_remove_modulation_filter_btn.isChecked(): return True signal_modulation = [ @@ -1274,6 +1395,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return False def location_filters_ok(self, signal_name): + """Evalaute if the a signal matches the location filters.""" if not self.apply_remove_location_filter_btn.isChecked(): return True signal_location = self.db.at[signal_name, Signal.LOCATION] @@ -1283,6 +1405,7 @@ class Artemis(QMainWindow, Ui_MainWindow): return False def acf_filters_ok(self, signal_name): + """Evalaute if the a signal matches the acf filters.""" if not self.apply_remove_acf_filter_btn.isChecked(): return True signal_acf = self.db.at[signal_name, Signal.ACF] @@ -1292,7 +1415,7 @@ class Artemis(QMainWindow, Ui_MainWindow): else: return False else: - signal_acf = float(signal_acf.rstrip("ms")) + signal_acf = safe_cast(signal_acf.rstrip("ms"), float) tolerance = self.acf_spinbox.value() * self.acf_confidence.value() / 100 upper_limit = self.acf_spinbox.value() + tolerance lower_limit = self.acf_spinbox.value() - tolerance @@ -1303,6 +1426,11 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot(QListWidgetItem, QListWidgetItem) def display_specs(self, item, previous_item): + """Display the signal properties. + + item is the item corresponding to the selected signal + previous_item is unused. + """ self.display_spectrogram() if item is not None: self.current_signal_name = item.text() @@ -1368,11 +1496,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.audio_widget.set_audio_player() def display_spectrogram(self): - default_pic = os.path.join( - Constants.DEFAULT_IMGS_FOLDER, - Constants.NOT_SELECTED - ) - item = self.result_list.currentItem() + """Display the selected signal's waterfall.""" + default_pic = Constants.DEFAULT_NOT_SELECTED + item = self.signals_list.currentItem() if item: spectrogram_name = item.text() path_spectr = os.path.join( @@ -1381,23 +1507,32 @@ class Artemis(QMainWindow, Ui_MainWindow): spectrogram_name + Constants.SPECTRA_EXT ) if not QFileInfo(path_spectr).exists(): - path_spectr = os.path.join( - Constants.DEFAULT_IMGS_FOLDER, - Constants.NOT_AVAILABLE - ) + path_spectr = Constants.DEFAULT_NOT_AVAILABLE else: path_spectr = default_pic self.spectrogram.setPixmap(QPixmap(path_spectr)) def activate_band_category(self, band_label, activate=True): + """Highlight the given band_label. + + If activate is False remove the highlight (default to True). + """ color = self.active_color if activate else self.inactive_color for label in band_label: label.setStyleSheet(f"color: {color};") def set_band_range(self, current_signal=None): + """Highlight the signal's band labels. + + If no signal is selected remove all highlights. + """ if current_signal is not None and not is_undef_freq(current_signal): - lower_freq = int(current_signal.at[Signal.INF_FREQ]) - upper_freq = int(current_signal.at[Signal.SUP_FREQ]) + lower_freq = safe_cast( + current_signal.at[Signal.INF_FREQ], int + ) + upper_freq = safe_cast( + current_signal.at[Signal.SUP_FREQ], int + ) zipped = list(zip(Constants.BANDS, self.band_labels)) for i, w in enumerate(zipped): band, band_label = w @@ -1417,6 +1552,10 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def reset_all_filters(self): + """Reset all filter screens. + + Show all available signals. + """ self.reset_frequency_filters_btn.clicked.emit() self.reset_band_filters_btn.clicked.emit() self.reset_cat_filters_btn.clicked.emit() @@ -1427,6 +1566,10 @@ class Artemis(QMainWindow, Ui_MainWindow): @pyqtSlot() def go_to_web_page_signal(self): + """Go the web page of the signal's wiki. + + Do nothing is no signal is selected. + """ if self.current_signal_name: self.url_button.setStyleSheet( f"color: {self.url_button.colors.clicked}" @@ -1435,6 +1578,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.db.at[self.current_signal_name, Signal.WIKI_CLICKED] = True def closeEvent(self, event): + """Extends closeEvent of QMainWindow. + + Shutdown all active threads and close all open windows.""" self.closing = True if self.download_window.isVisible(): self.download_window.close() @@ -1447,7 +1593,9 @@ class Artemis(QMainWindow, Ui_MainWindow): if __name__ == '__main__': my_app = QApplication(sys.argv) - img = QPixmap(":/icon/default_pics/Artemis3.500px.png") + img = QPixmap(os.path.join( + ":", "icon", "default_pics", "Artemis3.500px.png") + ) splash = QSplashScreen(img) splash.show() start= time() diff --git a/artemis.ui b/artemis.ui index 287d8e6..2976eaa 100644 --- a/artemis.ui +++ b/artemis.ui @@ -119,7 +119,7 @@ - + 0 diff --git a/audio_player.py b/audio_player.py index 5ccc6c6..e26d566 100644 --- a/audio_player.py +++ b/audio_player.py @@ -7,14 +7,23 @@ from constants import Constants import qtawesome as qta -class AudioPlayer(QObject): # Maybe useless inheriting from QObject - """This is the audio player widget. The only public methods are the __init__ +class AudioPlayer(QObject): + """Subclass QObject. Audio player widget for the audio samples. + + The only public methods are the __init__ method, set_audio_player, which loads the current file and refresh_btns_colors. Everything else is managed internally.""" __time_step = 500 # Milliseconds. - def __init__(self, play, pause, stop, volume, audio_progress, active_color, inactive_color): + def __init__(self, play, + pause, + stop, + volume, + audio_progress, + active_color, + inactive_color): + """Initialize the player.""" super().__init__() self.__paused = False self.__first_call = True @@ -36,6 +45,7 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject self.refresh_btns_colors(active_color, inactive_color) def refresh_btns_colors(self, active_color, inactive_color): + """Repaint the buttons of the widgetd after the theme has changed.""" self.__play.setIcon(qta.icon('fa5.play-circle', color=active_color, color_disabled=inactive_color)) @@ -48,12 +58,14 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject @pyqtSlot() def __set_volume(self): + """Set the volume of the audio samples.""" if mixer.get_init(): mixer.music.set_volume( self.__volume.value() / self.__volume.maximum() ) def __reset_audio_widget(self): + """Reset the widget. Stop all playing samples.""" if mixer.get_init(): if mixer.music.get_busy(): mixer.music.stop() @@ -65,6 +77,7 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject @pyqtSlot() def __update_bar(self): + """Upadte the progress bar.""" pos = mixer.music.get_pos() if pos == -1: self.__timer.stop() @@ -74,11 +87,13 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject self.__audio_progress.setValue(pos) def __set_max_progress_bar(self): + """Set the maximum value of the progress bar.""" self.__audio_progress.setMaximum( mixer.Sound(self.__audio_file).get_length() * 1000 ) - def set_audio_player(self, fname = ""): + def set_audio_player(self, fname=""): + """Set the current audio sample.""" self.__first_call = True self.__reset_audio_widget() full_name = os.path.join( @@ -92,6 +107,7 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject @pyqtSlot() def __play_audio(self): + """Play the audio sample.""" if not self.__paused: if self.__first_call: self.__first_call = False @@ -111,6 +127,7 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject @pyqtSlot() def __stop_audio(self): + """Stop the audio sample.""" mixer.music.stop() self.__audio_progress.reset() self.__timer.stop() @@ -118,12 +135,14 @@ class AudioPlayer(QObject): # Maybe useless inheriting from QObject @pyqtSlot() def __pause_audio(self): + """Pause the audio sample.""" mixer.music.pause() self.__timer.stop() self.__paused = True self.__enable_buttons(True, False, False) def __enable_buttons(self, play_en, pause_en, stop_en): + """Set the three buttons status.""" self.__play.setEnabled(play_en) self.__pause.setEnabled(pause_en) self.__stop.setEnabled(stop_en) diff --git a/clickable_progress_bar.py b/clickable_progress_bar.py index 2ab387d..6cc4a13 100644 --- a/clickable_progress_bar.py +++ b/clickable_progress_bar.py @@ -4,30 +4,31 @@ from constants import Constants class ClickableProgressBar(QProgressBar): + """Subclass QProgressBar. Clickable progress bar class.""" clicked = pyqtSignal() def __init__(self, parent=None): + """Initialize the instance.""" self.__text = '' super().__init__(parent) - # def __set_text(self, text): - # self.__text = text - def text(self): + """Return the text displayed on the bar.""" return self.__text def set_idle(self): - # self.__set_text(Constants.CLICK_TO_UPDATE_STR) + """Set the bar to a non-downloading status.""" self.__text = Constants.CLICK_TO_UPDATE_STR self.setMaximum(self.minimum() + 1) def set_updating(self): - # self.__set_text(Constants.UPDATING_STR) + """Set the bar to a downloading status.""" self.__text = Constants.UPDATING_STR self.setMaximum(self.minimum()) def mousePressEvent(self, event): + """Override QWidget.mousePressEvent. Detect a click on the bar.""" if event.button() == Qt.LeftButton: self.clicked.emit() else: diff --git a/constants.py b/constants.py index c74ae24..f82e63c 100644 --- a/constants.py +++ b/constants.py @@ -4,21 +4,32 @@ import os.path class Ftype: + """Container class to differentiate between frequency and band. + + used in reset_fb_filters. + """ + FREQ = "freq" BAND = "band" class GfdType(Enum): + """Enum class to differentiate the possible GFD search criterias.""" + FREQ = auto() LOC = auto() class ChecksumWhat(Enum): + """Enum class to distinguish the object you want to verify the checksum.""" + FOLDER = auto() DB = auto() class Messages: + """Container class for messages to be displayed.""" + DB_UP_TO_DATE = "Already up to date" DB_UP_TO_DATE_MSG = "No newer version to download." DB_NEW_VER = "New version available" @@ -34,6 +45,8 @@ class Messages: class Signal: + """Container class for the signal property names.""" + NAME = "name" INF_FREQ = "inf_freq" SUP_FREQ = "sup_freq" @@ -50,6 +63,8 @@ class Signal: class Database: + """Container class for the database-related constants.""" + LINK_LOC = "https://aresvalley.com/Storage/Artemis/Database/data.zip" LINK_REF = "https://aresvalley.com/Storage/Artemis/Database/data.zip.log" NAME = "db.csv" @@ -64,17 +79,18 @@ class Database: Signal.DESCRIPTION, Signal.MODULATION, Signal.CATEGORY_CODE, - Signal.ACF,) + Signal.ACF) DELIMITER = "*" STRINGS = (Signal.INF_FREQ, Signal.SUP_FREQ, Signal.MODE, Signal.INF_BAND, Signal.SUP_BAND, - Signal.CATEGORY_CODE,) + Signal.CATEGORY_CODE) class ForecastColors: + """Container class for the forecast labels colors.""" WARNING_COLOR = "#F95423" KP9_COLOR = "#FFCCCB" KP8_COLOR = "#FFCC9A" @@ -84,6 +100,8 @@ class ForecastColors: class Constants: + """Container class for several contants of the software.""" + CLICK_TO_UPDATE_STR = "Click to update" SIGIDWIKI = "https://www.sigidwiki.com/wiki/Signal_Identification_Guide" ADD_SIGNAL_LINK = "https://www.sigidwiki.com/index.php/Special:FormEdit/Signal/?preload=Signal_Identification_Wiki:Signal_form_preload_text" @@ -119,8 +137,6 @@ class Constants: LABEL_ON_COLOR = "on" LABEL_OFF_COLOR = "off" TEXT_COLOR = "text" - NOT_AVAILABLE = "spectrumnotavailable.png" - NOT_SELECTED = "nosignalselected.png" __Band = namedtuple("Band", ["lower", "upper"]) __ELF = __Band(0, 30) # Formally it is (3, 30) Hz. __SLF = __Band(30, 300) @@ -149,10 +165,14 @@ class Constants: "Chirp Spread Spectrum": (), "FHSS-TDM": (), "RAW": (), - "SC-FDMA": (),} + "SC-FDMA": ()} APPLY = "Apply" REMOVE = "Remove" UNKNOWN = "N/A" EXTRACTING_MSG = "Extracting..." EXTRACTING_CODE = -1 + NOT_AVAILABLE = "spectrumnotavailable.png" + NOT_SELECTED = "nosignalselected.png" DEFAULT_IMGS_FOLDER = os.path.join(":", "pics", "default_pics") + DEFAULT_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED) + DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE) diff --git a/double_text_button.py b/double_text_button.py index de2ed2e..dd0c82f 100644 --- a/double_text_button.py +++ b/double_text_button.py @@ -2,11 +2,18 @@ from PyQt5.QtWidgets import QPushButton from PyQt5.QtCore import pyqtSlot class DoubleTextButton(QPushButton): + """Subclass QPushButton. + + A click will deactivate/activate a series of 'slave' widgets depending + on the 'checked' status of the button.""" + def __init__(self, parent=None): + """Extends QPushButton.__init__.""" super().__init__(parent) self.clicked.connect(self.__manage_click) def set_texts(self, text_a, text_b): + """Set the two texts to be displayed.""" self.__text_a = text_a self.__text_b = text_b @@ -15,6 +22,14 @@ class DoubleTextButton(QPushButton): ruled_by_radio_1=None, radio_2=None, ruled_by_radio_2=None): + """Set all the 'slave' widgets. + + Keyword arguments: + simple_ones -- a list of widgets. + radio_1 -- a radio button. + ruled_by_radio_1 -- a list of widgets whose status depend upon radio_1. + radio_2 -- a radio button. + ruled_by_radio_2 -- a list of widgets whose status depend upon radio_2.""" self.__simple_ones = simple_ones self.__ruled_by_radio_1 = ruled_by_radio_1 self.__radio_1 = radio_1 @@ -23,6 +38,7 @@ class DoubleTextButton(QPushButton): @pyqtSlot() def __manage_click(self): + """Set the status of all the 'slave widgets' based on the status of the instance.""" if self.isChecked(): self.setText(self.__text_b) enable = False diff --git a/download_window.py b/download_window.py index 6bf2831..09087fa 100644 --- a/download_window.py +++ b/download_window.py @@ -9,10 +9,12 @@ Ui_Download_window, _ = uic.loadUiType(resource_path("download_db_window.ui")) class DownloadWindow(QWidget, Ui_Download_window): + """Subclass QWidget and Ui_Download_window. It is the window displayed during the database download.""" complete = pyqtSignal() def __init__(self): + """Initialize the window.""" super().__init__() self.setupUi(self) self.setWindowFlags( @@ -37,17 +39,21 @@ class DownloadWindow(QWidget, Ui_Download_window): self.cancel_btn.clicked.connect(self.__terminate_process) def start_download(self): + """Start the download thread.""" self.__download_thread.start() def __downlaod_format_str(self, n, speed): + """Return a well-formatted string with downloaded MB and speed.""" return f"Downloaded MB: {n}\nSpeed: {speed} MB/s" def show(self): + """Extends QWidget.show. Set downloaded MB and speed to zero.""" self.status_lbl.setText(self.__downlaod_format_str(0, 0)) super().show() @pyqtSlot(int, float) def __display_progress(self, progress, speed): + """Display the downloaded MB and speed.""" if progress != Constants.EXTRACTING_CODE: self.status_lbl.setText(self.__downlaod_format_str(progress, speed)) elif progress == Constants.EXTRACTING_CODE: @@ -55,6 +61,7 @@ class DownloadWindow(QWidget, Ui_Download_window): @pyqtSlot() def __terminate_process(self): + """Terminate the download thread and close.""" if self.__download_thread.isRunning(): self.__download_thread.terminate() self.__download_thread.wait() @@ -62,6 +69,7 @@ class DownloadWindow(QWidget, Ui_Download_window): @pyqtSlot() def __wait_close(self): + """Decide the action based on the download thread status and close.""" if self.__download_thread.status is ThreadStatus.OK: self.complete.emit() self.close() @@ -73,6 +81,7 @@ class DownloadWindow(QWidget, Ui_Download_window): self.close() def reject(self): + """Extends QWidget.reject. Terminate the download thread.""" if self.__download_thread.isRunning(): self.__download_thread.terminate() self.__download_thread.wait() diff --git a/fixed_aspect_ratio_label.py b/fixed_aspect_ratio_label.py index a8a5d18..a8e90d4 100644 --- a/fixed_aspect_ratio_label.py +++ b/fixed_aspect_ratio_label.py @@ -3,21 +3,29 @@ from PyQt5.QtCore import Qt class FixedAspectRatioLabel(QLabel): + """Subclass QLabel. A resizable label class.""" + def __init__(self, parent = None): + """Initialize the instance. Set the pixmap to None.""" super().__init__(parent) self.pixmap = None def set_default_stylesheet(self): + """Set the initial stylesheet of the label.""" self.setStyleSheet("""border-width: 1px; border-style: solid; border-color: black;""" ) def make_transparent(self): + """Make the label transparent. + + Remove text and border.""" self.setText('') self.setStyleSheet("border-width: 0px;") def apply_pixmap(self): + """Apply a scaled pixmap without modifying the dimension of the original one.""" if self.pixmap: self.setPixmap( self.pixmap.scaled( @@ -26,5 +34,6 @@ class FixedAspectRatioLabel(QLabel): ) def rescale(self, size): + """Rescale the widget and the displayed pixmap to the given size.""" self.resize(size) self.apply_pixmap() diff --git a/fixed_aspect_ratio_widget.py b/fixed_aspect_ratio_widget.py index 297166f..3de30df 100644 --- a/fixed_aspect_ratio_widget.py +++ b/fixed_aspect_ratio_widget.py @@ -3,12 +3,16 @@ from PyQt5.QtCore import QSize class FixedAspectRatioWidget(QWidget): + """Subclass QWidget. Keep all the internal labels to a fixed aaspect ratio.""" + space = 10 def __init__(self, parent=None): + """Initialize the instance.""" super().__init__(parent) self.labels = [] def resizeEvent(self, event): + """Override QWidget.resizeEvent. Rescale all the internal widgets.""" h, w = self.height(), self.width() h_lbl = h / 9 - self.space w_lbl = 5 * h_lbl diff --git a/switchable_label.py b/switchable_label.py index daffe0e..53dce85 100644 --- a/switchable_label.py +++ b/switchable_label.py @@ -3,34 +3,49 @@ from constants import ForecastColors class _BaseSwitchableLabel(QLabel): + """Subclass QLabel. Base class for the switchable labels.""" + def __init__(self, parent=None): + """Set is_on = False and level = 0.""" super().__init__(parent) self.is_on = False self.level = 0 def switch_on(self): + """Set is_on = True.""" self.is_on = True def switch_off(self): + """Set is_on = False.""" self.is_on = False class SwitchableLabel(_BaseSwitchableLabel): + """Subclass _BaseSwitchableLabel.""" + def __init__(self, parent=None): + """Define text and colors attributes.""" super().__init__(parent) self.switch_on_colors = () self.switch_off_colors = () self.text_color = '' def switch_on(self): + """Extend _BaseSwitchableLabel.switch_on. + + Apply the active state colors.""" super().switch_on() self.__apply_colors(*self.switch_on_colors) def switch_off(self): + """Extend _BaseSwitchableLabel.switch_off. + + Apply the inactive state colors.""" super().switch_off() self.__apply_colors(*self.switch_off_colors) def __apply_colors(self, start, end): + """Set text and background color of the label.""" self.setStyleSheet( f""" color:{self.text_color}; @@ -40,25 +55,33 @@ class SwitchableLabel(_BaseSwitchableLabel): class SingleColorSwitchableLabel(_BaseSwitchableLabel): + """Subclass _BaseSwitchableLabel.""" + + THRESHOLD = 30 + def __init__(self, parent=None): + """Set default active color.""" super().__init__(parent) self.active_color = ForecastColors.WARNING_COLOR def switch_on(self): - if self.level >= 30: + """Extend _BaseSwitchableLabel.switch_on. + + Apply the active state color if level >= THRESHOLD.""" + if self.level >= self.THRESHOLD: super().switch_on() - self.setStyleSheet(f"color: {self.active_color}" - # f"""background-color: {self.active_color}; - # color: #000000;""" - ) + self.setStyleSheet(f"color: {self.active_color}") def switch_off(self): + """Extend _BaseSwitchableLabel.switch_off. + + Apply an empty stylesheet.""" super().switch_off() - # self.setStyleSheet("""background-color: transparent;""") self.setStyleSheet("") class MultiColorSwitchableLabel(_BaseSwitchableLabel): + """Subclass _BaseSwitchableLabel.""" LEVEL_COLORS = { 9: ForecastColors.KP9_COLOR, @@ -68,11 +91,18 @@ class MultiColorSwitchableLabel(_BaseSwitchableLabel): 5: ForecastColors.KP5_COLOR } + MIN_LEVEL = list(LEVEL_COLORS.keys())[-1] + MAX_LEVEL = list(LEVEL_COLORS.keys())[0] + def __init__(self, parent=None): + """Initialize the instance.""" super().__init__(parent) def switch_on(self): - if 5 <= self.level <= 9: + """Extend _BaseSwitchableLabel.switch_on. + + Apply the active state color based on LEVEL_COLORS.""" + if self.MIN_LEVEL <= self.level <= self.MAX_LEVEL: super().switch_on() self.setStyleSheet( f"""color: {self.LEVEL_COLORS[self.level]}; @@ -80,20 +110,27 @@ class MultiColorSwitchableLabel(_BaseSwitchableLabel): ) def switch_off(self): + """Extend _BaseSwitchableLabel.switch_off. + + Apply an empty stylesheet.""" super().switch_off() - # self.setStyleSheet("background-color: transparent;") self.setStyleSheet("") class SwitchableLabelsIterable: + """Iterable class of _BaseSwitchableLabel.""" + def __init__(self, *labels): + """Set the labels to iterate through.""" self.labels = labels def __iter__(self): + """Define the iterator.""" for lab in self.labels: yield lab def switch_on(self, label): + """Switch on the label 'label. Switch off all the other labels.""" for lab in self.labels: if lab is label: lab.switch_on() @@ -101,14 +138,19 @@ class SwitchableLabelsIterable: lab.switch_off() def switch_off_all(self): + """Switch off all the labels.""" for lab in self.labels: lab.switch_off() def set(self, attr, value): + """Set the attribute 'attr' equal to 'value' for all the labels.""" for lab in self.labels: setattr(lab, attr, value) def refresh(self): + """Refresh the state of all the labels. + + Used after theme has changed.""" for lab in self.labels: if lab.is_on: lab.switch_on() diff --git a/themesmanager.py b/themesmanager.py index e12eb0d..5d2ed18 100644 --- a/themesmanager.py +++ b/themesmanager.py @@ -11,25 +11,36 @@ from utilities import pop_up class ThemeConstants: - FOLDER = "themes" - EXTENSION = ".qss" - ICONS_FOLDER = "icons" - DEFAULT = "dark" - CURRENT = ".current_theme" - COLORS = "colors.txt" - COLOR_SEPARATOR = "=" - DEFAULT_ACTIVE_COLOR = "#000000" - DEFAULT_INACTIVE_COLOR = "#9f9f9f" - DEFAULT_OFF_COLORS = "#000000", "#434343" - DEFAULT_ON_COLORS = "#4b79a1", "#283e51" - DEFAULT_TEXT_COLOR = "#ffffff" - THEME_NOT_FOUND = "Theme not found" - MISSING_THEME = "Missing theme in '" + FOLDER + "' folder." - MISSING_THEME_FOLDER = "'" + FOLDER + "'" + " folder not found.\nOnly the basic theme is available." - THEME_FOLDER_NOT_FOUND = "'" + FOLDER + "'" + " folder not found" + """Container class for all the relevant theme-related constants.""" + + FOLDER = "themes" + EXTENSION = ".qss" + ICONS_FOLDER = "icons" + DEFAULT = "dark" + CURRENT = ".current_theme" + COLORS = "colors.txt" + COLOR_SEPARATOR = "=" + DEFAULT_ACTIVE_COLOR = "#000000" + DEFAULT_INACTIVE_COLOR = "#9f9f9f" + DEFAULT_OFF_COLORS = "#000000", "#434343" + DEFAULT_ON_COLORS = "#4b79a1", "#283e51" + DEFAULT_TEXT_COLOR = "#ffffff" + THEME_NOT_FOUND = "Theme not found" + MISSING_THEME = "Missing theme in '" + FOLDER + "' folder." + MISSING_THEME_FOLDER = "'" + FOLDER + "'" + " folder not found.\nOnly the basic theme is available." + THEME_FOLDER_NOT_FOUND = "'" + FOLDER + "'" + " folder not found" + DEFAULT_ICONS_PATH = os.path.join(FOLDER, DEFAULT, ICONS_FOLDER) + DEFAULT_SEARCH_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.SEARCH_LABEL_IMG) + DEFAULT_VOLUME_LABEL_PATH = os.path.join(DEFAULT_ICONS_PATH, Constants.VOLUME_LABEL_IMG) + CURRENT_THEME_FILE = os.path.join(FOLDER, CURRENT) + DEFAULT_THEME_PATH = os.path.join(FOLDER, DEFAULT) + class ThemeManager: + """Manage all the operations releted the the themes.""" + def __init__(self, parent): + """Initialize the ThemeManager instance.""" self.__parent = parent self.__parent.active_color = ThemeConstants.DEFAULT_ACTIVE_COLOR self.__parent.inactive_color = ThemeConstants.DEFAULT_INACTIVE_COLOR @@ -37,12 +48,6 @@ class ThemeManager: self.__theme_path = "" self.__current_theme = "" - self.__parent.default_images_folder = os.path.join( - ThemeConstants.FOLDER, - ThemeConstants.DEFAULT, - ThemeConstants.ICONS_FOLDER - ) - self.__space_weather_labels = SwitchableLabelsIterable( *list( chain( @@ -66,9 +71,9 @@ class ThemeManager: ) self.__theme_names = {} - self.__detect_themes() def __refresh_range_labels(self): + """Refresh the range-labels.""" self.__parent.set_acf_interval_label() self.__parent.set_band_filter_label( self.__parent.activate_low_band_filter_btn, @@ -96,12 +101,16 @@ class ThemeManager: @pyqtSlot() def __apply(self, theme_path): + """Apply the selected theme. + + Refresh all relevant widgets. + Display a QMessageBox if the theme is not found.""" self.__theme_path = theme_path if os.path.exists(theme_path): if self.__theme_path != self.__current_theme: self.__change() self.__parent.display_specs( - item=self.__parent.result_list.currentItem(), + item=self.__parent.signals_list.currentItem(), previous_item=None ) self.__refresh_range_labels() @@ -115,6 +124,7 @@ class ThemeManager: text=ThemeConstants.MISSING_THEME).show() def __pretty_name(self, bad_name): + """Return a well-formatted theme name.""" return ' '.join( map(lambda s: s.capitalize(), bad_name.split('_') @@ -122,6 +132,10 @@ class ThemeManager: ) def __detect_themes(self): + """Detect all available themes. + + Connect all the actions to change the theme. + Display a QMessageBox if the theme folder is not found.""" themes = [] ag = QActionGroup(self.__parent, exclusive=True) if os.path.exists(ThemeConstants.FOLDER): @@ -146,6 +160,7 @@ class ThemeManager: text=ThemeConstants.MISSING_THEME_FOLDER).show() def __is_valid_html_color(self, colors): + """Return if a string or a list of strings has a valid html format.""" pattern = "#([a-zA-Z0-9]){6}" match_ok = lambda col: bool(re.match(pattern, col)) if isinstance(colors, list): @@ -157,6 +172,11 @@ class ThemeManager: return match_ok(colors) def __change(self): + """Change the current theme. + + Apply the stylesheet and set active and inactive colors. + Set all the new images needed. + Save the new current theme on file.""" theme_name = os.path.basename(self.__theme_path) + ThemeConstants.EXTENSION try: with open( @@ -170,47 +190,26 @@ class ThemeManager: text=ThemeConstants.MISSING_THEME).show() else: icons_path = os.path.join(self.__theme_path, ThemeConstants.ICONS_FOLDER) - default_icons_path = os.path.join( - ThemeConstants.FOLDER, - ThemeConstants.DEFAULT, - ThemeConstants.ICONS_FOLDER - ) - - if os.path.exists(os.path.join(icons_path, Constants.NOT_SELECTED)) and \ - os.path.exists(os.path.join(icons_path, Constants.NOT_AVAILABLE)): - self.__parent.default_images_folder = icons_path - else: - self.__parent.default_images_folder = default_icons_path path_to_search_label = os.path.join( icons_path, Constants.SEARCH_LABEL_IMG ) - default_search_label = os.path.join( - default_icons_path, - Constants.SEARCH_LABEL_IMG - ) if os.path.exists(path_to_search_label): - self.__parent.search_label.setPixmap( - QPixmap(path_to_search_label) - ) - self.__parent.modulation_search_label.setPixmap( - QPixmap(path_to_search_label) - ) - self.__parent.location_search_label.setPixmap( - QPixmap(path_to_search_label) - ) + path = path_to_search_label else: - self.__parent.search_label.setPixmap( - QPixmap(default_search_label) - ) - self.__parent.modulation_search_label.setPixmap( - QPixmap(default_search_label) - ) - self.__parent.location_search_label.setPixmap( - QPixmap(default_search_label) - ) + path = ThemeConstants.DEFAULT_SEARCH_LABEL_PATH + + self.__parent.search_label.setPixmap( + QPixmap(path) + ) + self.__parent.modulation_search_label.setPixmap( + QPixmap(path) + ) + self.__parent.location_search_label.setPixmap( + QPixmap(path) + ) self.__parent.search_label.setScaledContents(True) self.__parent.modulation_search_label.setScaledContents(True) @@ -220,19 +219,15 @@ class ThemeManager: icons_path, Constants.VOLUME_LABEL_IMG ) - default_volume_label = os.path.join( - default_icons_path, - Constants.VOLUME_LABEL_IMG - ) if os.path.exists(path_to_volume_label): - self.__parent.volume_label.setPixmap( - QPixmap(path_to_volume_label) - ) + path = path_to_volume_label else: - self.__parent.volume_label.setPixmap( - QPixmap(default_volume_label) - ) + path = ThemeConstants.DEFAULT_VOLUME_LABEL_PATH + + self.__parent.volume_label.setPixmap( + QPixmap(path) + ) self.__parent.volume_label.setScaledContents(True) @@ -307,21 +302,16 @@ class ThemeManager: self.__current_theme = self.__theme_path try: - with open(os.path.join( - ThemeConstants.FOLDER, - ThemeConstants.CURRENT - ), "w") as current_theme: + with open(ThemeConstants.CURRENT_THEME_FILE, "w") as current_theme: current_theme.write(self.__theme_path) except Exception: pass def start(self): - current_theme_file = os.path.join( - ThemeConstants.FOLDER, - ThemeConstants.CURRENT - ) - if os.path.exists(current_theme_file): - with open(current_theme_file, "r") as current_theme_path: + """Start the theme manager.""" + self.__detect_themes() + if os.path.exists(ThemeConstants.CURRENT_THEME_FILE): + with open(ThemeConstants.CURRENT_THEME_FILE, "r") as current_theme_path: theme_path = current_theme_path.read() theme_name = self.__pretty_name(os.path.basename(theme_path)) try: @@ -340,9 +330,4 @@ class ThemeManager: pop_up(self.__parent, title=ThemeConstants.THEME_NOT_FOUND, text=ThemeConstants.MISSING_THEME).show() else: - self.__apply( - os.path.join( - ThemeConstants.FOLDER, - ThemeConstants.DEFAULT - ) - ) + self.__apply(ThemeConstants.DEFAULT_THEME_PATH) diff --git a/threads.py b/threads.py index 93a56b5..47a5f86 100644 --- a/threads.py +++ b/threads.py @@ -14,6 +14,8 @@ from utilities import checksum_ok class ThreadStatus(Enum): + """Possible thread status.""" + OK = auto() NO_CONNECTION_ERR = auto() UNKNOWN_ERR = auto() @@ -22,24 +24,31 @@ class ThreadStatus(Enum): class BaseDownloadThread(QThread): + """Subclass QThread. Base class for the download threads.""" + def __init__(self, parent=None): + """Set the status as 'UNDEFINED'.""" super().__init__(parent) self.status = ThreadStatus.UNDEFINED def __del__(self): + """Force the termination of the thread.""" self.terminate() self.wait() class DownloadThread(BaseDownloadThread): + """Subclass BaseDownloadThread. Download the database, images and audio samples.""" progress = pyqtSignal(int, float) CHUNK = 1024**2 def __init__(self): + """Just call super().__init__.""" super().__init__() def __pretty_len(self, byte_obj): + """Return a well-formatted number of downloaded MB.""" mega = len(byte_obj) / self.CHUNK if mega.is_integer(): return int(mega) @@ -47,12 +56,17 @@ class DownloadThread(BaseDownloadThread): return ceil(mega) def __get_download_speed(self, data, delta): + """Return the download speed in MB/s.""" return round( (len(data) / self.CHUNK) / delta, 2 ) def run(self): + """QThread.run. Download the database, images and audio samples. + + Handle all possible exceptions. Also extract the files + in the local folder.""" self.status = ThreadStatus.UNDEFINED raw_data = bytes(0) try: @@ -102,29 +116,39 @@ class DownloadThread(BaseDownloadThread): class _AsyncDownloader: + """Mixin class for asynchronous threads.""" + async def _download_resource(self, session, link): + """Return the content of 'link' as bytes.""" resp = await session.get(link) return await resp.read() class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader): + """Subclass BaseDownloadThread. Downlaod the space weather data.""" __properties = ("xray", "prot_el", "ak_index", "sgas", "geo_storm") def __init__(self, space_weather_data): + """Initialize the a local space_weather_data.""" super().__init__() self.__space_weather_data = space_weather_data async def __download_property(self, session, property_name): + """Download the data conteining the information of a specific property.""" link = getattr(Constants, "SPACE_WEATHER_" + property_name.upper()) data = await self._download_resource(session, link) setattr(self.__space_weather_data, property_name, str(data, 'utf-8')) async def __download_image(self, session, n): - im = await self._download_resource(session, Constants.SPACE_WEATHER_IMGS[n]) + """Download the data corresponding the n-th image displayed in the screen.""" + im = await self._download_resource( + session, Constants.SPACE_WEATHER_IMGS[n] + ) self.__space_weather_data.images[n].loadFromData(im) async def _download_resources(self): + """Download all the data.""" session = aiohttp.ClientSession() try: t = [] @@ -137,7 +161,9 @@ class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader): t1 = [] for im_number in tot_images: t1.append( - asyncio.create_task(self.__download_image(session, im_number)) + asyncio.create_task( + self.__download_image(session, im_number) + ) ) await asyncio.gather(*t, *t1) except Exception: @@ -148,29 +174,35 @@ class UpdateSpaceWeatherThread(BaseDownloadThread, _AsyncDownloader): await session.close() def run(self): + """Override QThread.run. Start the download of the data.""" self.status = ThreadStatus.UNDEFINED asyncio.run(self._download_resources()) class UpdateForecastThread(BaseDownloadThread, _AsyncDownloader): + """Subclass BaseDownloadThread. Download the forecast data.""" class _PropertyName(Enum): + """Enum used to differentiate between the two data needed.""" FORECAST = auto() PROBABILITIES = auto() - def __init__(self, parent): + def __init__(self, owner): + """Set the owner object (a ForecastData instance).""" super().__init__() - self.parent = parent + self.owner = owner async def __download_property(self, session, link, prop_name): + """Download the data from 'link' and set the corresponding property of the owner.""" resp = await self._download_resource(session, link) resp = str(resp, 'utf-8') if prop_name is self._PropertyName.FORECAST: - self.parent.forecast = resp + self.owner.forecast = resp if prop_name is self._PropertyName.PROBABILITIES: - self.parent.probabilities = resp + self.owner.probabilities = resp async def _download_resources(self): + """Download all the data needed.""" session = aiohttp.ClientSession() try: await asyncio.gather( @@ -197,5 +229,6 @@ class UpdateForecastThread(BaseDownloadThread, _AsyncDownloader): await session.close() def run(self): + """Override QThread.run. Start the data download.""" self.status = ThreadStatus.UNDEFINED asyncio.run(self._download_resources()) diff --git a/utilities.py b/utilities.py index 3786362..a6dcec6 100644 --- a/utilities.py +++ b/utilities.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import QMessageBox from constants import Constants, Signal, Database, ChecksumWhat def resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller.""" try: base_path = sys._MEIPASS except Exception: @@ -16,6 +17,7 @@ def resource_path(relative_path): return os.path.join(base_path, relative_path) def uncheck_and_emit(button): + """Set the button to the unchecked state and emit the clicked signal.""" if button.isChecked(): button.setChecked(False) button.clicked.emit() @@ -25,6 +27,13 @@ def pop_up(cls, title, text, connection=None, is_question=False, default_btn=QMessageBox.Yes): + """Return a QMessageBox object. + + Keyword arguments: + informative_text -- possible informative text to be displayed. + connection -- a callable to connect the message when emitting the finished signal. + is_question -- whether the message contains a question. + default_btn -- the default button for the possible answer to the question.""" msg = QMessageBox(cls) msg.setWindowTitle(title) msg.setText(text) @@ -39,6 +48,7 @@ def pop_up(cls, title, text, return msg def checksum_ok(data, what): + """Check whether the checksum of the 'data' argument is correct.""" code = hashlib.sha256() code.update(data) if what is ChecksumWhat.FOLDER: @@ -57,6 +67,9 @@ def checksum_ok(data, what): return code.hexdigest() == reference def connect_events_to_func(events_to_connect, fun_to_connect, fun_args): + """Connect all elements of events_to_connect to the callable fun_to_connect. + + fun_args is a list of fun_to_connect arguments.""" if fun_args is not None: for event in events_to_connect: event.connect(partial(fun_to_connect, *fun_args)) @@ -65,21 +78,25 @@ def connect_events_to_func(events_to_connect, fun_to_connect, fun_args): event.connect(fun_to_connect) def filters_limit(spinbox, filter_unit, confidence, sign=1): - band_filter = spinbox.value() * Constants.CONVERSION_FACTORS[filter_unit.currentText()] - return band_filter + sign * (confidence.value() * band_filter) // 100 + """Return the actual limit of a numerical filter.""" + band_filter = spinbox.value() * Constants.CONVERSION_FACTORS[filter_unit.currentText()] + return band_filter + sign * (confidence.value() * band_filter) // 100 def is_undef_freq(current_signal): + """Return whether the lower or upper frequency of a signal is undefined.""" lower_freq = current_signal.at[Signal.INF_FREQ] upper_freq = current_signal.at[Signal.SUP_FREQ] return lower_freq == Constants.UNKNOWN or upper_freq == Constants.UNKNOWN def is_undef_band(current_signal): + """Return whether the lower or upper band of a signal is undefined.""" lower_band = current_signal.at[Signal.INF_BAND] upper_band = current_signal.at[Signal.SUP_BAND] return lower_band == Constants.UNKNOWN or upper_band == Constants.UNKNOWN -def _change_unit(num): - digits = len(num) +def _change_unit(str_num): + """Return a scale factor givent the number of digits of a numeric string.""" + digits = len(str_num) if digits < 4: return 1 elif digits < 7: @@ -90,13 +107,14 @@ def _change_unit(num): return 10**9 def format_numbers(lower, upper): + """Return the string which displays the numeric limits of a filter.""" units = {1: 'Hz', 1000: 'kHz', 10**6: 'MHz', 10**9: 'GHz'} lower_factor = _change_unit(lower) upper_factor = _change_unit(upper) pre_lower = lower pre_upper = upper - lower = int(lower) / lower_factor - upper = int(upper) / upper_factor + lower = safe_cast(lower, int) / lower_factor + upper = safe_cast(upper, int) / upper_factor if lower.is_integer(): lower = int(lower) else: @@ -109,3 +127,15 @@ def format_numbers(lower, upper): return f"{lower:,} {units[lower_factor]} - {upper:,} {units[upper_factor]}" else: return f"{lower:,} {units[lower_factor]}" + +def safe_cast(value, cast_type): + """Calls 'cast_type(value)' and returns the result. + + If the operation fails returns -1. Should be used to perform 'safe casts'. + """ + try: + r = cast_type(value) + except Exception: + r = -1 + finally: + return r diff --git a/weatherdata.py b/weatherdata.py index 525f636..f4162ca 100644 --- a/weatherdata.py +++ b/weatherdata.py @@ -7,27 +7,39 @@ from threads import (BaseDownloadThread, UpdateForecastThread) from constants import Constants from switchable_label import MultiColorSwitchableLabel +from utilities import safe_cast class _BaseWeatherData(QObject): + """Base class for the weather data. Extends QObject.""" + update_complete = pyqtSignal(bool) def __init__(self): + """Create a BaseDownloadThread object.""" super().__init__() self._update_thread = BaseDownloadThread() @property def is_updating(self): + """Return whether the thread is running.""" return self._update_thread.isRunning() def update(self): + """Start the thread.""" self._update_thread.start() def _parse_data(self): + """Dummy function. Must be overrided by subclasses.""" pass @pyqtSlot() def _parse_and_emit_signal(self): + """Parse the data and emit an 'update_complete' signal. + + If the download was not successful, do not parse the data. + The 'update_complete' signal propagates the thread status up to the + calling slot.""" status_ok = False if self._update_thread.status is ThreadStatus.OK: status_ok = True @@ -35,15 +47,22 @@ class _BaseWeatherData(QObject): self.update_complete.emit(status_ok) def _double_split(self, string): + """Given a string, return a list of lists. + + First split on each line. Then split each line on whitespaces.""" return [i.split() for i in string.splitlines()] def shutdown_thread(self): + """Terminate the download thread.""" self._update_thread.terminate() self._update_thread.wait() class SpaceWeatherData(_BaseWeatherData): + """Space weather class. Extends _BaseWeatherData.""" + def __init__(self): + """Set all attributes and connect the thread to _parse_and_emit_signal.""" super().__init__() self.xray = '' self.prot_el = '' @@ -65,6 +84,9 @@ class SpaceWeatherData(_BaseWeatherData): self._update_thread.finished.connect(self._parse_and_emit_signal) def _parse_data(self): + """Override _BaseWeatherData._parse_data. + + Set all the data.""" self.xray = self._double_split(self.xray) self.prot_el = self._double_split(self.prot_el) self.ak_index = self._double_split(self.ak_index) @@ -72,6 +94,7 @@ class SpaceWeatherData(_BaseWeatherData): self.geo_storm = self._double_split(self.geo_storm) def remove_data(self): + """Remove the reference to all the data.""" self.xray = '' self.prot_el = '' self.ak_index = '' @@ -91,6 +114,7 @@ class SpaceWeatherData(_BaseWeatherData): class ForecastData(_BaseWeatherData): + """3-day forecast class. Extends _BaseWeatherData.""" ROW_KEYWORDS = { "solar_row": "S1 or greater", @@ -101,6 +125,7 @@ class ForecastData(_BaseWeatherData): } def __init__(self, parent): + """Initialize all attributes and connect the thread to _parse_and_emit_signal.""" super().__init__() self.forecast = '' self.probabilities = '' @@ -112,16 +137,17 @@ class ForecastData(_BaseWeatherData): self.__kp_index_row = None self._update_thread = UpdateForecastThread(self) self._update_thread.finished.connect(self._parse_and_emit_signal) - self.today_lbl = parent.today_lbl - self.today_p1_lbl = parent.today_p1_lbl - self.today_p2_lbl = parent.today_p2_lbl + # Cannot use '__' here because of the for loop below. + self._today_lbl = parent.today_lbl + self._today_p1_lbl = parent.today_p1_lbl + self._today_p2_lbl = parent.today_p2_lbl self.__today_lbls = [] self.__today_p1_lbls = [] self.__today_p2_lbls = [] self.__all_lbls = [] flags = ['', 'p1_', 'p2_'] for flag in flags: - title_lbl = getattr(self, "today_" + flag + "lbl") + title_lbl = getattr(self, "_today_" + flag + "lbl") title_lbl.setText("-") for index in range(20): label = getattr( @@ -143,6 +169,9 @@ class ForecastData(_BaseWeatherData): ] def _parse_data(self): + """Override _BaseWeatherData._parse_data. + + Set all the relevant data.""" # Remove possible '(G\d)' from the kp_index table self.forecast = re.sub( '\(G\d\)', lambda obj: '', self.forecast @@ -154,16 +183,21 @@ class ForecastData(_BaseWeatherData): self.probabilities = self.probabilities.splitlines() def __split_lists(self): + """Split the elements of forecast and probabilities.""" self.forecast = [i.split() for i in self.forecast] self.probabilities = [i.split() for i in self.probabilities] def __find_row_with(self, data, text): + """Given a list of strings, return the index of the first string containing the target text.""" for i, row in enumerate(data): if text in row: return i return None def __get_rows(self): + """Set all the rows needed for updating the screen. + + Raise an exception if something goes wrong.""" self.__solar_row = self.__find_row_with( self.forecast, self.ROW_KEYWORDS["solar_row"] @@ -185,26 +219,27 @@ class ForecastData(_BaseWeatherData): self.ROW_KEYWORDS["kp_index_row"] ) - is_none = lambda x: x is None if any([ - is_none(self.__solar_row), - is_none(self.__event_row), - is_none(self.__rb_now_row), - is_none(self.__ga_now_row), - is_none(self.__kp_index_row) + self.__solar_row is None, + self.__event_row is None, + self.__rb_now_row is None, + self.__ga_now_row is None, + self.__kp_index_row is None ]): raise Exception('Missing Rows') def __set_dates(self): + """Set the date labels.""" month = self.forecast[self.__solar_row - 1][0] today = self.forecast[self.__solar_row - 1][1] today_p1 = self.forecast[self.__solar_row - 1][3] today_p2 = self.forecast[self.__solar_row - 1][5] - self.today_lbl.setText(month + ' ' + today) - self.today_p1_lbl.setText(month + ' ' + today_p1) - self.today_p2_lbl.setText(month + ' ' + today_p2) + self._today_lbl.setText(month + ' ' + today) + self._today_p1_lbl.setText(month + ' ' + today_p1) + self._today_p2_lbl.setText(month + ' ' + today_p2) def __make_labels_table(self): + """Organize all the arguments to feed __get_lbl_value.""" get_first_split = lambda x: x.split("/")[0] get_second_split = lambda x: x.split("/")[1] get_third_split = lambda x: x.split("/")[2] @@ -277,34 +312,32 @@ class ForecastData(_BaseWeatherData): ] ] - def __get_lbl_value(self, data, row, col, f = None): + def __get_lbl_value(self, data, row, col, f=None): + """Return the well-formatted string-value of the label.""" val = data[row][col] if f is not None: val = f(val) - val = val.lstrip('0').rstrip('%') + val = val.rstrip('%') + if len(val) > 1: + val = val.lstrip('0') return val - def __is_integer(self, s): - try: - int(s) - except Exception: - return False - else: - return True - def __set_labels_values(self): + """Set all the labels values.""" for lbl_list, table in zip(self.__all_lbls, self.__labels_table): for lbl, row in zip(lbl_list, table): lbl.switch_off() value = self.__get_lbl_value(*row) - if self.__is_integer(value): - lbl.level = int(value) + lbl.level = safe_cast(value, int) if not isinstance(lbl, MultiColorSwitchableLabel): value += '%' lbl.setText(value) lbl.switch_on() def update_all_labels(self): + """Update all the labels values. + + If an exception is raised in the process, do nothing.""" try: self.__get_rows() self.__split_lists() @@ -315,5 +348,6 @@ class ForecastData(_BaseWeatherData): pass def remove_data(self): + """Remove the reference to the downloaded data.""" self.forecast = '' self.probabilities = ''