From 651ed290312ed56b8c092360df942d1f4a10260f Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Wed, 30 Oct 2024 16:20:20 +0200 Subject: [PATCH 1/4] Drop DB codenames, crystallize DB editor naming responsibilities Drop database codenames in favor of specific database display name registry that centralizes all database name handling. This makes it clearer who is responsible of giving names to DB editor tabs. Also, moved DB editor window/tab management out of DB manager where it arguable doesn't belong. --- spinetoolbox/database_display_names.py | 120 ++++++++++++++++++ spinetoolbox/multi_tab_windows.py | 71 +++++++++++ spinetoolbox/plotting.py | 7 +- spinetoolbox/spine_db_commands.py | 8 +- spinetoolbox/spine_db_editor/editors.py | 16 +++ .../spine_db_editor/graphics_items.py | 12 +- spinetoolbox/spine_db_editor/main.py | 4 +- .../mvcmodels/alternative_item.py | 13 +- .../mvcmodels/alternative_model.py | 2 +- .../spine_db_editor/mvcmodels/empty_models.py | 42 +++--- .../mvcmodels/entity_tree_item.py | 10 +- .../mvcmodels/frozen_table_model.py | 4 +- .../mvcmodels/metadata_table_model_base.py | 28 ++-- .../mvcmodels/multi_db_tree_item.py | 4 +- .../mvcmodels/parameter_value_list_item.py | 12 +- .../mvcmodels/parameter_value_list_model.py | 2 +- .../mvcmodels/pivot_table_models.py | 42 +++--- .../mvcmodels/scenario_item.py | 17 +-- .../mvcmodels/scenario_model.py | 2 +- .../mvcmodels/single_models.py | 11 +- .../mvcmodels/tree_item_utility.py | 31 ++--- .../widgets/add_items_dialogs.py | 41 +++--- .../spine_db_editor/widgets/commit_viewer.py | 2 +- .../widgets/custom_delegates.py | 16 ++- .../spine_db_editor/widgets/custom_menus.py | 16 ++- .../widgets/custom_qwidgets.py | 11 +- .../widgets/edit_or_remove_items_dialogs.py | 49 ++++--- .../widgets/graph_view_mixin.py | 2 +- .../widgets/manage_items_dialogs.py | 6 +- .../widgets/mass_select_items_dialogs.py | 11 +- .../widgets/multi_spine_db_editor.py | 77 ++++++++--- .../widgets/spine_db_editor.py | 108 ++++++++-------- .../widgets/stacked_view_mixin.py | 6 +- .../widgets/tabular_view_mixin.py | 6 +- .../spine_db_editor/widgets/toolbar.py | 15 ++- spinetoolbox/spine_db_manager.py | 81 ++---------- spinetoolbox/widgets/multi_tab_window.py | 2 +- spinetoolbox/widgets/settings_widget.py | 5 +- tests/project_item/test_logging_connection.py | 4 +- tests/spine_db_editor/helpers.py | 5 +- .../mvcmodels/test_alternative_model.py | 13 +- .../mvcmodels/test_emptyParameterModels.py | 3 +- .../mvcmodels/test_frozen_table_model.py | 3 +- .../test_item_metadata_table_model.py | 3 +- .../mvcmodels/test_metadata_table_model.py | 6 +- .../mvcmodels/test_scenario_model.py | 9 +- .../mvcmodels/test_single_parameter_models.py | 3 +- tests/spine_db_editor/test_graphics_items.py | 3 +- .../widgets/spine_db_editor_test_base.py | 3 +- .../widgets/test_SpineDBEditor.py | 2 +- .../widgets/test_SpineDBEditorBase.py | 10 +- .../test_SpineDBEditorWithDBMapping.py | 5 +- .../widgets/test_add_items_dialog.py | 3 +- .../widgets/test_commit_viewer.py | 2 +- .../widgets/test_custom_menus.py | 21 ++- .../widgets/test_mass_select_items_dialogs.py | 2 +- .../widgets/test_multi_spine_db_editor.py | 48 ++++++- tests/test_SpineDBManager.py | 58 ++------- tests/test_database_display_names.py | 102 +++++++++++++++ tests/test_multi_tab_windows.py | 56 ++++++++ tests/test_parameter_type_validation.py | 3 +- tests/test_spine_db_fetcher.py | 3 +- 62 files changed, 824 insertions(+), 458 deletions(-) create mode 100644 spinetoolbox/database_display_names.py create mode 100644 spinetoolbox/multi_tab_windows.py create mode 100644 spinetoolbox/spine_db_editor/editors.py create mode 100644 tests/test_database_display_names.py create mode 100644 tests/test_multi_tab_windows.py diff --git a/spinetoolbox/database_display_names.py b/spinetoolbox/database_display_names.py new file mode 100644 index 000000000..4b6ed677d --- /dev/null +++ b/spinetoolbox/database_display_names.py @@ -0,0 +1,120 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### + +"""This module contains functionality to manage database display names.""" +import hashlib +import pathlib +from PySide6.QtCore import QObject, Signal, Slot +from sqlalchemy.engine.url import URL, make_url + + +class NameRegistry(QObject): + display_name_changed = Signal(str, str) + """Emitted when the display name of a database changes.""" + + def __init__(self, parent=None): + """ + Args: + parent (QObject, optional): parent object + """ + super().__init__(parent) + self._names_by_url: dict[str, set[str]] = {} + + @Slot(str, str) + def register(self, db_url, name): + """Registers a new name for given database URL. + + Args: + db_url (URL or str): database URL + name (str): name to register + """ + url = str(db_url) + if url in self._names_by_url and name in self._names_by_url[url]: + return + self._names_by_url.setdefault(url, set()).add(name) + self.display_name_changed.emit(url, self.display_name(db_url)) + + @Slot(str, str) + def unregister(self, db_url, name): + """Removes a name from the registry. + + Args: + db_url (URL or str): database URL + name (str): name to remove + """ + url = str(db_url) + names = self._names_by_url[url] + old_name = self.display_name(url) if len(names) in (1, 2) else None + names.remove(name) + if old_name is not None: + new_name = self.display_name(url) + self.display_name_changed.emit(url, new_name) + + def display_name(self, db_url): + """Makes display name for a database. + + Args: + db_url (URL or str): database URL + + Returns: + str: display name + """ + try: + registered_names = self._names_by_url[str(db_url)] + except KeyError: + return suggest_display_name(db_url) + else: + if len(registered_names) == 1: + return next(iter(registered_names)) + return suggest_display_name(db_url) + + def display_name_iter(self, db_maps): + """Yields database mapping display names. + + Args: + db_maps (Iterable of DatabaseMapping): database mappings + + Yields: + str: display name + """ + yield from (self.display_name(db_map.sa_url) for db_map in db_maps) + + def map_display_names_to_db_maps(self, db_maps): + """Returns a dictionary that maps display names to database mappings. + + Args: + db_maps (Iterable of DatabaseMapping): database mappings + + Returns: + dict: database mappings keyed by display names + """ + return {self.display_name(db_map.sa_url): db_map for db_map in db_maps} + + +def suggest_display_name(db_url): + """Returns a short name for the database mapping. + + Args: + db_url (URL or str): database URL + + Returns: + str: suggested name for the database for display purposes. + """ + if not isinstance(db_url, URL): + db_url = make_url(db_url) + if not db_url.drivername.startswith("sqlite"): + return db_url.database + if db_url.database is not None: + return pathlib.Path(db_url.database).stem + hashing = hashlib.sha1() + hashing.update(bytes(str(id(db_url)), "utf-8")) + return hashing.hexdigest() diff --git a/spinetoolbox/multi_tab_windows.py b/spinetoolbox/multi_tab_windows.py new file mode 100644 index 000000000..cd5fc6716 --- /dev/null +++ b/spinetoolbox/multi_tab_windows.py @@ -0,0 +1,71 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### + +"""Contains functionality to keep track on open MultiTabWindow instances.""" +from spinetoolbox.widgets.multi_tab_window import MultiTabWindow + + +class MultiTabWindowRegistry: + """Registry that holds multi tab windows.""" + + def __init__(self): + self._multi_tab_windows: list[MultiTabWindow] = [] + + def has_windows(self): + """Tests if there are any windows registered. + + Returns: + bool: True if editor windows exist, False otherwise + """ + return bool(self._multi_tab_windows) + + def windows(self): + """Returns a list of multi tab windows. + + Returns: + list of MultiTabWindow: windows + """ + return list(self._multi_tab_windows) + + def tabs(self): + """Returns a list of tabs across all windows. + + Returns: + list of QWidget: tab widgets + """ + return [ + window.tab_widget.widget(k) for window in self._multi_tab_windows for k in range(window.tab_widget.count()) + ] + + def register_window(self, window): + """Registers a new multi tab window. + + Args: + window (MultiTabWindow): window to register + """ + self._multi_tab_windows.append(window) + + def unregister_window(self, window): + """Removes multi tab window from the registry. + + Args: + window (MultiTabWindow): window to unregister + """ + self._multi_tab_windows.remove(window) + + def get_some_window(self): + """Returns a random multi tab window or None if none is available. + + Returns: + MultiTabWindow: editor window + """ + return self._multi_tab_windows[0] if self._multi_tab_windows else None diff --git a/spinetoolbox/plotting.py b/spinetoolbox/plotting.py index 1c5f3cd90..26af616ef 100644 --- a/spinetoolbox/plotting.py +++ b/spinetoolbox/plotting.py @@ -692,12 +692,13 @@ def plot_pivot_table_selection(model, model_indexes, plot_widget=None): return plot_data(data_list, plot_widget) -def plot_db_mngr_items(items, db_maps, plot_widget=None): +def plot_db_mngr_items(items, db_maps, db_name_registry, plot_widget=None): """Returns a plot widget with plots of database manager parameter value items. Args: items (list of dict): parameter value items db_maps (list of DatabaseMapping): database mappings corresponding to items + db_name_registry (NameRegistry): database display name registry plot_widget (PlotWidget, optional): widget to add plots to """ if not items: @@ -707,13 +708,13 @@ def plot_db_mngr_items(items, db_maps, plot_widget=None): root_node = TreeNode("database") for item, db_map in zip(items, db_maps): value = from_database(item["value"], item["type"]) + db_name = db_name_registry.display_name(db_map.sa_url) if value is None: continue try: leaf_content = _convert_to_leaf(value) except PlottingError as error: - raise PlottingError(f"Failed to plot value in {db_map.codename}: {error}") from error - db_name = db_map.codename + raise PlottingError(f"Failed to plot value in {db_name}: {error}") from error parameter_name = item["parameter_definition_name"] entity_byname = item["entity_byname"] if not isinstance(entity_byname, tuple): diff --git a/spinetoolbox/spine_db_commands.py b/spinetoolbox/spine_db_commands.py index a7467f851..2495e1444 100644 --- a/spinetoolbox/spine_db_commands.py +++ b/spinetoolbox/spine_db_commands.py @@ -107,7 +107,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): self.redo_data = data self.undo_ids = None self._check = check - self.setText(f"add {item_type} items to {db_map.codename}") + self.setText(f"add {item_type} items to {db_mngr.name_registry.display_name(db_map.sa_url)}") def redo(self): super().redo() @@ -143,7 +143,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): if self.redo_data == self.undo_data: self.setObsolete(True) self._check = check - self.setText(f"update {item_type} items in {db_map.codename}") + self.setText(f"update {item_type} items in {self.db_mngr.name_registry.display_name(db_map.sa_url)}") def redo(self): super().redo() @@ -183,7 +183,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): self.redo_update_data = None self.undo_remove_ids = None self.undo_update_data = None - self.setText(f"update {item_type} items in {db_map.codename}") + self.setText(f"update {item_type} items in {self.db_mngr.name_registry.display_name(db_map.sa_url)}") def redo(self): super().redo() @@ -225,7 +225,7 @@ def __init__(self, db_mngr, db_map, item_type, ids, check=True, **kwargs): self.item_type = item_type self.ids = ids self._check = check - self.setText(f"remove {item_type} items from {db_map.codename}") + self.setText(f"remove {item_type} items from {self.db_mngr.name_registry.display_name(db_map.sa_url)}") def redo(self): super().redo() diff --git a/spinetoolbox/spine_db_editor/editors.py b/spinetoolbox/spine_db_editor/editors.py new file mode 100644 index 000000000..d3b2f7d09 --- /dev/null +++ b/spinetoolbox/spine_db_editor/editors.py @@ -0,0 +1,16 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### + +"""Contains Spine Database editor's window registry.""" +from spinetoolbox.multi_tab_windows import MultiTabWindowRegistry + +db_editor_registry = MultiTabWindowRegistry() diff --git a/spinetoolbox/spine_db_editor/graphics_items.py b/spinetoolbox/spine_db_editor/graphics_items.py index a37da029f..ffc036858 100644 --- a/spinetoolbox/spine_db_editor/graphics_items.py +++ b/spinetoolbox/spine_db_editor/graphics_items.py @@ -157,7 +157,7 @@ def display_data(self): @property def display_database(self): - return ",".join([db_map.codename for db_map in self.db_maps]) + return ", ".join(self.db_mngr.name_registry.display_name_iter(self.db_maps)) @property def db_maps(self): @@ -370,7 +370,7 @@ def default_parameter_data(self): return { "entity_class_name": self.entity_class_name, "entity_byname": DB_ITEM_SEPARATOR.join(self.byname), - "database": self.first_db_map.codename, + "database": self.db_mngr.name_registry.display_name(self.first_db_map.sa_url), } def shape(self): @@ -667,7 +667,7 @@ def _populate_connect_entities_menu(self, menu): for name, db_map_ent_clss in self._db_map_entity_class_lists.items(): for db_map, ent_cls in db_map_ent_clss: icon = self.db_mngr.entity_class_icon(db_map, ent_cls["id"]) - action_name = name + "@" + db_map.codename + action_name = name + "@" + self.db_mngr.name_registry.display_name(db_map.sa_url) enabled = set(ent_cls["dimension_id_list"]) <= entity_class_ids_in_graph.get(db_map, set()) action_name_icon_enabled.append((action_name, icon, enabled)) for action_name, icon, enabled in sorted(action_name_icon_enabled): @@ -702,7 +702,11 @@ def _start_connecting_entities(self, action): class_name, db_name = action.text().split("@") db_map_ent_cls_lst = self._db_map_entity_class_lists[class_name] db_map, ent_cls = next( - iter((db_map, ent_cls) for db_map, ent_cls in db_map_ent_cls_lst if db_map.codename == db_name) + iter( + (db_map, ent_cls) + for db_map, ent_cls in db_map_ent_cls_lst + if self.db_mngr.name_registry.display_name(db_map.sa_url) == db_name + ) ) self._spine_db_editor.start_connecting_entities(db_map, ent_cls, self) diff --git a/spinetoolbox/spine_db_editor/main.py b/spinetoolbox/spine_db_editor/main.py index 11e8819d7..7fc105f1f 100644 --- a/spinetoolbox/spine_db_editor/main.py +++ b/spinetoolbox/spine_db_editor/main.py @@ -29,9 +29,9 @@ def main(): editor = MultiSpineDBEditor(db_mngr) if args.separate_tabs: for url in args.url: - editor.add_new_tab({url: None}) + editor.add_new_tab([url]) else: - editor.add_new_tab({url: None for url in args.url}) + editor.add_new_tab(args.url) editor.show() return_code = app.exec() return return_code diff --git a/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py b/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py index c1f11c2df..c4c7535be 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py @@ -20,10 +20,6 @@ class DBItem(EmptyChildMixin, FetchMoreMixin, StandardDBItem): """A root item representing a db.""" - @property - def item_type(self): - return "db" - @property def fetch_item_type(self): return "alternative" @@ -38,13 +34,8 @@ def _make_child(self, id_): class AlternativeItem(GrayIfLastMixin, EditableMixin, LeafItem): """An alternative leaf item.""" - @property - def item_type(self): - return "alternative" - - @property - def icon_code(self): - return _ALTERNATIVE_ICON + item_type = "alternative" + icon_code = _ALTERNATIVE_ICON def tool_tip(self, column): if column == 0 and self.id: diff --git a/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py b/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py index 845be17d5..e797bcc84 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py @@ -24,7 +24,7 @@ class AlternativeModel(TreeModelBase): """A model to display alternatives in a tree view.""" def _make_db_item(self, db_map): - return DBItem(self, db_map) + return DBItem(self, db_map, self.db_mngr.name_registry) def mimeData(self, indexes): """Stores selected indexes into MIME data. diff --git a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py index e4663f4fd..c3830d73f 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py @@ -11,6 +11,7 @@ ###################################################################################################################### """Empty models for dialogs as well as parameter definitions and values.""" +from typing import ClassVar from PySide6.QtCore import Qt from ...helpers import DB_ITEM_SEPARATOR, rows_to_row_count_tuples from ...mvcmodels.empty_row_model import EmptyRowModel @@ -22,6 +23,9 @@ class EmptyModelBase(EmptyRowModel): """Base class for all empty models that go in a CompoundModelBase subclass.""" + item_type: ClassVar[str] = None + can_be_filtered = False + def __init__(self, parent): """ Args: @@ -33,10 +37,6 @@ def __init__(self, parent): self.entity_class_id = None self._db_map_entities_to_add = {} - @property - def item_type(self): - raise NotImplementedError() - @property def field_map(self): return self._parent.field_map @@ -68,7 +68,7 @@ def add_items_to_db(self, db_map_data): def _notify_about_added_entities(self): editor = self.parent().parent() - popup = AddedEntitiesPopup(editor, self._db_map_entities_to_add) + popup = AddedEntitiesPopup(editor, self.db_mngr.name_registry, self._db_map_entities_to_add) popup.show() def _clean_to_be_added_entities(self, db_map_items): @@ -91,10 +91,6 @@ def _make_unique_id(self, item): which rows have been added and thus need to be removed.""" raise NotImplementedError() - @property - def can_be_filtered(self): - return False - def accepted_rows(self): return range(self.rowCount()) @@ -109,8 +105,8 @@ def handle_items_added(self, db_map_data): Finds and removes model items that were successfully added to the db.""" added_ids = set() for db_map, items in db_map_data.items(): + database = self.db_mngr.name_registry.display_name(db_map.sa_url) for item in items: - database = db_map.codename unique_id = (database, *self._make_unique_id(item)) added_ids.add(unique_id) removed_rows = [] @@ -167,8 +163,13 @@ def _make_db_map_data(self, rows): db_map_data = {} for item in items: database = item.pop("database") - db_map = next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) - if not db_map: + try: + db_map = next( + iter( + x for x in self.db_mngr.db_maps if self.db_mngr.name_registry.display_name(x.sa_url) == database + ) + ) + except StopIteration: continue item = {k: v for k, v in item.items() if v is not None} db_map_data.setdefault(db_map, []).append(item) @@ -177,7 +178,10 @@ def _make_db_map_data(self, rows): def data(self, index, role=Qt.ItemDataRole.DisplayRole): if role == DB_MAP_ROLE: database = self.data(index, Qt.ItemDataRole.DisplayRole) - return next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) + return next( + iter(x for x in self.db_mngr.db_maps if self.db_mngr.name_registry.display_name(x.sa_url) == database), + None, + ) return super().data(index, role) @@ -244,9 +248,7 @@ def _entity_class_name_candidates_by_entity(db_map, item): class EmptyParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, EmptyModelBase): """An empty parameter_definition model.""" - @property - def item_type(self): - return "parameter_definition" + item_type = "parameter_definition" def _make_unique_id(self, item): return tuple(item.get(x) for x in ("entity_class_name", "name")) @@ -268,9 +270,7 @@ class EmptyParameterValueModel( ): """An empty parameter_value model.""" - @property - def item_type(self): - return "parameter_value" + item_type = "parameter_value" @staticmethod def _check_item(item): @@ -309,9 +309,7 @@ def _entity_class_name_candidates(self, db_map, item): class EmptyEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, EmptyModelBase): - @property - def item_type(self): - return "entity_alternative" + item_type = "entity_alternative" @staticmethod def _check_item(item): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py index 9886a48cf..6abbd9310 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py @@ -116,7 +116,10 @@ def _children_sort_key(self): def default_parameter_data(self): """Return data to put as default in a parameter table when this item is selected.""" - return {"entity_class_name": self.name, "database": self.first_db_map.codename} + return { + "entity_class_name": self.name, + "database": self.db_mngr.name_registry.display_name(self.first_db_map.sa_url), + } @property def display_data(self): @@ -260,12 +263,13 @@ def set_data(self, column, value, role): def default_parameter_data(self): """Return data to put as default in a parameter table when this item is selected.""" item = self.db_map_data(self.first_db_map) + db_name = self.db_mngr.name_registry.display_name(self.first_db_map.sa_url) if not item: - return {"database": self.first_db_map.codename} + return {"database": db_name} return { "entity_class_name": item["entity_class_name"], "entity_byname": DB_ITEM_SEPARATOR.join(item["entity_byname"]), - "database": self.first_db_map.codename, + "database": db_name, } def is_valid(self): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py b/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py index f439ae0e5..1329e956c 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py @@ -336,7 +336,7 @@ def _tooltip_from_data(self, row, column): elif header == "index": tool_tip = str(value[1]) elif header == "database": - tool_tip = value.codename + tool_tip = self.db_mngr.name_registry.display_name(value.sa_url) elif header == "entity": db_map, id_ = value tool_tip = self.db_mngr.get_item(db_map, "entity", id_).get("description") @@ -365,7 +365,7 @@ def _name_from_data(self, value, header): if header == "index": return str(value[1]) if header == "database": - return value.codename + return self.db_mngr.name_registry.display_name(value.sa_url) db_map, id_ = value item = self.db_mngr.get_item(db_map, "entity", id_) return item.get("name") diff --git a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py index 241d3179c..c84e9d22f 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py @@ -127,7 +127,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DisplayRole: if column == Column.DB_MAP: db_map = self._data[row][column] if row < len(self._data) else self._adder_row[column] - return db_map.codename if db_map is not None else "" + return self._db_mngr.name_registry.display_name(db_map.sa_url) if db_map is not None else "" return self._data[row][column] if row < len(self._data) else self._adder_row[column] if ( role == Qt.ItemDataRole.BackgroundRole @@ -208,7 +208,7 @@ def batch_set_data(self, indexes, values): columns = [] previous_values = [] data_length = len(self._data) - available_codenames = {db_map.codename: db_map for db_map in self._db_maps} + available_codenames = self._db_mngr.name_registry.map_display_names_to_db_maps(self._db_maps) reserved = self._reserved_metadata() for index, value in zip(indexes, values): if not self.flags(index) & Qt.ItemIsEditable: @@ -440,35 +440,33 @@ def _remove_data(self, db_map_data, id_column): self._data = self._data[:row] + self._data[row + count :] self.endRemoveRows() - def sort(self, column, order=Qt.AscendingOrder): + def sort(self, column, order=Qt.SortOrder.AscendingOrder): if not self._data or column < 0: return def db_map_sort_key(row): db_map = row[Column.DB_MAP] - return db_map.codename if db_map is not None else "" + return self._db_mngr.name_registry.display_name(db_map.sa_url) if db_map is not None else "" sort_key = itemgetter(column) if column != Column.DB_MAP else db_map_sort_key - self._data.sort(key=sort_key, reverse=order == Qt.DescendingOrder) + self._data.sort(key=sort_key, reverse=order == Qt.SortOrder.DescendingOrder) top_left = self.index(0, 0) bottom_right = self.index(len(self._data) - 1, Column.DB_MAP) self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.BackgroundRole]) - def _find_db_map(self, codename): - """Finds database mapping with given codename. + def _find_db_map(self, name): + """Finds database mapping with given name. Args: - codename (str): database mapping's code name + name (str): database mapping's name Returns: - DiffDatabaseMapping: database mapping or None if not found + DatabaseMapping: database mapping or None if not found """ - match = None - for db_map in self._db_maps: - if codename == db_map.codename: - match = db_map - break - return match + return next( + iter(db_map for db_map in self._db_maps if name == self._db_mngr.name_registry.display_name(db_map.sa_url)), + None, + ) def _reserved_metadata(self): """Collects metadata names and values that are already in database. diff --git a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py index 5c8a329be..e265e7017 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py @@ -117,7 +117,7 @@ def display_data(self): @property def display_database(self): """Returns the database for display.""" - return ",".join([db_map.codename for db_map in self.db_maps]) + return ", ".join(self.model.db_mngr.name_registry.display_name_iter(self._db_map_ids)) @property def display_icon(self): @@ -489,7 +489,7 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole): def default_parameter_data(self): """Returns data to set as default in a parameter table when this item is selected.""" - return {"database": self.first_db_map.codename} + return {"database": self.db_mngr.name_registry.display_name(self.first_db_map.sa_url)} def tear_down(self): super().tear_down() diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py index 2e7af66b5..77bfa7f2b 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py @@ -30,10 +30,6 @@ class DBItem(EmptyChildMixin, FetchMoreMixin, StandardDBItem): """An item representing a db.""" - @property - def item_type(self): - return "db" - @property def fetch_item_type(self): return "parameter_value_list" @@ -50,9 +46,7 @@ class ListItem( ): """A list item.""" - @property - def item_type(self): - return "parameter_value_list" + item_type = "parameter_value_list" @property def fetch_item_type(self): @@ -95,9 +89,7 @@ def update_item_in_db(self, db_item): class ValueItem(GrayIfLastMixin, EditableMixin, LeafItem): - @property - def item_type(self): - return "list_value" + item_type = "list_value" def data(self, column, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DisplayRole and not self.id: diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py index b2d704dcf..f922fa353 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py @@ -20,7 +20,7 @@ class ParameterValueListModel(TreeModelBase): """A model to display parameter_value_list data in a tree view.""" def _make_db_item(self, db_map): - return DBItem(self, db_map) + return DBItem(self, db_map, self.db_mngr.name_registry) def columnCount(self, parent=QModelIndex()): """Returns the number of columns under the given parent. Always 1.""" diff --git a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py index 5802ceb67..554cd0c1d 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py @@ -278,7 +278,7 @@ class TopLeftDatabaseHeaderItem(TopLeftHeaderItem): def __init__(self, model): super().__init__(model) - self._suggested_codename = None + self._suggested_db_name = None @property def header_type(self): @@ -290,7 +290,7 @@ def name(self): def header_data(self, header_id, role=Qt.ItemDataRole.DisplayRole): """See base class.""" - return header_id.codename + return self._model.db_mngr.name_registry.display_name(header_id.sa_url) def update_data(self, db_map_data): """See base class.""" @@ -300,17 +300,17 @@ def add_data(self, names, db_map): """See base class.""" return False - def set_data(self, codename): - """Sets database mapping's codename. + def set_data(self, name): + """Sets database mapping's name. Args: - codename (str): database codename + name (str): database name Returns: - bool: True if codename was acceptable, False otherwise + bool: True if name was acceptable, False otherwise """ - if any(db_map.codename == codename for db_map in self.model.db_maps): - self._suggested_codename = codename + if any(self._model.db_mngr.name_registry.display_name(db_map.sa_url) == name for db_map in self._model.db_maps): + self._suggested_db_name = name return True return False @@ -320,23 +320,23 @@ def take_suggested_db_map(self): Returns: DatabaseMapping: database mapping """ - if self._suggested_codename is not None: + if self._suggested_db_name is not None: for db_map in self.model.db_maps: - if db_map.codename == self._suggested_codename: - self._suggested_codename = None + if self._model.db_mngr.name_registry.display_name(db_map.sa_url) == self._suggested_db_name: + self._suggested_db_name = None return db_map - raise RuntimeError(f"Logic error: no such database mapping `{self._suggested_codename}`") + raise RuntimeError(f"Logic error: no such database mapping `{self._suggested_db_name}`") return next(iter(self.model.db_maps)) - def suggest_db_map_codename(self): - """Suggests a database mapping codename. + def suggest_db_map_name(self): + """Suggests a database mapping name. Returns: - str: codename + str: database display name """ - if self._suggested_codename is not None: - return self._suggested_codename - return next(iter(self.model.db_maps)).codename + if self._suggested_db_name is not None: + return self._suggested_db_name + return self._model.db_mngr.name_registry.display_name(next(iter(self.model.db_maps)).sa_url) class PivotTableModelBase(QAbstractTableModel): @@ -837,14 +837,14 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): with suppress(ValueError): database_header_column = self.model.pivot_rows.index("database") if index.column() == database_header_column: - return self.top_left_headers["database"].suggest_db_map_codename() + return self.top_left_headers["database"].suggest_db_map_name() elif ( self.emptyColumnCount() > 0 and index.column() == self.headerColumnCount() + self.dataColumnCount() ): with suppress(ValueError): database_header_row = self.model.pivot_columns.index("database") if index.row() == database_header_row: - return self.top_left_headers["database"].suggest_db_map_codename() + return self.top_left_headers["database"].suggest_db_map_name() return None if role == Qt.ItemDataRole.FontRole and self.index_in_top_left(index): font = QFont() @@ -1200,7 +1200,7 @@ def all_header_names(self, index): entity_names = [self.db_mngr.get_item(db_map, "entity", id_)["name"] for id_ in entity_ids] parameter_name = self.db_mngr.get_item(db_map, "parameter_definition", parameter_id).get("name", "") alternative_name = self.db_mngr.get_item(db_map, "alternative", alternative_id).get("name", "") - return entity_names, parameter_name, alternative_name, db_map.codename + return entity_names, parameter_name, alternative_name, self.db_mngr.name_registry.display_name(db_map.sa_url) def index_name(self, index): """Returns a string that concatenates the object and parameter names corresponding to the given data index. diff --git a/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py b/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py index 4a3ec4437..fcf0cd770 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py @@ -27,10 +27,6 @@ class ScenarioDBItem(EmptyChildMixin, FetchMoreMixin, StandardDBItem): """A root item representing a db.""" - @property - def item_type(self): - return "db" - @property def fetch_item_type(self): return "scenario" @@ -45,18 +41,13 @@ def _make_child(self, id_): class ScenarioItem(GrayIfLastMixin, EditableMixin, EmptyChildMixin, FetchMoreMixin, BoldTextMixin, LeafItem): """A scenario leaf item.""" - @property - def item_type(self): - return "scenario" + item_type = "scenario" + icon_code = _SCENARIO_ICON @property def fetch_item_type(self): return "scenario_alternative" - @property - def icon_code(self): - return _SCENARIO_ICON - def tool_tip(self, column): if column == 0 and not self.id: return "

Note: Scenario names longer than 20 characters might appear shortened in generated files.

" @@ -125,9 +116,7 @@ def _make_child(self, id_): class ScenarioAlternativeItem(GrayIfLastMixin, EditableMixin, LeafItem): """A scenario alternative leaf item.""" - @property - def item_type(self): - return "scenario_alternative" + item_type = "scenario_alternative" def tool_tip(self, column): if column == 0: diff --git a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py index ac574a019..4ab0d3eb0 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py @@ -24,7 +24,7 @@ class ScenarioModel(TreeModelBase): """A model to display scenarios in a tree view.""" def _make_db_item(self, db_map): - return ScenarioDBItem(self, db_map) + return ScenarioDBItem(self, db_map, self.db_mngr.name_registry) def supportedDropActions(self): return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py index 885fb1871..c43e843ec 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -48,6 +48,7 @@ class SingleModelBase(HalfSortedTableModel): item_type: ClassVar[str] = NotImplemented group_fields: ClassVar[Iterable[str]] = () + can_be_filtered = True def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): """ @@ -67,7 +68,9 @@ def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): def __lt__(self, other): if self.entity_class_name == other.entity_class_name: - return self.db_map.codename < other.db_map.codename + return self.db_mngr.name_registry.display_name( + self.db_map.sa_url + ) < self.db_mngr.name_registry.display_name(other.db_map.sa_url) keys = {} for side, model in {"left": self, "right": other}.items(): dim = len(model.dimension_id_list) @@ -113,10 +116,6 @@ def dimension_id_list(self): def fixed_fields(self): return ["entity_class_name", "database"] - @property - def can_be_filtered(self): - return True - def _mapped_field(self, field): return self.field_map.get(field, field) @@ -202,7 +201,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): return FIXED_FIELD_COLOR if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole): if field == "database": - return self.db_map.codename + return self.db_mngr.name_registry.display_name(self.db_map.sa_url) id_ = self._main_data[index.row()] item = self.db_mngr.get_item(self.db_map, self.item_type, id_) if role == Qt.ItemDataRole.ToolTipRole: diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py index cb05c4eba..17369a576 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py @@ -11,6 +11,7 @@ ###################################################################################################################### """A tree model for parameter_value lists.""" +from typing import ClassVar from PySide6.QtCore import Qt from PySide6.QtGui import QBrush, QFont, QGuiApplication, QIcon from spinetoolbox.fetch_parent import FlexibleFetchParent @@ -21,9 +22,8 @@ class StandardTreeItem(TreeItem): """A tree item that fetches their children as they are inserted.""" - @property - def item_type(self): - return None + item_type: ClassVar[str] = None + icon_code: ClassVar[str] = None @property def db_mngr(self): @@ -33,10 +33,6 @@ def db_mngr(self): def display_data(self): return None - @property - def icon_code(self): - return None - def tool_tip(self, column): return None @@ -223,19 +219,18 @@ def handle_items_updated(self, db_map_data): class StandardDBItem(SortChildrenMixin, StandardTreeItem): """An item representing a db.""" - def __init__(self, model, db_map): - """Init class. + item_type = "db" + def __init__(self, model, db_map, db_name_registry): + """ Args: - model (MinimalTreeModel) - db_map (DatabaseMapping) + model (MinimalTreeModel): tree model + db_map (DatabaseMapping): database mapping + db_name_registry (NameRegistry): database display name registry """ super().__init__(model) self.db_map = db_map - - @property - def item_type(self): - return "db" + self._db_name_registry = db_name_registry def data(self, column, role=Qt.ItemDataRole.DisplayRole): """Shows Spine icon for fun.""" @@ -244,7 +239,7 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DecorationRole: return QIcon(":/symbols/Spine_symbol.png") if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - return self.db_map.codename + return self._db_name_registry.display_name(self.db_map.sa_url) class LeafItem(StandardTreeItem): @@ -260,10 +255,6 @@ def __init__(self, model, identifier=None): def _make_item_data(self): return {"name": f"Type new {self.item_type} name here...", "description": ""} - @property - def item_type(self): - raise NotImplementedError() - @property def db_map(self): return self.parent_item.db_map diff --git a/spinetoolbox/spine_db_editor/widgets/add_items_dialogs.py b/spinetoolbox/spine_db_editor/widgets/add_items_dialogs.py index a7764234b..a97618ad6 100644 --- a/spinetoolbox/spine_db_editor/widgets/add_items_dialogs.py +++ b/spinetoolbox/spine_db_editor/widgets/add_items_dialogs.py @@ -145,11 +145,11 @@ def __init__(self, parent, db_mngr, *db_maps): Args: parent (SpineDBEditor) db_mngr (SpineDBManager) - *db_maps: DiffDatabaseMapping instances + *db_maps: DatabaseMapping instances """ super().__init__(parent, db_mngr) self.db_maps = db_maps - self.keyed_db_maps = {x.codename: x for x in db_maps} + self.keyed_db_maps = db_mngr.name_registry.map_display_names_to_db_maps(db_maps) self.remove_rows_button = QToolButton(self) self.remove_rows_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.remove_rows_button.setText("Remove selected rows") @@ -170,10 +170,8 @@ def remove_selected_rows(self, checked=True): self.model.removeRows(row, 1) def all_databases(self, row): - """Returns a list of db names available for a given row. - Used by delegates. - """ - return [x.codename for x in self.db_maps] + """Returns a list of db names available for a given row.""" + return [self.db_mngr.name_registry.display_name(x.sa_url) for x in self.db_maps] class AddEntityClassesDialog(ShowIconColorEditorMixin, GetEntityClassesMixin, AddItemsDialog): @@ -211,7 +209,7 @@ def __init__(self, parent, item, db_mngr, *db_maps, force_default=False): labels = ["dimension name (1)"] if dimension_one_name is not None else [] labels += ["entity class name", "description", "display icon", "active by default", "databases"] self.model.set_horizontal_header_labels(labels) - db_names = ",".join(x.codename for x in item.db_maps) + db_names = ", ".join(db_mngr.name_registry.display_name_iter(item.db_maps)) self.default_display_icon = None self.model.set_default_row( **{ @@ -310,7 +308,7 @@ def accept(self): db_names = row_data[db_column] if db_names is None: db_names = "" - for db_name in db_names.split(","): + for db_name in db_names.split(", "): if db_name not in self.keyed_db_maps: self.parent().msg_error.emit(f"Invalid database {db_name} at row {i + 1}") return @@ -501,7 +499,7 @@ def _class_key_to_str(self, key, *db_maps): class_name = self.db_map_ent_cls_lookup[db_maps[0]][key]["name"] if len(db_maps) == len(self.db_maps): return class_name - return class_name + "@(" + ", ".join(db_map.codename for db_map in db_maps) + ")" + return class_name + "@(" + ", ".join(self.db_mngr.name_registry.display_name_iter(db_maps)) + ")" def _accepts_class(self, ent_cls): if self.entity_class is None: @@ -519,7 +517,7 @@ def _do_reset_model(self): header = self.dimension_name_list + ("entity name", "alternative", "entity group", "databases") self.model.set_horizontal_header_labels(header) default_db_maps = [db_map for db_map, keys in self.db_map_ent_cls_lookup.items() if self.class_key in keys] - db_names = ",".join([db_name for db_name, db_map in self.keyed_db_maps.items() if db_map in default_db_maps]) + db_names = ", ".join([db_name for db_name, db_map in self.keyed_db_maps.items() if db_map in default_db_maps]) alt_selection_model = self.parent().ui.alternative_tree_view.selectionModel() alt_selection = alt_selection_model.selection() selected_alt_name = None @@ -553,7 +551,7 @@ def append_db_codenames(self, name, db_maps): """ if len(db_maps) == len(self.parent().db_maps): return name - return name + "@(" + ", ".join(db_map.codename for db_map in db_maps) + ")" + return name + "@(" + ", ".join(self.db_mngr.name_registry.display_name_iter(db_maps)) + ")" def get_db_map_data(self): db_map_data = {} @@ -570,7 +568,7 @@ def get_db_map_data(self): db_names = row_data[db_column] if db_names is None: db_names = "" - for db_name in db_names.split(","): + for db_name in db_names.split(", "): if db_name not in self.keyed_db_maps: self.parent().msg_error.emit(f"Invalid database {db_name} at row {i + 1}") return @@ -636,7 +634,7 @@ def make_entity_alternatives(self, entities): entity_name = row_data[name_column] entity = entities[entity_name] db_names = row_data[db_column] - for db_name in db_names.split(","): + for db_name in db_names.split(", "): db_map = self.keyed_db_maps[db_name] entity_alternatives.setdefault(db_map, []).append( { @@ -664,7 +662,7 @@ def make_entity_groups(self, entities): entity = entities[entity_name] class_name = entity["entity_class_name"] db_names = row_data[db_column] - for db_name in db_names.split(","): + for db_name in db_names.split(", "): db_map = self.keyed_db_maps[db_name] db_map_data.setdefault(db_map, {}).setdefault("entities", set()).add((class_name, entity_group)) db_map_data.setdefault(db_map, {}).setdefault("entity_groups", set()).add( @@ -725,8 +723,9 @@ def __init__(self, parent, item, db_mngr, *db_maps): self.existing_items_model = MinimalTableModel(self, lazy=False) self.new_items_model = MinimalTableModel(self, lazy=False) self.model.sub_models = [self.new_items_model, self.existing_items_model] - self.db_combo_box.addItems([db_map.codename for db_map in db_maps]) - self.reset_entity_class_combo_box(db_maps[0].codename) + names = list(db_mngr.name_registry.display_name_iter(db_maps)) + self.db_combo_box.addItems(names) + self.reset_entity_class_combo_box(names[0]) self.connect_signals() def _populate_layout(self): @@ -891,7 +890,7 @@ def __init__(self, parent, entity_class_item, db_mngr, *db_maps): self.db_mngr = db_mngr self.db_maps = db_maps self.db_map = db_maps[0] - self.db_maps_by_codename = {db_map.codename: db_map for db_map in db_maps} + self.db_maps_by_db_name = db_mngr.name_registry.map_display_names_to_db_maps(db_maps) self.db_combo_box = QComboBox(self) self.header_widget = QWidget(self) self.group_name_line_edit = QLineEdit(self) @@ -938,7 +937,7 @@ def __init__(self, parent, entity_class_item, db_mngr, *db_maps): layout.addWidget(self.members_tree, 1, 2) layout.addWidget(self.button_box, 2, 0, 1, 3) self.setAttribute(Qt.WA_DeleteOnClose) - self.db_combo_box.addItems(list(self.db_maps_by_codename)) + self.db_combo_box.addItems(list(self.db_maps_by_db_name)) self.db_map_entity_ids = { db_map: { x["name"]: x["id"] @@ -958,7 +957,7 @@ def connect_signals(self): self.remove_button.clicked.connect(self.remove_members) def reset_list_widgets(self, database): - self.db_map = self.db_maps_by_codename[database] + self.db_map = self.db_maps_by_db_name[database] entity_ids = self.db_map_entity_ids[self.db_map] members = [] non_members = [] @@ -1015,7 +1014,7 @@ def __init__(self, parent, entity_class_item, db_mngr, *db_maps): self.setWindowTitle("Add entity group") self.group_name_line_edit.setFocus() self.group_name_line_edit.setPlaceholderText("Type group name here") - self.reset_list_widgets(db_maps[0].codename) + self.reset_list_widgets(self.db_mngr.name_registry.display_name(db_maps[0].sa_url)) self.connect_signals() def initial_member_ids(self): @@ -1071,7 +1070,7 @@ def __init__(self, parent, entity_item, db_mngr, *db_maps): self.group_name_line_edit.setReadOnly(True) self.group_name_line_edit.setText(entity_item.name) self.entity_item = entity_item - self.reset_list_widgets(db_maps[0].codename) + self.reset_list_widgets(self.db_mngr.name_registry.display_name(db_maps[0].sa_url)) self.connect_signals() def _entity_groups(self): diff --git a/spinetoolbox/spine_db_editor/widgets/commit_viewer.py b/spinetoolbox/spine_db_editor/widgets/commit_viewer.py index 94510aecc..0ffab7fa3 100644 --- a/spinetoolbox/spine_db_editor/widgets/commit_viewer.py +++ b/spinetoolbox/spine_db_editor/widgets/commit_viewer.py @@ -251,7 +251,7 @@ def __init__(self, qsettings, db_mngr, *db_maps, parent=None): self._current_index = 0 for db_map in self._db_maps: widget = _DBCommitViewer(self._db_mngr, db_map) - tab_widget.addTab(widget, db_map.codename) + tab_widget.addTab(widget, db_mngr.name_registry.display_name(db_map.sa_url)) restore_ui(self, self._qsettings, "commitViewer") self._qsettings.beginGroup("commitViewer") current = self.centralWidget().widget(self._current_index) diff --git a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py index 98ec8ffb7..525c25a5b 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_delegates.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_delegates.py @@ -275,7 +275,10 @@ class DatabaseNameDelegate(TableDelegate): def createEditor(self, parent, option, index): """Returns editor.""" editor = SearchBarEditor(self.parent(), parent) - editor.set_data(index.data(Qt.ItemDataRole.DisplayRole), [x.codename for x in self.db_mngr.db_maps]) + editor.set_data( + index.data(Qt.ItemDataRole.DisplayRole), + list(self.db_mngr.name_registry.display_name_iter(self.db_mngr.db_maps)), + ) editor.data_committed.connect(lambda *_: self._close_editor(editor, index)) return editor @@ -758,7 +761,7 @@ def _create_alternative_editor(self, parent, index): dbs_by_alternative_name = {} database_column = self.parent().model.horizontal_header_labels().index("databases") database_index = index.model().index(index.row(), database_column) - databases = database_index.data(Qt.ItemDataRole.DisplayRole).split(",") + databases = database_index.data(Qt.ItemDataRole.DisplayRole).split(", ") for db_map_codename in databases: # Filter possible alternatives based on selected databases db_map = self.parent().keyed_db_maps[db_map_codename] alternatives = self.parent().db_mngr.get_items(db_map, "alternative") @@ -781,11 +784,12 @@ def _create_entity_group_editor(self, parent, index): """ database_column = self.parent().model.horizontal_header_labels().index("databases") database_index = index.model().index(index.row(), database_column) - databases = database_index.data(Qt.ItemDataRole.DisplayRole).split(",") + databases = database_index.data(Qt.ItemDataRole.DisplayRole).split(", ") entity_class = self.parent().class_item - dbs_by_entity_group = {} # A mapping from entity_group to db_map(s) + dbs_by_entity_group = {} for db_map in entity_class.db_maps: - if db_map.codename not in databases: # Allow groups that are in selected DBs under "databases" -column. + if parent.db_mngr.name_registry.display_name(db_map.sa_url) not in databases: + # Allow groups that are in selected DBs under "databases" column. continue class_item = self.parent().db_mngr.get_item_by_field(db_map, "entity_class", "name", entity_class.name) if not class_item: @@ -814,7 +818,7 @@ def _create_database_editor(self, parent, index): """ editor = CheckListEditor(parent) all_databases = self.parent().all_databases(index.row()) - databases = index.data(Qt.ItemDataRole.DisplayRole).split(",") + databases = index.data(Qt.ItemDataRole.DisplayRole).split(", ") editor.set_data(all_databases, databases) return editor diff --git a/spinetoolbox/spine_db_editor/widgets/custom_menus.py b/spinetoolbox/spine_db_editor/widgets/custom_menus.py index 90cd7e3d2..94fa04ecd 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_menus.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_menus.py @@ -28,11 +28,12 @@ class AutoFilterMenu(FilterMenuBase): def __init__(self, parent, db_mngr, db_maps, item_type, field, show_empty=True): """ Args: - parent (SpineDBEditor) + parent (SpineDBEditor): parent widget db_mngr (SpineDBManager) db_maps (Sequence of DatabaseMapping) item_type (str) field (str): the field name + show_empty (bool) """ super().__init__(parent) self._item_type = item_type @@ -62,12 +63,12 @@ def set_filter_rejected_values(self, rejected_values): def _get_value(self, item, db_map): if self._field == "database": - return db_map.codename + return self._db_mngr.name_registry.display_name(db_map.sa_url) return item[self._field] def _get_display_value(self, item, db_map): if self._field in ("value", "default_value"): - return self._db_mngr.get_value(db_map, item, role=Qt.DisplayRole) + return self._db_mngr.get_value(db_map, item, role=Qt.ItemDataRole.DisplayRole) if self._field == "entity_byname": return DB_ITEM_SEPARATOR.join(item[self._field]) return self._get_value(item, db_map) or "(empty)" @@ -216,20 +217,21 @@ def emit_filter_changed(self, valid_values): self.filterChanged.emit(self._identifier, valid_values, self._filter.has_filter()) -class TabularViewCodenameFilterMenu(TabularViewFilterMenuBase): - """Filter menu to filter database codenames in Pivot table.""" +class TabularViewDatabaseNameFilterMenu(TabularViewFilterMenuBase): + """Filter menu to filter database names in Pivot table.""" - def __init__(self, parent, db_maps, identifier, show_empty=True): + def __init__(self, parent, db_maps, identifier, db_name_registry, show_empty=True): """ Args: parent (SpineDBEditor): parent widget db_maps (Sequence of DatabaseMapping): database mappings identifier (str): header identifier + db_name_registry (NameRegistry): database display name registry show_empty (bool): if True, an empty row will be added to the end of the item list """ super().__init__(parent, identifier) self._set_up(SimpleFilterCheckboxListModel, self, show_empty=show_empty) - self._filter.set_filter_list([db_map.codename for db_map in db_maps]) + self._filter.set_filter_list(list(db_name_registry.display_name_iter(db_maps))) def emit_filter_changed(self, valid_values): """See base class.""" diff --git a/spinetoolbox/spine_db_editor/widgets/custom_qwidgets.py b/spinetoolbox/spine_db_editor/widgets/custom_qwidgets.py index 4358c8b6e..a36415b41 100644 --- a/spinetoolbox/spine_db_editor/widgets/custom_qwidgets.py +++ b/spinetoolbox/spine_db_editor/widgets/custom_qwidgets.py @@ -150,8 +150,7 @@ def __init__(self, file_path, progress, db_editor): @Slot(bool) def open_file(self, checked=False): - codename = os.path.splitext(self.file_name)[0] - self.db_editor._open_sqlite_url(self.url, codename) + self.db_editor.add_new_tab(self.url) class ShootingLabel(QLabel): @@ -459,14 +458,14 @@ def selections(self): class AddedEntitiesPopup(QDialog): """Class for showing automatically added entities""" - def __init__(self, parent, added_entities): + def __init__(self, parent, db_name_registry, added_entities): super().__init__(parent) self.setWindowTitle("Added Entities") self._textEdit = QTextEdit(self) self._text = None self._entity_names = None self._create_entity_names(added_entities) - self._create_text() + self._create_text(db_name_registry) self._textEdit.setHtml(self._text) self._textEdit.setReadOnly(True) self._textEdit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) @@ -484,10 +483,10 @@ def __init__(self, parent, added_entities): self.setSizeGripEnabled(True) self.resize(400, 400) - def _create_text(self): + def _create_text(self, db_name_registry): lines = [] for db_map, classes in self._entity_names.items(): - lines.append(f"{db_map.codename}:") + lines.append(f"{db_name_registry.display_name(db_map.sa_url)}:") for cls_name, ent_names in classes.items(): lines.append(f"
  • {cls_name}:
  • ") for ent_name in ent_names: diff --git a/spinetoolbox/spine_db_editor/widgets/edit_or_remove_items_dialogs.py b/spinetoolbox/spine_db_editor/widgets/edit_or_remove_items_dialogs.py index 4bd3ffde6..389ea3ce5 100644 --- a/spinetoolbox/spine_db_editor/widgets/edit_or_remove_items_dialogs.py +++ b/spinetoolbox/spine_db_editor/widgets/edit_or_remove_items_dialogs.py @@ -32,11 +32,9 @@ def __init__(self, parent, db_mngr): self.items = [] def all_databases(self, row): - """Returns a list of db names available for a given row. - Used by delegates. - """ + """Returns a list of db names available for a given row.""" item = self.items[row] - return [db_map.codename for db_map in item.db_maps] + return list(self.db_mngr.name_registry.display_name_iter(item.db_maps)) class EditEntityClassesDialog(ShowIconColorEditorMixin, EditOrRemoveItemsDialog): @@ -89,8 +87,15 @@ def accept(self): db_names = "" item = self.items[i] db_maps = [] - for database in db_names.split(","): - db_map = next((db_map for db_map in item.db_maps if db_map.codename == database), None) + for database in db_names.split(", "): + db_map = next( + ( + db_map + for db_map in item.db_maps + if self.db_mngr.name_registry.display_name(db_map.sa_url) == database + ), + None, + ) if db_map is None: self.parent().msg_error.emit(f"Invalid database {database} at row {i + 1}") return @@ -137,7 +142,7 @@ def __init__(self, parent, db_mngr, selected, class_key): self.table_view.setItemDelegate(ManageEntitiesDelegate(self)) self.connect_signals() self.db_maps = set(db_map for item in selected for db_map in item.db_maps) - self.keyed_db_maps = {x.codename: x for x in self.db_maps} + self.keyed_db_maps = self.db_mngr.name_registry.map_display_names_to_db_maps(self.db_maps) self.class_key = class_key self.model.set_horizontal_header_labels( [x + " byname" for x in self.dimension_name_list] + ["entity name", "databases"] @@ -174,9 +179,16 @@ def accept(self): if db_names is None: db_names = "" db_maps = [] - for database in db_names.split(","): - db_map = next((db_map for db_map in item.db_maps if db_map.codename == database), None) - if db_map is None: + for database in db_names.split(", "): + try: + db_map = next( + ( + db_map + for db_map in item.db_maps + if self.db_mngr.name_registry.display_name(db_map.sa_url) == database + ) + ) + except StopIteration: self.parent().msg_error.emit(f"Invalid database {database} at row {i + 1}") return db_maps.append(db_map) @@ -187,7 +199,7 @@ def accept(self): entity_classes = self.db_map_ent_cls_lookup[db_map] if (self.class_key) not in entity_classes: self.parent().msg_error.emit( - f"Invalid entity class '{self.class_name}' for db '{db_map.codename}' at row {i + 1}" + f"Invalid entity class '{self.class_name}' for db '{self.db_mngr.name_registry.display_name(db_map.sa_url)}' at row {i + 1}" ) return ent_cls = entity_classes[self.class_key] @@ -198,7 +210,7 @@ def accept(self): for dimension_id, element_name in zip(dimension_id_list, element_name_list): if (dimension_id, element_name) not in entities: self.parent().msg_error.emit( - f"Invalid entity '{element_name}' for db '{db_map.codename}' at row {i + 1}" + f"Invalid entity '{element_name}' for db '{self.db_mngr.name_registry.display_name(db_map.sa_url)}' at row {i + 1}" ) return element_id = entities[dimension_id, element_name]["id"] @@ -248,8 +260,15 @@ def accept(self): db_names = "" item = self.items[i] db_maps = [] - for database in db_names.split(","): - db_map = next((db_map for db_map in item.db_maps if db_map.codename == database), None) + for database in db_names.split(", "): + db_map = next( + ( + db_map + for db_map in item.db_maps + if self.db_mngr.name_registry.display_name(db_map.sa_url) == database + ), + None, + ) if db_map is None: self.parent().msg_error.emit(f"Invalid database {database} at row {i + 1}") return @@ -282,7 +301,7 @@ def __init__(self, parent, entity_class_item, db_mngr, *db_maps): combobox.setCurrentText(superclass_subclass["superclass_name"]) else: combobox.setCurrentIndex(0) - self._tab_widget.addTab(combobox, db_map.codename) + self._tab_widget.addTab(combobox, self.db_mngr.name_registry.display_name(db_map.sa_url)) self.connect_signals() self.setWindowTitle(f"Select {self._subclass_name}'s superclass") diff --git a/spinetoolbox/spine_db_editor/widgets/graph_view_mixin.py b/spinetoolbox/spine_db_editor/widgets/graph_view_mixin.py index 1cb17cff6..a5428f125 100644 --- a/spinetoolbox/spine_db_editor/widgets/graph_view_mixin.py +++ b/spinetoolbox/spine_db_editor/widgets/graph_view_mixin.py @@ -661,7 +661,7 @@ def get_entity_key(self, db_map_entity_id): entity = self.db_mngr.get_item(db_map, "entity", entity_id) key = (entity["entity_class_name"], entity["dimension_name_list"], entity["entity_byname"]) if not self.ui.graphicsView.get_property("merge_dbs"): - key += (db_map.codename,) + key += (self.db_mngr.name_registry.display_name(db_map.sa_url),) return key def _update_entity_element_inds(self, db_map_element_id_lists): diff --git a/spinetoolbox/spine_db_editor/widgets/manage_items_dialogs.py b/spinetoolbox/spine_db_editor/widgets/manage_items_dialogs.py index 6d3ba4722..b776dc5dc 100644 --- a/spinetoolbox/spine_db_editor/widgets/manage_items_dialogs.py +++ b/spinetoolbox/spine_db_editor/widgets/manage_items_dialogs.py @@ -167,7 +167,7 @@ def entity_class_name_list(self, row): """ db_column = self.model.header.index("databases") db_names = self.model._main_data[row][db_column] - db_maps = [self.keyed_db_maps[x] for x in db_names.split(",") if x in self.keyed_db_maps] + db_maps = [self.keyed_db_maps[x] for x in db_names.split(", ") if x in self.keyed_db_maps] return self._entity_class_name_list_from_db_maps(*db_maps) def _entity_class_name_list_from_db_maps(self, *db_maps): @@ -234,7 +234,7 @@ def alternative_name_list(self, row): """ db_column = self.model.header.index("databases") db_names = self.model._main_data[row][db_column] - db_maps = [self.keyed_db_maps[x] for x in db_names.split(",") if x in self.keyed_db_maps] + db_maps = [self.keyed_db_maps[x] for x in db_names.split(", ") if x in self.keyed_db_maps] return sorted(set(x for db_map in db_maps for x in self.db_map_alt_id_lookup[db_map])) def entity_name_list(self, row, column): @@ -243,7 +243,7 @@ def entity_name_list(self, row, column): """ db_column = self.model.header.index("databases") db_names = self.model._main_data[row][db_column] - db_maps = [self.keyed_db_maps[x] for x in db_names.split(",") if x in self.keyed_db_maps] + db_maps = [self.keyed_db_maps[x] for x in db_names.split(", ") if x in self.keyed_db_maps] entity_name_lists = [] for db_map in db_maps: entity_classes = self.db_map_ent_cls_lookup[db_map] diff --git a/spinetoolbox/spine_db_editor/widgets/mass_select_items_dialogs.py b/spinetoolbox/spine_db_editor/widgets/mass_select_items_dialogs.py index 349332612..eeeb1b390 100644 --- a/spinetoolbox/spine_db_editor/widgets/mass_select_items_dialogs.py +++ b/spinetoolbox/spine_db_editor/widgets/mass_select_items_dialogs.py @@ -22,11 +22,12 @@ class _SelectDatabases(QWidget): checked_state_changed = Signal(int) - def __init__(self, db_maps, checked_states, parent): + def __init__(self, db_maps, checked_states, db_name_registry, parent): """ Args: db_maps (tuple of DatabaseMapping): database maps checked_states (dict, optional): mapping from item name to check state boolean + db_name_registry (NameRegistry): database display name registry parent (QWidget): parent widget """ super().__init__(parent) @@ -34,7 +35,9 @@ def __init__(self, db_maps, checked_states, parent): self._ui = Ui_Form() self._ui.setupUi(self) - self._check_boxes = {db_map: QCheckBox(db_map.codename, self) for db_map in db_maps} + self._check_boxes = { + db_map: QCheckBox(db_name_registry.display_name(db_map.sa_url), self) for db_map in db_maps + } add_check_boxes( self._check_boxes, checked_states, @@ -80,7 +83,9 @@ def __init__(self, parent, db_mngr, *db_maps, stored_state, ok_button_text): database_checked_states = ( stored_state["databases"] if stored_state is not None else {db_map: True for db_map in db_maps} ) - self._database_check_boxes_widget = _SelectDatabases(tuple(db_maps), database_checked_states, self) + self._database_check_boxes_widget = _SelectDatabases( + tuple(db_maps), database_checked_states, db_mngr.name_registry, self + ) self._database_check_boxes_widget.checked_state_changed.connect(self._handle_check_box_state_changed) self._ui.root_layout.insertWidget(0, self._database_check_boxes_widget) diff --git a/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py b/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py index c2feec59e..c894a5047 100644 --- a/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py +++ b/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py @@ -19,6 +19,7 @@ from ...helpers import CharIconEngine, open_url from ...widgets.multi_tab_window import MultiTabWindow from ...widgets.settings_widget import SpineDBEditorSettingsWidget +from ..editors import db_editor_registry from .custom_qwidgets import OpenFileButton, OpenSQLiteFileButton, ShootingLabel from .spine_db_editor import SpineDBEditor @@ -26,11 +27,11 @@ class MultiSpineDBEditor(MultiTabWindow): """Database editor's tabbed main window.""" - def __init__(self, db_mngr, db_url_codenames=None): + def __init__(self, db_mngr, db_urls=None): """ Args: db_mngr (SpineDBManager): database manager - db_url_codenames (dict, optional): mapping from database URL to its codename + db_urls (Iterable of str, optional): URLs of database to load """ super().__init__(db_mngr.qsettings, "spineDBEditor") self.db_mngr = db_mngr @@ -42,9 +43,10 @@ def __init__(self, db_mngr, db_url_codenames=None): self.setStatusBar(_CustomStatusBar(self)) self.statusBar().hide() self.tab_load_success = True - if db_url_codenames is not None: - if not self.add_new_tab(db_url_codenames, window=True): + if db_urls is not None: + if not self.add_new_tab(db_urls): self.tab_load_success = False + db_editor_registry.register_window(self) def _make_other(self): return MultiSpineDBEditor(self.db_mngr) @@ -84,10 +86,10 @@ def _disconnect_tab_signals(self, index): tab.ui.actionClose.triggered.disconnect(self.handle_close_request_from_tab) return True - def _make_new_tab(self, db_url_codenames=None, window=False): # pylint: disable=arguments-differ + def _make_new_tab(self, db_urls=None): # pylint: disable=arguments-differ """Makes a new tab, if successful return the tab, returns None otherwise""" tab = SpineDBEditor(self.db_mngr) - if not tab.load_db_urls(db_url_codenames, create=True, window=window): + if not tab.load_db_urls(db_urls if db_urls is not None else [], create=True): return return tab @@ -102,7 +104,7 @@ def show_plus_button_context_menu(self, global_pos): return menu = QMenu(self) for name, url in ds_urls.items(): - action = menu.addAction(name, lambda name=name, url=url: self.db_mngr.open_db_editor({url: name}, True)) + action = menu.addAction(name, lambda url=url: open_db_editor([url], self.db_mngr, True)) action.setEnabled(url is not None and is_url_validated[name]) menu.popup(global_pos) menu.aboutToHide.connect(menu.deleteLater) @@ -114,13 +116,11 @@ def make_context_menu(self, index): tab = self.tab_widget.widget(index) menu.addSeparator() menu.addAction(tab.toolbar.reload_action) - db_url_codenames = tab.db_url_codenames + db_urls = tab.db_urls menu.addAction( QIcon(CharIconEngine("\uf24d")), "Duplicate", - lambda _=False, index=index + 1, db_url_codenames=db_url_codenames: self.insert_new_tab( - index, db_url_codenames - ), + lambda _=False, index=index + 1, db_urls=db_urls: self.insert_new_tab(index, db_urls), ) return menu @@ -160,10 +160,6 @@ def insert_open_file_button(self, file_path, progress, is_sqlite): button = (OpenSQLiteFileButton if is_sqlite else OpenFileButton)(file_path, progress, self) self._insert_statusbar_button(button) - def _open_sqlite_url(self, url, codename): - """Opens sqlite url.""" - self.add_new_tab({url: codename}) - @Slot(bool) def show_user_guide(self, checked=False): """Opens Spine db editor documentation page in browser.""" @@ -171,6 +167,11 @@ def show_user_guide(self, checked=False): if not open_url(doc_url): self.msg_error.emit(f"Unable to open url {doc_url}") + def closeEvent(self, event): + super().closeEvent(event) + if event.isAccepted(): + db_editor_registry.unregister_window(self) + class _CustomStatusBar(QStatusBar): def __init__(self, parent=None): @@ -198,3 +199,49 @@ def __init__(self, parent=None): self.insertPermanentWidget(0, self._hide_button) self.setSizeGripEnabled(False) self._hide_button.clicked.connect(self.hide) + + +def _get_existing_spine_db_editor(db_urls): + """Returns existing editor window and tab or None for given database URLs. + + Args: + db_urls (Sequence of str): database URLs + + Returns: + tuple: editor window and tab or None if not found + """ + for multi_db_editor in db_editor_registry.windows(): + for k in range(multi_db_editor.tab_widget.count()): + db_editor = multi_db_editor.tab_widget.widget(k) + if all(url in db_urls for url in db_editor.db_urls): + return multi_db_editor, db_editor + return None + + +def open_db_editor(db_urls, db_mngr, reuse_existing_editor): + """Opens a SpineDBEditor with given urls. + + Optionally uses an existing MultiSpineDBEditor if any. + Also, if the same urls are open in an existing SpineDBEditor, just raises that one + instead of creating another. + + Args: + db_urls (Iterable of str): URLs of databases to open + db_mngr (SpineDBManager): database manager + reuse_existing_editor (bool): if True and the same URL is already open, just raise the existing window + """ + multi_db_editor = db_editor_registry.get_some_window() if reuse_existing_editor else None + if multi_db_editor is None: + multi_db_editor = MultiSpineDBEditor(db_mngr, db_urls) + if multi_db_editor.tab_load_success: + multi_db_editor.show() + return + existing = _get_existing_spine_db_editor(list(map(str, db_urls))) + if existing is None: + multi_db_editor.add_new_tab(db_urls) + else: + multi_db_editor, db_editor = existing + multi_db_editor.set_current_tab(db_editor) + if multi_db_editor.isMinimized(): + multi_db_editor.showNormal() + multi_db_editor.activateWindow() diff --git a/spinetoolbox/spine_db_editor/widgets/spine_db_editor.py b/spinetoolbox/spine_db_editor/widgets/spine_db_editor.py index 748414173..fd647cb0c 100644 --- a/spinetoolbox/spine_db_editor/widgets/spine_db_editor.py +++ b/spinetoolbox/spine_db_editor/widgets/spine_db_editor.py @@ -75,8 +75,8 @@ def __init__(self, db_mngr): from ..ui.spine_db_editor_window import Ui_MainWindow # pylint: disable=import-outside-toplevel self.db_mngr = db_mngr - self.db_maps = [] - self.db_urls = [] + self.db_maps: list[DatabaseMapping] = [] + self.db_urls: list[str] = [] self._history = [] self.recent_dbs_menu = RecentDatabasesPopupMenu(self) self._change_notifiers = [] @@ -124,37 +124,38 @@ def toolbox(self): def settings_subgroup(self): return ";".join(self.db_urls) - @property - def db_names(self): - return ", ".join([f"{db_map.codename}" for db_map in self.db_maps]) - @property def first_db_map(self): return self.db_maps[0] - @property - def db_url_codenames(self): - return {db_map.db_url: db_map.codename for db_map in self.db_maps} - @staticmethod def is_db_map_editor(): - """Always returns True as SpineDBEditors are truly database editors. + """Always returns True as SpineDBEditors are truly database editors.""" + return True - Unless, of course, the database can one day be opened in read-only mode. - In that case this method should return False. + @Slot(str, str) + def _update_title(self, url, name): + """Updates window title if database display name has changed. - Returns: - bool: Always True + Args: + url (str): database url + name (str): database display name """ - return True + if not any(str(db_map.sa_url) == url for db_map in self.db_maps): + return + self._reset_window_title() + + def _reset_window_title(self): + """Sets new window title according to open databases.""" + self.setWindowTitle(", ".join(self.db_mngr.name_registry.display_name_iter(self.db_maps))) - def load_db_urls(self, db_url_codenames, create=False, update_history=True, window=False): + def load_db_urls(self, db_urls, create=False, update_history=True): self.ui.actionImport.setEnabled(False) self.ui.actionExport.setEnabled(False) self.ui.actionMass_remove_items.setEnabled(False) self.ui.actionVacuum.setEnabled(False) self.toolbar.reload_action.setEnabled(False) - if not db_url_codenames: + if not db_urls: return True if not self.tear_down(): return False @@ -163,10 +164,8 @@ def load_db_urls(self, db_url_codenames, create=False, update_history=True, wind self.db_maps = [] self._changelog.clear() self._purge_change_notifiers() - for url, codename in db_url_codenames.items(): - db_map = self.db_mngr.get_db_map( - url, self, codename=None, create=create, window=window, force_upgrade_prompt=True - ) + for url in db_urls: + db_map = self.db_mngr.get_db_map(url, self, create=create, force_upgrade_prompt=True) if db_map is not None: self.db_maps.append(db_map) if not self.db_maps: @@ -184,9 +183,9 @@ def load_db_urls(self, db_url_codenames, create=False, update_history=True, wind self.db_mngr.register_listener(self, *self.db_maps) self.init_models() self.init_add_undo_redo_actions() - self.setWindowTitle(f"{self.db_names}") # This sets the tab name, just in case + self._reset_window_title() if update_history: - self.add_urls_to_history(self.db_url_codenames) + self.add_urls_to_history() self.update_last_view() self.restore_ui(self.last_view, fresh=True) self.update_commit_enabled() @@ -200,19 +199,16 @@ def show_recent_db(self): self.recent_dbs_menu = RecentDatabasesPopupMenu(self) self.ui.actionOpen_recent.setMenu(self.recent_dbs_menu) - def add_urls_to_history(self, db_urls): - """Adds urls to history. - - Args: - db_urls (dict) - """ + def add_urls_to_history(self): + """Adds current urls to history.""" opened_names = set() for row in self._history: for name in row: opened_names.add(name) - for db_url, name in db_urls.items(): + for url in self.db_urls: + name = self.db_mngr.name_registry.display_name(url) if name not in opened_names: - self._history.insert(0, {name: db_url}) + self._history.insert(0, {name: url}) def init_add_undo_redo_actions(self): new_undo_action = self.db_mngr.undo_action[self.first_db_map] @@ -228,8 +224,8 @@ def open_db_file(self, _=False): self.qsettings.endGroup() if not file_path: return - url = "sqlite:///" + file_path - self.load_db_urls({url: None}) + url = "sqlite:///" + os.path.normcase(file_path) + self.load_db_urls([url]) @Slot(bool) def add_db_file(self, _=False): @@ -240,10 +236,8 @@ def add_db_file(self, _=False): self.qsettings.endGroup() if not file_path: return - url = "sqlite:///" + file_path - db_url_codenames = self.db_url_codenames - db_url_codenames[url] = None - self.load_db_urls(db_url_codenames) + url = "sqlite:///" + os.path.normcase(file_path) + self.load_db_urls(self.db_urls + [url]) @Slot(bool) def create_db_file(self, _=False): @@ -258,8 +252,8 @@ def create_db_file(self, _=False): os.remove(file_path) except OSError: pass - url = "sqlite:///" + file_path - self.load_db_urls({url: None}, create=True) + url = "sqlite:///" + os.path.normcase(file_path) + self.load_db_urls([url], create=True) def reset_docks(self): """Resets the layout of the dock widgets for this URL""" @@ -284,13 +278,12 @@ def _browse_commits(self): def connect_signals(self): """Connects signals to slots.""" - # Message signals self.msg.connect(self.add_message) self.msg_error.connect(self.err_msg.showMessage) self.db_mngr.items_added.connect(self._handle_items_added) self.db_mngr.items_updated.connect(self._handle_items_updated) self.db_mngr.items_removed.connect(self._handle_items_removed) - # Menu actions + self.db_mngr.name_registry.display_name_changed.connect(self._update_title) self.ui.actionCommit.triggered.connect(self.commit_session) self.ui.actionRollback.triggered.connect(self.rollback_session) self.ui.actionView_history.triggered.connect(self._browse_commits) @@ -310,7 +303,7 @@ def vacuum(self, _checked=False): msg = "Vacuum finished
      " for db_map in self.db_maps: freed, unit = vacuum(db_map.db_url) - msg += f"
    • {freed} {unit} freed from {db_map.codename}
    • " + msg += f"
    • {freed} {unit} freed from {self.db_mngr.name_registry.display_name(db_map.sa_url)}
    • " msg += "
    " self.msg.emit(msg) @@ -555,7 +548,7 @@ def duplicate_scenario(self, db_map, scen_id): Duplicates a scenario. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) scen_id (int) """ orig_name = self.db_mngr.get_item(db_map, "scenario", scen_id)["name"] @@ -597,7 +590,7 @@ def commit_session(self, checked=False): dirty_db_maps = self.db_mngr.dirty(*self.db_maps) if not dirty_db_maps: return - db_names = ", ".join([db_map.codename for db_map in dirty_db_maps]) + db_names = ", ".join(self.db_mngr.name_registry.display_name_iter(dirty_db_maps)) commit_msg = self._get_commit_msg(db_names) if not commit_msg: return @@ -609,7 +602,7 @@ def rollback_session(self, checked=False): dirty_db_maps = self.db_mngr.dirty(*self.db_maps) if not dirty_db_maps: return - db_names = ", ".join([db_map.codename for db_map in dirty_db_maps]) + db_names = ", ".join(self.db_mngr.name_registry.display_name_iter(dirty_db_maps)) if not self._get_rollback_confirmation(db_names): return self.db_mngr.rollback_session(*dirty_db_maps) @@ -618,7 +611,7 @@ def receive_session_committed(self, db_maps, cookie): db_maps = set(self.db_maps) & set(db_maps) if not db_maps: return - db_names = ", ".join([x.codename for x in db_maps]) + db_names = ", ".join(self.db_mngr.name_registry.display_name_iter(db_maps)) if cookie is self: msg = f"All changes in {db_names} committed successfully." self.msg.emit(msg) @@ -631,7 +624,7 @@ def receive_session_rolled_back(self, db_maps): db_maps = set(self.db_maps) & set(db_maps) if not db_maps: return - db_names = ", ".join([x.codename for x in db_maps]) + db_names = ", ".join(self.db_mngr.name_registry.display_name_iter(db_maps)) msg = f"All changes in {db_names} rolled back successfully." self.msg.emit(msg) @@ -681,7 +674,9 @@ def receive_error_msg(self, db_map_error_log): for db_map, error_log in db_map_error_log.items(): if isinstance(error_log, str): error_log = [error_log] - msg = "From " + db_map.codename + ":" + format_string_list(error_log) + msg = ( + "From " + self.db_mngr.name_registry.display_name(db_map.sa_url) + ": " + format_string_list(error_log) + ) msgs.append(msg) self.msg_error.emit(format_string_list(msgs)) @@ -769,7 +764,7 @@ def tear_down(self): answer = self._prompt_to_commit_changes() if answer == QMessageBox.StandardButton.Cancel: return False - db_names = ", ".join([db_map.codename for db_map in dirty_db_maps]) + db_names = ", ".join(self.db_mngr.name_registry.display_name_iter(dirty_db_maps)) if answer == QMessageBox.StandardButton.Save: commit_dirty = True commit_msg = self._get_commit_msg(db_names) @@ -781,7 +776,7 @@ def tear_down(self): self, *self.db_maps, dirty_db_maps=dirty_db_maps, commit_dirty=commit_dirty, commit_msg=commit_msg ) if failed_db_maps: - msg = f"Failed to commit {[db_map.codename for db_map in failed_db_maps]}" + msg = f"Failed to commit {list(self.db_mngr.name_registry.display_name_iter(failed_db_maps))}" self.db_mngr.receive_error_msg({i: [msg] for i in failed_db_maps}) return False return True @@ -872,6 +867,7 @@ def closeEvent(self, event): event.ignore() return self.save_window_state() + self.db_mngr.name_registry.display_name_changed.disconnect(self._update_title) super().closeEvent(event) @staticmethod @@ -907,11 +903,11 @@ class SpineDBEditor(TabularViewMixin, GraphViewMixin, StackedViewMixin, TreeView pinned_values_updated = Signal(list) - def __init__(self, db_mngr, db_url_codenames=None): - """Initializes everything. - + def __init__(self, db_mngr, db_urls=None): + """ Args: db_mngr (SpineDBManager): The manager to use + db_urls (Iterable of str, optional): URLs of databases to load """ super().__init__(db_mngr) self._original_size = None @@ -926,8 +922,8 @@ def __init__(self, db_mngr, db_url_codenames=None): self.connect_signals() self.apply_stacked_style() self.set_db_column_visibility(False) - if db_url_codenames is not None: - self.load_db_urls(db_url_codenames) + if db_urls is not None: + self.load_db_urls(db_urls) def set_db_column_visibility(self, visible): """Set the visibility of the database -column in all the views it is present""" diff --git a/spinetoolbox/spine_db_editor/widgets/stacked_view_mixin.py b/spinetoolbox/spine_db_editor/widgets/stacked_view_mixin.py index 9197dd936..b1702a809 100644 --- a/spinetoolbox/spine_db_editor/widgets/stacked_view_mixin.py +++ b/spinetoolbox/spine_db_editor/widgets/stacked_view_mixin.py @@ -22,9 +22,7 @@ class StackedViewMixin: - """ - Provides stacked parameter tables for the Spine db editor. - """ + """Provides stacked parameter tables for the Spine db editor.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -102,7 +100,7 @@ def _set_default_parameter_data(self, index=None): """ if index is None or not index.isValid(): default_db_map = next(iter(self.db_maps)) - default_data = {"database": default_db_map.codename} + default_data = {"database": self.db_mngr.name_registry.display_name(default_db_map.sa_url)} else: item = index.model().item_from_index(index) default_db_map = item.first_db_map diff --git a/spinetoolbox/spine_db_editor/widgets/tabular_view_mixin.py b/spinetoolbox/spine_db_editor/widgets/tabular_view_mixin.py index 06ff27058..2435eb265 100644 --- a/spinetoolbox/spine_db_editor/widgets/tabular_view_mixin.py +++ b/spinetoolbox/spine_db_editor/widgets/tabular_view_mixin.py @@ -27,7 +27,7 @@ PivotTableSortFilterProxy, ScenarioAlternativePivotTableModel, ) -from .custom_menus import TabularViewCodenameFilterMenu, TabularViewDBItemFilterMenu +from .custom_menus import TabularViewDatabaseNameFilterMenu, TabularViewDBItemFilterMenu from .tabular_view_header_widget import TabularViewHeaderWidget @@ -402,7 +402,9 @@ def create_filter_menu(self, identifier): """ if identifier not in self.filter_menus: if identifier == "database": - menu = TabularViewCodenameFilterMenu(self, self.db_maps, identifier, show_empty=False) + menu = TabularViewDatabaseNameFilterMenu( + self, self.db_maps, identifier, self.db_mngr.name_registry, show_empty=False + ) else: header = self.pivot_table_model.top_left_headers[identifier] if header.header_type == "parameter": diff --git a/spinetoolbox/spine_db_editor/widgets/toolbar.py b/spinetoolbox/spine_db_editor/widgets/toolbar.py index 22aad619c..4341f451e 100644 --- a/spinetoolbox/spine_db_editor/widgets/toolbar.py +++ b/spinetoolbox/spine_db_editor/widgets/toolbar.py @@ -111,8 +111,8 @@ def create_button_for_action(self, action): self.addWidget(tool_button) def _show_url_codename_widget(self): - """Shows the url codename widget""" - dialog = _URLDialog(self._db_editor.db_url_codenames, parent=self) + """Shows the url widget""" + dialog = _URLDialog(self._db_editor.db_urls, self._db_editor.db_mngr.name_registry, self) dialog.show() @Slot(bool) @@ -163,6 +163,7 @@ def __init__(self, db_mngr, db_map, parent=None): super().__init__(parent=parent) layout = QHBoxLayout(self) self._offset = 0 + self._db_mngr = db_mngr self._db_map = db_map self._filter_widgets = [] active_filter_configs = {cfg["type"]: cfg for cfg in filter_configs(db_map.db_url)} @@ -181,7 +182,7 @@ def filtered_url_codename(self): if not filter_config_: continue url = append_filter_config(url, filter_config_) - return url, self._db_map.codename + return url, self._db_mngr.name_registry.display_name(self._db_map.sa_url) def sizeHint(self): size = super().sizeHint() @@ -206,7 +207,7 @@ def __init__(self, db_mngr, db_maps, parent=None): self.header().hide() self._filter_arrays = [] for db_map in db_maps: - top_level_item = QTreeWidgetItem([db_map.codename]) + top_level_item = QTreeWidgetItem([db_mngr.name_registry.display_name(db_map.sa_url)]) self.addTopLevelItem(top_level_item) child = QTreeWidgetItem() top_level_item.addChild(child) @@ -262,13 +263,13 @@ def accept(self): class _URLDialog(QDialog): - """Class for showing URLs and codenames in the database""" + """Class for showing URLs and database names in the editor""" - def __init__(self, url_codenames, parent=None): + def __init__(self, urls, name_registry, parent=None): super().__init__(parent=parent, f=Qt.Popup) self.textEdit = QTextEdit(self) self.textEdit.setObjectName("textEdit") - text = "
    ".join([f"{codename}: {url}" for url, codename in url_codenames.items()]) + text = "
    ".join([f"{name_registry.display_name(url)}: {url}" for url in urls]) self.textEdit.setHtml(text) self.textEdit.setReadOnly(True) self.textEdit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) diff --git a/spinetoolbox/spine_db_manager.py b/spinetoolbox/spine_db_manager.py index 3b38e6d23..1c06c3a1a 100644 --- a/spinetoolbox/spine_db_manager.py +++ b/spinetoolbox/spine_db_manager.py @@ -15,8 +15,7 @@ import json import os from PySide6.QtCore import QObject, Qt, Signal, Slot -from PySide6.QtGui import QWindow -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget +from PySide6.QtWidgets import QApplication, QMessageBox from sqlalchemy.engine.url import URL from spinedb_api import ( Array, @@ -48,6 +47,7 @@ split_value_and_type, ) from spinedb_api.spine_io.exporters.excel import export_spine_database_to_xlsx +from spinetoolbox.database_display_names import NameRegistry from .helpers import busy_effect, plain_to_tool_tip from .mvcmodels.shared import INVALID_TYPE, PARAMETER_TYPE_VALIDATION_ROLE, PARSED_ROLE, TYPE_NOT_VALIDATED, VALID_TYPE from .parameter_type_validation import ParameterTypeValidator, ValidationKey @@ -58,7 +58,6 @@ RemoveItemsCommand, UpdateItemsCommand, ) -from .spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor from .spine_db_icon_manager import SpineDBIconManager from .spine_db_worker import SpineDBWorker from .widgets.options_dialog import OptionsDialog @@ -114,6 +113,7 @@ def __init__(self, settings, parent, synchronous=False): super().__init__(parent) self.qsettings = settings self._db_maps = {} + self.name_registry = NameRegistry(self) self._workers = {} self.listeners = {} self.undo_stack = {} @@ -196,7 +196,7 @@ def register_fetch_parent(self, db_map, parent): worker.register_fetch_parent(parent) def can_fetch_more(self, db_map, parent): - """Whether or not we can fetch more items of given type from given db. + """Whether we can fetch more items of given type from given db. Args: db_map (DatabaseMapping) @@ -333,6 +333,7 @@ def close_session(self, url): worker.clean_up() del self._validated_values["parameter_definition"][id(db_map)] del self._validated_values["parameter_value"][id(db_map)] + self.undo_stack[db_map].cleanChanged.disconnect() del self.undo_stack[db_map] del self.undo_action[db_map] del self.redo_action[db_map] @@ -342,15 +343,13 @@ def close_all_sessions(self): for url in list(self._db_maps): self.close_session(url) - def get_db_map(self, url, logger, window=False, codename=None, create=False, force_upgrade_prompt=False): + def get_db_map(self, url, logger, create=False, force_upgrade_prompt=False): """Returns a DatabaseMapping instance from url if possible, None otherwise. If needed, asks the user to upgrade to the latest db version. Args: url (str, URL) logger (LoggerInterface) - window (bool) - codename (str, optional) create (bool) force_upgrade_prompt (bool) @@ -360,8 +359,6 @@ def get_db_map(self, url, logger, window=False, codename=None, create=False, for url = str(url) db_map = self._db_maps.get(url) if db_map is not None: - if not window and codename is not None and db_map.codename != codename: - return None return db_map try: prompt_data = DatabaseMapping.get_upgrade_db_prompt_data(url, create=create) @@ -380,7 +377,7 @@ def get_db_map(self, url, logger, window=False, codename=None, create=False, for return None else: kwargs = {} - kwargs.update(codename=codename, create=create) + kwargs["create"] = create try: return self._do_get_db_map(url, **kwargs) except SpineDBAPIError as err: @@ -390,13 +387,10 @@ def get_db_map(self, url, logger, window=False, codename=None, create=False, for @busy_effect def _do_get_db_map(self, url, **kwargs): """Returns a memorized DatabaseMapping instance from url. - Called by `get_db_map`. Args: url (str, URL) - codename (str, NoneType) - upgrade (bool) - create (bool) + **kwargs: arguments passed to worker's get_db_map() Returns: DatabaseMapping @@ -1648,7 +1642,7 @@ def export_to_sqlite(self, file_path, data_for_export, caller): try: db_map.commit_session("Export data from Spine Toolbox.") except SpineDBAPIError as err: - error_msg = f"[SpineDBAPIError] Unable to export file {db_map.codename}: {err.msg}" + error_msg = f"[SpineDBAPIError] Unable to export file {file_path}: {err.msg}" caller.msg_error.emit(error_msg) else: caller.file_exported.emit(file_path, 1.0, True) @@ -1700,63 +1694,6 @@ def get_items_for_commit(self, db_map, commit_id): return {} return worker.commit_cache.get(commit_id.db_id, {}) - @staticmethod - def get_all_multi_spine_db_editors(): - """Yields all instances of MultiSpineDBEditor currently open. - - Yields: - MultiSpineDBEditor - """ - for window in qApp.topLevelWindows(): # pylint: disable=undefined-variable - if isinstance(window, QWindow): - widget = QWidget.find(window.winId()) - if isinstance(widget, MultiSpineDBEditor) and widget.accepting_new_tabs: - yield widget - - def get_all_spine_db_editors(self): - """Yields all instances of SpineDBEditor currently open. - - Yields: - SpineDBEditor - """ - for w in self.get_all_multi_spine_db_editors(): - for k in range(w.tab_widget.count()): - yield w.tab_widget.widget(k) - - def _get_existing_spine_db_editor(self, db_url_codenames): - db_url_codenames = {str(url): codename for url, codename in db_url_codenames.items()} - for multi_db_editor in self.get_all_multi_spine_db_editors(): - for k in range(multi_db_editor.tab_widget.count()): - db_editor = multi_db_editor.tab_widget.widget(k) - if db_editor.db_url_codenames == db_url_codenames: - return multi_db_editor, db_editor - return None - - def open_db_editor(self, db_url_codenames, reuse_existing_editor): - """Opens a SpineDBEditor with given urls. Uses an existing MultiSpineDBEditor if any. - Also, if the same urls are open in an existing SpineDBEditor, just raises that one - instead of creating another. - - Args: - db_url_codenames (dict): mapping url to codename - reuse_existing_editor (bool): if True and the same URL is already open, just raise the existing window - """ - multi_db_editor = next(self.get_all_multi_spine_db_editors(), None) if reuse_existing_editor else None - if multi_db_editor is None: - multi_db_editor = MultiSpineDBEditor(self, db_url_codenames) - if multi_db_editor.tab_load_success: # don't open an editor if tabs were not loaded successfully - multi_db_editor.show() - return - existing = self._get_existing_spine_db_editor(db_url_codenames) - if existing is None: - multi_db_editor.add_new_tab(db_url_codenames) - else: - multi_db_editor, db_editor = existing - multi_db_editor.set_current_tab(db_editor) - if multi_db_editor.isMinimized(): - multi_db_editor.showNormal() - multi_db_editor.activateWindow() - @Slot(ValidationKey, bool) def _parameter_value_validated(self, key, is_valid): with suppress(KeyError): diff --git a/spinetoolbox/widgets/multi_tab_window.py b/spinetoolbox/widgets/multi_tab_window.py index dd4baa7eb..9c0d332b3 100644 --- a/spinetoolbox/widgets/multi_tab_window.py +++ b/spinetoolbox/widgets/multi_tab_window.py @@ -70,7 +70,7 @@ def _make_new_tab(self, *args, **kwargs): """Creates a new tab. Args: - *args: positional arguments neede to make a new tab + *args: positional arguments needed to make a new tab **kwargs: keyword arguments needed to make a new tab """ raise NotImplementedError() diff --git a/spinetoolbox/widgets/settings_widget.py b/spinetoolbox/widgets/settings_widget.py index 416992135..3152772b8 100644 --- a/spinetoolbox/widgets/settings_widget.py +++ b/spinetoolbox/widgets/settings_widget.py @@ -39,6 +39,7 @@ from ..kernel_fetcher import KernelFetcher from ..link import JumpLink, Link from ..project_item_icon import ProjectItemIcon +from ..spine_db_editor.editors import db_editor_registry from ..widgets.kernel_editor import MiniJuliaKernelEditor, MiniPythonKernelEditor from .add_up_spine_opt_wizard import AddUpSpineOptWizard from .install_julia_wizard import InstallJuliaWizard @@ -234,7 +235,7 @@ def update_ui(self): @Slot(bool) def set_hide_empty_classes(self, checked=False): - for db_editor in self.db_mngr.get_all_spine_db_editors(): + for db_editor in db_editor_registry.tabs(): db_editor.entity_tree_model.hide_empty_classes = checked @Slot(bool) @@ -266,7 +267,7 @@ def set_neg_weight_exp(self, value=None): self._set_graph_property("neg_weight_exp", value) def _set_graph_property(self, name, value): - for db_editor in self.db_mngr.get_all_spine_db_editors(): + for db_editor in db_editor_registry.tabs(): db_editor.ui.graphicsView.set_property(name, value) diff --git a/tests/project_item/test_logging_connection.py b/tests/project_item/test_logging_connection.py index 0afa3c13e..69c67a83e 100644 --- a/tests/project_item/test_logging_connection.py +++ b/tests/project_item/test_logging_connection.py @@ -103,9 +103,7 @@ def setUp(self): project.add_item(store_2) self._db_mngr_logger = MagicMock() self._url = "sqlite:///" + str(Path(self._temp_dir.name, "test_database.sqlite")) - self._db_map = self._toolbox.db_mngr.get_db_map( - self._url, self._db_mngr_logger, codename="database", create=True - ) + self._db_map = self._toolbox.db_mngr.get_db_map(self._url, self._db_mngr_logger, create=True) def tearDown(self): clean_up_toolbox(self._toolbox) diff --git a/tests/spine_db_editor/helpers.py b/tests/spine_db_editor/helpers.py index 658e77105..564885316 100644 --- a/tests/spine_db_editor/helpers.py +++ b/tests/spine_db_editor/helpers.py @@ -40,8 +40,9 @@ def _common_setup(self, url, create): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map(url, logger, codename=self.db_codename, create=create) - self._db_editor = SpineDBEditor(self._db_mngr, {url: self.db_codename}) + self._db_map = self._db_mngr.get_db_map(url, logger, create=create) + self._db_mngr.name_registry.register(url, self.db_codename) + self._db_editor = SpineDBEditor(self._db_mngr, [url]) QApplication.processEvents() def _common_tear_down(self): diff --git a/tests/spine_db_editor/mvcmodels/test_alternative_model.py b/tests/spine_db_editor/mvcmodels/test_alternative_model.py index 2471f0cc3..7f3360f85 100644 --- a/tests/spine_db_editor/mvcmodels/test_alternative_model.py +++ b/tests/spine_db_editor/mvcmodels/test_alternative_model.py @@ -30,9 +30,10 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, self.db_codename) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": self.db_codename}) + self._db_editor = SpineDBEditor(self._db_mngr) def tearDown(self): with ( @@ -129,11 +130,13 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map1 = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db_1", create=True) + self._db_map1 = self._db_mngr.get_db_map("sqlite://", logger, create=True) url2 = "sqlite:///" + str(Path(self._temp_dir.name, "db2.sqlite")) - self._db_map2 = self._db_mngr.get_db_map(url2, logger, codename=self.db_codename, create=True) + self._db_map2 = self._db_mngr.get_db_map(url2, logger, create=True) + self._db_mngr.name_registry.register(self._db_map1.sa_url, "test_db_1") + self._db_mngr.name_registry.register(self._db_map2.sa_url, self.db_codename) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db_1", url2: self.db_codename}) + self._db_editor = SpineDBEditor(self._db_mngr) def tearDown(self): with ( diff --git a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py index 8ac64dc3e..9c9577cfd 100644 --- a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py +++ b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py @@ -50,7 +50,8 @@ def setUp(self): app_settings = mock.MagicMock() logger = mock.MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="mock_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "mock_db") import_object_classes(self._db_map, ("dog", "fish")) import_object_parameters(self._db_map, (("dog", "breed"),)) import_objects(self._db_map, (("dog", "pluto"), ("fish", "nemo"))) diff --git a/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py b/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py index 3da5f0d33..45ddf56b0 100644 --- a/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py @@ -26,7 +26,8 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, self.db_codename) self._parent = QObject() self._model = FrozenTableModel(self._db_mngr, self._parent) diff --git a/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py b/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py index 7e8ebeae3..7db6ea771 100644 --- a/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py @@ -78,7 +78,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map(self._url, logger, codename="database") + self._db_map = self._db_mngr.get_db_map(self._url, logger) + self._db_mngr.name_registry.register(self._db_map.sa_url, "database") QApplication.processEvents() self._db_map.fetch_all() self._model = ItemMetadataTableModel(self._db_mngr, [self._db_map], None) diff --git a/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py b/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py index e49447bf6..8869c76d5 100644 --- a/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py @@ -29,7 +29,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "database") QApplication.processEvents() self._model = MetadataTableModel(self._db_mngr, [self._db_map], None) fetch_model(self._model) @@ -94,7 +95,8 @@ def test_adding_data_to_another_database(self): database_path = Path(temp_dir, "db.sqlite") url = "sqlite:///" + str(database_path) try: - db_map_2 = self._db_mngr.get_db_map(url, logger, codename="2nd database", create=True) + db_map_2 = self._db_mngr.get_db_map(url, logger, create=True) + self._db_mngr.name_registry.register(url, "2nd database") self._model.set_db_maps([self._db_map, db_map_2]) fetch_model(self._model) index = self._model.index(1, Column.DB_MAP) diff --git a/tests/spine_db_editor/mvcmodels/test_scenario_model.py b/tests/spine_db_editor/mvcmodels/test_scenario_model.py index f789ffea3..ee61a6f95 100644 --- a/tests/spine_db_editor/mvcmodels/test_scenario_model.py +++ b/tests/spine_db_editor/mvcmodels/test_scenario_model.py @@ -42,7 +42,8 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.db_url, self.db_codename) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": self.db_codename}) @@ -433,9 +434,11 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map1 = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db_1", create=True) + self._db_map1 = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map1.sa_url, "test_db_1") url2 = "sqlite:///" + str(Path(self._temp_dir.name, "db_2.sqlite")) - self._db_map2 = self._db_mngr.get_db_map(url2, logger, codename="test_db_2", create=True) + self._db_map2 = self._db_mngr.get_db_map(url2, logger, create=True) + self._db_mngr.name_registry.register(self._db_map2.sa_url, "test_db_2") with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db_1", url2: "test_db_2"}) diff --git a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py index 152d88a8c..acc4d692a 100644 --- a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py +++ b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py @@ -78,7 +78,8 @@ class TestSingleObjectParameterValueModel(TestCaseWithQApplication): def setUp(self): self._db_mngr = TestSpineDBManager(None, None) self._logger = MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite:///", self._logger, codename="Test database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite:///", self._logger, create=True) + self._db_mngr.name_registry.register(self._db_map.db_url, "Test database") def tearDown(self): self._db_mngr.close_all_sessions() diff --git a/tests/spine_db_editor/test_graphics_items.py b/tests/spine_db_editor/test_graphics_items.py index fdc3a3c1e..7c67c2978 100644 --- a/tests/spine_db_editor/test_graphics_items.py +++ b/tests/spine_db_editor/test_graphics_items.py @@ -32,7 +32,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "database") self._spine_db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "database"}) self._spine_db_editor.pivot_table_model = mock.MagicMock() self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) diff --git a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py index 7e04af412..7b8de19d3 100644 --- a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py +++ b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py @@ -31,7 +31,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self.db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self.mock_db_map = self.db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self.mock_db_map = self.db_mngr.get_db_map("sqlite://", logger, create=True) + self.db_mngr.name_registry.register("sqlite://", self.db_codename) self.spine_db_editor = SpineDBEditor(self.db_mngr, {"sqlite://": self.db_codename}) self.spine_db_editor.pivot_table_model = mock.MagicMock() self.spine_db_editor.entity_tree_model.hide_empty_classes = False diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditor.py b/tests/spine_db_editor/widgets/test_SpineDBEditor.py index 3de9699f5..b142e51e7 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditor.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditor.py @@ -263,7 +263,7 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) def tearDown(self): self._db_mngr.close_all_sessions() diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py b/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py index c7dc5ceff..585bef02b 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py @@ -13,6 +13,7 @@ """Contains unit tests for the SpineDBEditorBase class.""" import unittest from unittest import mock +from sqlalchemy.engine.url import make_url from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditorBase from tests.mock_helpers import TestCaseWithQApplication, TestSpineDBManager @@ -21,7 +22,7 @@ class TestSpineDBEditorBase(TestCaseWithQApplication): def setUp(self): """Builds a SpineDBEditorBase object.""" with ( - mock.patch("spinetoolbox.spine_db_worker.DatabaseMapping") as mock_DiffDBMapping, + mock.patch("spinetoolbox.spine_db_worker.DatabaseMapping") as mock_DBMapping, mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show"), ): @@ -29,14 +30,15 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwards: 0 self.db_mngr = TestSpineDBManager(mock_settings, None) - def DiffDBMapping_side_effect(url, codename=None, upgrade=False, create=False): + def DBMapping_side_effect(url, upgrade=False, create=False): mock_db_map = mock.MagicMock() - mock_db_map.codename = codename mock_db_map.db_url = url + mock_db_map.sa_url = make_url(url) return mock_db_map - mock_DiffDBMapping.side_effect = DiffDBMapping_side_effect + mock_DBMapping.side_effect = DBMapping_side_effect self.db_editor = SpineDBEditorBase(self.db_mngr) + self.db_editor.connect_signals() def tearDown(self): """Frees resources after each test.""" diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py b/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py index 0a3ef06e2..52bf1457b 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py @@ -34,8 +34,9 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwards: 0 self.db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self.db_map = self.db_mngr.get_db_map(url, logger, codename="db", create=True) - self.spine_db_editor = SpineDBEditor(self.db_mngr, {url: "db"}) + self.db_map = self.db_mngr.get_db_map(url, logger, create=True) + self.spine_db_editor = SpineDBEditor(self.db_mngr, [url]) + self.db_mngr.name_registry.register(self.db_map.sa_url, "db") self.spine_db_editor.pivot_table_model = mock.MagicMock() def tearDown(self): diff --git a/tests/spine_db_editor/widgets/test_add_items_dialog.py b/tests/spine_db_editor/widgets/test_add_items_dialog.py index 5d86717b7..7b7afdf96 100644 --- a/tests/spine_db_editor/widgets/test_add_items_dialog.py +++ b/tests/spine_db_editor/widgets/test_add_items_dialog.py @@ -37,7 +37,8 @@ def setUp(self): logger = mock.MagicMock() self._temp_dir = TemporaryDirectory() url = "sqlite:///" + self._temp_dir.name + "/db.sqlite" - self._db_map = self._db_mngr.get_db_map(url, logger, codename="mock_db", create=True) + self._db_map = self._db_mngr.get_db_map(url, logger, create=True) + self._db_mngr.name_registry.register(url, "mock_db") self._db_editor = SpineDBEditor(self._db_mngr, {url: "mock_db"}) def tearDown(self): diff --git a/tests/spine_db_editor/widgets/test_commit_viewer.py b/tests/spine_db_editor/widgets/test_commit_viewer.py index 51a4b4f2f..61d096589 100644 --- a/tests/spine_db_editor/widgets/test_commit_viewer.py +++ b/tests/spine_db_editor/widgets/test_commit_viewer.py @@ -30,7 +30,7 @@ def setUp(self): self._db_mngr = SpineDBManager(mock_settings, None, synchronous=True) logger = mock.MagicMock() url = "sqlite://" - self._db_map = self._db_mngr.get_db_map(url, logger, codename="mock_db", create=True) + self._db_map = self._db_mngr.get_db_map(url, logger, create=True) with mock.patch.object(QSplitter, "restoreState"): self._commit_viewer = CommitViewer(mock_settings, self._db_mngr, self._db_map) diff --git a/tests/spine_db_editor/widgets/test_custom_menus.py b/tests/spine_db_editor/widgets/test_custom_menus.py index dc9b0d2cd..7463d814f 100644 --- a/tests/spine_db_editor/widgets/test_custom_menus.py +++ b/tests/spine_db_editor/widgets/test_custom_menus.py @@ -14,8 +14,9 @@ import unittest from unittest import mock from PySide6.QtWidgets import QWidget +from spinetoolbox.database_display_names import NameRegistry from spinetoolbox.helpers import signal_waiter -from spinetoolbox.spine_db_editor.widgets.custom_menus import TabularViewCodenameFilterMenu +from spinetoolbox.spine_db_editor.widgets.custom_menus import TabularViewDatabaseNameFilterMenu from tests.mock_helpers import TestCaseWithQApplication @@ -28,11 +29,14 @@ def tearDown(self): def test_init_fills_filter_list_with_database_codenames(self): db_map1 = mock.MagicMock() - db_map1.codename = "db map 1" + db_map1.sa_url = "sqlite://a" db_map2 = mock.MagicMock() - db_map2.codename = "db map 2" + db_map2.sa_url = "sqlite://b" db_maps = [db_map1, db_map2] - menu = TabularViewCodenameFilterMenu(self._parent, db_maps, "database") + name_registry = NameRegistry() + name_registry.register(db_map1.sa_url, "db map 1") + name_registry.register(db_map2.sa_url, "db map 2") + menu = TabularViewDatabaseNameFilterMenu(self._parent, db_maps, "database", name_registry) self.assertIs(menu.anchor, self._parent) filter_list_model = menu._filter._filter_model filter_rows = [] @@ -42,11 +46,14 @@ def test_init_fills_filter_list_with_database_codenames(self): def test_filter_changed_signal_is_emitted_correctly(self): db_map1 = mock.MagicMock() - db_map1.codename = "db map 1" + db_map1.sa_url = "sqlite://a" db_map2 = mock.MagicMock() - db_map2.codename = "db map 2" + db_map2.sa_url = "sqlite://b" db_maps = [db_map1, db_map2] - menu = TabularViewCodenameFilterMenu(self._parent, db_maps, "database") + name_registry = NameRegistry() + name_registry.register(db_map1.sa_url, "db map 1") + name_registry.register(db_map2.sa_url, "db map 2") + menu = TabularViewDatabaseNameFilterMenu(self._parent, db_maps, "database", name_registry) with signal_waiter(menu.filterChanged, timeout=0.1) as waiter: menu._clear_filter() waiter.wait() diff --git a/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py b/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py index af08dd5f8..b66b2def2 100644 --- a/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py +++ b/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py @@ -30,7 +30,7 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = SpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map(url, logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map(url, logger, create=True) QApplication.processEvents() def tearDown(self): diff --git a/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py index f920bbfb3..e356fa2c9 100644 --- a/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py +++ b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py @@ -11,10 +11,15 @@ ###################################################################################################################### """Unit tests for SpineDBEditor classes.""" +from pathlib import Path from tempfile import TemporaryDirectory -from PySide6.QtCore import QPoint -from spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor -from tests.mock_helpers import FakeDataStore, clean_up_toolbox, create_toolboxui_with_project +from unittest.mock import MagicMock +from PySide6.QtCore import QPoint, QSettings +from PySide6.QtWidgets import QApplication +from spinetoolbox.spine_db_editor.editors import db_editor_registry +from spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor, open_db_editor +from spinetoolbox.spine_db_manager import SpineDBManager +from tests.mock_helpers import FakeDataStore, TestCaseWithQApplication, clean_up_toolbox, create_toolboxui_with_project from .spine_db_editor_test_base import DBEditorTestBase @@ -32,7 +37,7 @@ def tearDown(self): def test_multi_spine_db_editor(self): self.db_mngr.setParent(self._toolbox) multieditor = MultiSpineDBEditor(self.db_mngr) - multieditor.add_new_tab() + multieditor.add_new_tab([]) self.assertEqual(1, multieditor.tab_widget.count()) multieditor.make_context_menu(0) multieditor.show_plus_button_context_menu(QPoint(0, 0)) @@ -40,3 +45,38 @@ def test_multi_spine_db_editor(self): self._toolbox.project()._project_items = {"a": FakeDataStore("a")} multieditor.show_plus_button_context_menu(QPoint(0, 0)) multieditor._take_tab(0) + + +class TestOpenDBEditor(TestCaseWithQApplication): + def setUp(self): + self._temp_dir = TemporaryDirectory() + db_path = Path(self._temp_dir.name, "db.sqlite") + self._db_url = "sqlite:///" + str(db_path) + self._db_mngr = SpineDBManager(QSettings(), None) + self._logger = MagicMock() + + def tearDown(self): + self._db_mngr.close_all_sessions() + self._db_mngr.clean_up() + # Database connection may still be open. Retry cleanup until it succeeds. + running = True + while running: + QApplication.processEvents() + try: + self._temp_dir.cleanup() + except NotADirectoryError: + pass + else: + running = False + + def test_open_db_editor(self): + self.assertFalse(db_editor_registry.has_windows()) + open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) + self.assertEqual(len(db_editor_registry.windows()), 1) + open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) + self.assertEqual(len(db_editor_registry.windows()), 1) + editor = db_editor_registry.windows()[0] + self.assertEqual(editor.tab_widget.count(), 1) + for editor in db_editor_registry.windows(): + QApplication.processEvents() + editor.close() diff --git a/tests/test_SpineDBManager.py b/tests/test_SpineDBManager.py index fc908e693..3f13f7401 100644 --- a/tests/test_SpineDBManager.py +++ b/tests/test_SpineDBManager.py @@ -318,7 +318,8 @@ def setUp(self): self.editor = MagicMock() self._temp_dir = TemporaryDirectory() url = "sqlite:///" + self._temp_dir.name + "/db.sqlite" - self._db_map = self._db_mngr.get_db_map(url, logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map(url, logger, create=True) + self._db_mngr.name_registry.register(url, "test_import_export_data_db") def tearDown(self): self._db_mngr.close_all_sessions() @@ -402,52 +403,6 @@ def test_import_parameter_value_lists(self): ) -class TestOpenDBEditor(TestCaseWithQApplication): - def setUp(self): - self._temp_dir = TemporaryDirectory() - db_path = Path(self._temp_dir.name, "db.sqlite") - self._db_url = "sqlite:///" + str(db_path) - self._db_mngr = SpineDBManager(QSettings(), None) - self._logger = MagicMock() - - @unittest.skip("FIXME") - def test_open_db_editor(self): - editors = list(self._db_mngr.get_all_multi_spine_db_editors()) - self.assertFalse(editors) - self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) - editors = list(self._db_mngr.get_all_multi_spine_db_editors()) - self.assertEqual(len(editors), 1) - self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) - editors = list(self._db_mngr.get_all_multi_spine_db_editors()) - self.assertEqual(len(editors), 1) - self._db_mngr.open_db_editor({self._db_url: "not_the_same"}, reuse_existing_editor=True) - self.assertEqual(len(editors), 1) - editor = editors[0] - self.assertEqual(editor.tab_widget.count(), 1) - # Finally try to open the first tab again - self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) - editors = list(self._db_mngr.get_all_multi_spine_db_editors()) - editor = editors[0] - self.assertEqual(editor.tab_widget.count(), 1) - for editor in self._db_mngr.get_all_multi_spine_db_editors(): - QApplication.processEvents() - editor.close() - - def tearDown(self): - self._db_mngr.close_all_sessions() - self._db_mngr.clean_up() - # Database connection may still be open. Retry cleanup until it succeeds. - running = True - while running: - QApplication.processEvents() - try: - self._temp_dir.cleanup() - except NotADirectoryError: - pass - else: - running = False - - class TestDuplicateEntity(TestCaseWithQApplication): @classmethod def setUpClass(cls): @@ -457,7 +412,8 @@ def setUpClass(cls): def setUp(self): self._db_mngr = SpineDBManager(QSettings(), None) logger = MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, self.db_codename) def tearDown(self): self._db_mngr.close_all_sessions() @@ -526,7 +482,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = SpineDBManager(mock_settings, None) self._logger = MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "test_update_expanded_parameter_values_db") def tearDown(self): self._db_mngr.close_all_sessions() @@ -571,7 +528,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = SpineDBManager(mock_settings, None) self._logger = MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="database", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "test_remove_scenario_alternative_db") def tearDown(self): self._db_mngr.close_all_sessions() diff --git a/tests/test_database_display_names.py b/tests/test_database_display_names.py new file mode 100644 index 000000000..0697dc219 --- /dev/null +++ b/tests/test_database_display_names.py @@ -0,0 +1,102 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### +import sys +import unittest +from unittest import mock +from sqlalchemy.engine.url import make_url +from spinetoolbox.database_display_names import NameRegistry, suggest_display_name +from spinetoolbox.helpers import signal_waiter +from tests.mock_helpers import TestCaseWithQApplication + + +class TestNameRegistry(TestCaseWithQApplication): + def test_display_name_for_unregistered_url(self): + registry = NameRegistry() + self.assertEqual(registry.display_name("mysql://db.example.com/best_database"), "best_database") + sa_url = make_url("mysql://db.example.com/even_better_database") + self.assertEqual(registry.display_name(sa_url), "even_better_database") + + def test_display_name_for_registered_url(self): + registry = NameRegistry() + url = "mysql://db.example.com/best_database" + registry.register(url, "Best database") + self.assertEqual(registry.display_name(url), "Best database") + sa_url = make_url("mysql://db.example.com/even_better_database") + registry.register(sa_url, "Even better database") + self.assertEqual(registry.display_name(sa_url), "Even better database") + + def test_multiple_registered_names_gives_simple_database_name(self): + registry = NameRegistry() + url = "mysql://db.example.com/best_database" + with signal_waiter(registry.display_name_changed, timeout=0.1) as waiter: + registry.register(url, "Best database") + self.assertEqual(waiter.args, (url, "Best database")) + with signal_waiter(registry.display_name_changed, timeout=0.1) as waiter: + registry.register(url, "Even better database") + self.assertEqual(waiter.args, (url, "best_database")) + self.assertEqual(registry.display_name(url), "best_database") + + def test_unregister(self): + registry = NameRegistry() + url = "mysql://db.example.com/best_database" + with signal_waiter(registry.display_name_changed, timeout=0.1) as waiter: + registry.register(url, "Best database") + self.assertEqual(waiter.args, (url, "Best database")) + self.assertEqual(registry.display_name(url), "Best database") + with signal_waiter(registry.display_name_changed, timeout=0.1) as waiter: + registry.unregister(url, "Best database") + self.assertEqual(waiter.args, (url, "best_database")) + self.assertEqual(registry.display_name(url), "best_database") + + def test_unregister_one_of_two_names(self): + registry = NameRegistry() + url = "mysql://db.example.com/best_database" + registry.register(url, "Database 1") + registry.register(url, "Database 2") + self.assertEqual(registry.display_name(url), "best_database") + with signal_waiter(registry.display_name_changed, timeout=0.1) as waiter: + registry.unregister(url, "Database 1") + self.assertEqual(waiter.args, (url, "Database 2")) + self.assertEqual(registry.display_name(url), "Database 2") + + def test_unregister_one_of_three_names(self): + registry = NameRegistry() + url = "mysql://db.example.com/best_database" + registry.register(url, "Database 1") + registry.register(url, "Database 2") + registry.register(url, "Database 3") + self.assertEqual(registry.display_name(url), "best_database") + with mock.patch.object(registry, "display_name_changed") as name_changed_signal: + registry.unregister(url, "Database 3") + name_changed_signal.emit.assert_not_called() + self.assertEqual(registry.display_name(url), "best_database") + + +class TestSuggestDisplayName(unittest.TestCase): + def test_mysql_url_returns_database_name(self): + sa_url = make_url("mysql://db.example.com/my_lovely_db") + self.assertEqual(suggest_display_name(sa_url), "my_lovely_db") + + def test_sqlite_url_returns_file_name_without_extension(self): + path = "c:\path\to\my_lovely_db.sqlite" if sys.platform == "win32" else "/path/to/my_lovely_db.sqlite" + sa_url = make_url(r"sqlite:///" + path) + self.assertEqual(suggest_display_name(sa_url), "my_lovely_db") + + def test_in_memory_sqlite_url_returns_random_hash(self): + sa_url = make_url(r"sqlite://") + name = suggest_display_name(sa_url) + self.assertTrue(isinstance(name, str)) + self.assertTrue(bool(name)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_multi_tab_windows.py b/tests/test_multi_tab_windows.py new file mode 100644 index 000000000..702002259 --- /dev/null +++ b/tests/test_multi_tab_windows.py @@ -0,0 +1,56 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# This file is part of Spine Toolbox. +# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) +# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General +# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with +# this program. If not, see . +###################################################################################################################### +import unittest +from unittest import mock +from spinetoolbox.multi_tab_windows import MultiTabWindowRegistry + + +class TestMultiTabWindowRegistry(unittest.TestCase): + def test_initialization(self): + registry = MultiTabWindowRegistry() + self.assertFalse(registry.has_windows()) + self.assertEqual(registry.windows(), []) + self.assertEqual(registry.tabs(), []) + self.assertIsNone(registry.get_some_window()) + + def test_register_window(self): + registry = MultiTabWindowRegistry() + window = mock.MagicMock() + registry.register_window(window) + self.assertEqual(registry.windows(), [window]) + + def test_unregister_window(self): + registry = MultiTabWindowRegistry() + window = mock.MagicMock() + registry.register_window(window) + self.assertTrue(registry.has_windows()) + registry.unregister_window(window) + self.assertEqual(registry.windows(), []) + + def test_get_some_window(self): + registry = MultiTabWindowRegistry() + window = mock.MagicMock() + registry.register_window(window) + self.assertIs(registry.get_some_window(), window) + + def test_tabs(self): + registry = MultiTabWindowRegistry() + window = mock.MagicMock() + window.tab_widget.count.return_value = 1 + tab = mock.MagicMock() + window.tab_widget.widget.return_value = tab + registry.register_window(window) + self.assertEqual(registry.tabs(), [tab]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_parameter_type_validation.py b/tests/test_parameter_type_validation.py index 5aca06561..8f4c20db9 100644 --- a/tests/test_parameter_type_validation.py +++ b/tests/test_parameter_type_validation.py @@ -29,7 +29,8 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, self.db_codename) self._db_mngr.parameter_type_validator.set_interval(0) def tearDown(self): diff --git a/tests/test_spine_db_fetcher.py b/tests/test_spine_db_fetcher.py index 4990c6980..99d8748c3 100644 --- a/tests/test_spine_db_fetcher.py +++ b/tests/test_spine_db_fetcher.py @@ -31,7 +31,8 @@ def setUp(self): app_settings = MagicMock() self._logger = MagicMock() # Collects error messages therefore handy for debugging. self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="db_fetcher_test_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, create=True) + self._db_mngr.name_registry.register(self._db_map.sa_url, "db_fetcher_test_db") def tearDown(self): self._db_mngr.close_all_sessions() From f051339b9f7462c0d55b316c82d8f530b0facdb2 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Wed, 30 Oct 2024 14:14:10 +0200 Subject: [PATCH 2/4] Release 0.9.1 --- CHANGELOG.md | 12 ++---------- pyproject.toml | 4 ++-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7458f4b..6e0db0833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,19 +3,11 @@ All **notable** changes to this project are documented here. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) -## [Unreleased] - -### Added - -### Changed - -### Deprecated +## [0.9.1] ### Removed -### Fixed - -### Security +- Removed support for MSSQL dialect. It did not work anyway. ## [0.9.0] diff --git a/pyproject.toml b/pyproject.toml index 4a5232eae..f194bb2b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "PySide6 >= 6.5.0, != 6.5.3, != 6.6.3, != 6.7.0, < 6.8", "jupyter_client >=6.0", "qtconsole >=5.1", - "spinedb_api>=0.32.0", + "spinedb_api>=0.32.1", "spine_engine>=0.25.0", "numpy >=1.20.2", "matplotlib >= 3.5", @@ -26,7 +26,7 @@ dependencies = [ "Pygments >=2.8", "jill >=0.9.2", "pyzmq >=21.0", - "spine_items>=0.23.0", + "spine_items>=0.23.1", ] [project.urls] From 58b502069b931b125d27878fe759c0979978aaa2 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Mon, 4 Nov 2024 13:27:51 +0200 Subject: [PATCH 3/4] Put CHANGELOG.md back to development mode --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0db0833..5072ec659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All **notable** changes to this project are documented here. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + ## [0.9.1] ### Removed From d2275e910cc2977dbb8559ed52f236e9d825e69e Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Thu, 7 Nov 2024 08:34:21 +0200 Subject: [PATCH 4/4] Fix opening DB editor tab when editor window has a empty tab only --- .../widgets/multi_spine_db_editor.py | 2 +- spinetoolbox/ui_main.py | 2 +- .../widgets/test_multi_spine_db_editor.py | 54 +++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py b/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py index c894a5047..d4027c267 100644 --- a/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py +++ b/spinetoolbox/spine_db_editor/widgets/multi_spine_db_editor.py @@ -213,7 +213,7 @@ def _get_existing_spine_db_editor(db_urls): for multi_db_editor in db_editor_registry.windows(): for k in range(multi_db_editor.tab_widget.count()): db_editor = multi_db_editor.tab_widget.widget(k) - if all(url in db_urls for url in db_editor.db_urls): + if db_editor.db_urls and all(url in db_urls for url in db_editor.db_urls): return multi_db_editor, db_editor return None diff --git a/spinetoolbox/ui_main.py b/spinetoolbox/ui_main.py index a89261653..09fc15329 100644 --- a/spinetoolbox/ui_main.py +++ b/spinetoolbox/ui_main.py @@ -1329,7 +1329,7 @@ def open_specification_file(self, index): @Slot(bool) def new_db_editor(self): - editor = MultiSpineDBEditor(self.db_mngr, {}) + editor = MultiSpineDBEditor(self.db_mngr, []) editor.show() @Slot() diff --git a/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py index e356fa2c9..2a13bf2ed 100644 --- a/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py +++ b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py @@ -13,10 +13,10 @@ """Unit tests for SpineDBEditor classes.""" from pathlib import Path from tempfile import TemporaryDirectory -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from PySide6.QtCore import QPoint, QSettings from PySide6.QtWidgets import QApplication -from spinetoolbox.spine_db_editor.editors import db_editor_registry +from spinetoolbox.multi_tab_windows import MultiTabWindowRegistry from spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor, open_db_editor from spinetoolbox.spine_db_manager import SpineDBManager from tests.mock_helpers import FakeDataStore, TestCaseWithQApplication, clean_up_toolbox, create_toolboxui_with_project @@ -54,6 +54,7 @@ def setUp(self): self._db_url = "sqlite:///" + str(db_path) self._db_mngr = SpineDBManager(QSettings(), None) self._logger = MagicMock() + self._db_editor_registry = MultiTabWindowRegistry() def tearDown(self): self._db_mngr.close_all_sessions() @@ -69,14 +70,45 @@ def tearDown(self): else: running = False - def test_open_db_editor(self): - self.assertFalse(db_editor_registry.has_windows()) - open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) - self.assertEqual(len(db_editor_registry.windows()), 1) - open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) - self.assertEqual(len(db_editor_registry.windows()), 1) - editor = db_editor_registry.windows()[0] - self.assertEqual(editor.tab_widget.count(), 1) - for editor in db_editor_registry.windows(): + def _close_windows(self): + for editor in self._db_editor_registry.windows(): QApplication.processEvents() editor.close() + self.assertFalse(self._db_editor_registry.has_windows()) + + def test_open_db_editor(self): + with ( + patch( + "spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor.db_editor_registry", + self._db_editor_registry, + ), + patch("spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor.MultiSpineDBEditor.show") as mock_show, + ): + self.assertFalse(self._db_editor_registry.has_windows()) + open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) + mock_show.assert_called_once() + self.assertEqual(len(self._db_editor_registry.windows()), 1) + open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) + self.assertEqual(len(self._db_editor_registry.windows()), 1) + editor = self._db_editor_registry.windows()[0] + self.assertEqual(editor.tab_widget.count(), 1) + self._close_windows() + + def test_open_db_in_tab_when_editor_has_an_empty_tab(self): + with ( + patch( + "spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor.db_editor_registry", + self._db_editor_registry, + ), + patch("spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor.MultiSpineDBEditor.show") as mock_show, + ): + self.assertFalse(self._db_editor_registry.has_windows()) + window = MultiSpineDBEditor(self._db_mngr, []) + self.assertEqual(window.tab_widget.count(), 1) + tab = window.tab_widget.widget(0) + self.assertEqual(tab.db_urls, []) + open_db_editor([self._db_url], self._db_mngr, reuse_existing_editor=True) + self.assertEqual(window.tab_widget.count(), 2) + tab = window.tab_widget.widget(1) + self.assertEqual(tab.db_urls, [self._db_url]) + self._close_windows()