diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7458f4b..5072ec659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) ### Security +## [0.9.1] + +### Removed + +- Removed support for MSSQL dialect. It did not work anyway. + ## [0.9.0] Dropped support for Python 3.8. 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] 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..d4027c267 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 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 + + +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/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/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 384471734..c6ff014a7 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..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 @@ -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, patch +from PySide6.QtCore import QPoint, QSettings +from PySide6.QtWidgets import QApplication +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 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,70 @@ 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() + self._db_editor_registry = MultiTabWindowRegistry() + + 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 _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() 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()